diff --git a/.changeset/typed-subsumption.md b/.changeset/typed-subsumption.md index 956889ddd..2a6ed2ea7 100644 --- a/.changeset/typed-subsumption.md +++ b/.changeset/typed-subsumption.md @@ -8,7 +8,7 @@ Now that the structural contract holds, `from()`/`to()`/`fromDynamic()`/`toDynam 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` — the alias type is untyped, since no schema relationship is claimed. Because it is one value, 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`). Omitting the options argument, passing `{}`, and passing an explicit `undefined` all mean "take the store default", so an options bag can be forwarded through a caller's own helper without special-casing the empty case. A bag that can carry an axis the call site does not pin — `{ expansion?: "exact" }` from a wrapper that only ever narrows, or the option type itself — forwards as well, and types its alias conservatively (untyped, as `"narrower"` does), since the axis is unknown at compile time. `queryDefaults.expansion` and `createQueryBuilder`'s `defaultExpansion` accept a stated `undefined` for the same forwarding reason. `store.search()` spells the same axis under the same name (`expansion: "exact" | "subclasses"`, default `"exact"` — the search facade is not polymorphic by default). -Every meta-edge factory — not only `subClassOf`/`equivalentTo` — now returns a relation typed with its meta-edge name literal and its endpoint kinds, 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 whose ontology mixes `subClassOf` with `broader` / `inverseOf` / `disjointWith` keeps exact alias types for the kinds no subsumption relation names. The flip side: a relation a caller annotates as the bare `OntologyRelation` has lost the literals the alias computation reads, so it now widens EVERY alias in that graph conservatively rather than reporting an affected kind as unaffected. +Every meta-edge factory — not only `subClassOf`/`equivalentTo` — now returns a relation typed with its meta-edge name literal and its endpoint kinds, 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 whose ontology mixes `subClassOf` with `broader` / `inverseOf` / `disjointWith` keeps exact alias types for the kinds no subsumption relation names. The flip side: a relation a caller annotates as the bare `OntologyRelation` has lost the literals the alias computation reads, so it now widens EVERY alias in that graph conservatively rather than reporting an affected kind as unaffected. An endpoint declared as a union of node types keeps its literals and stays precise — it widens exactly the kinds the union can hold, and nothing else. ## Breaking changes @@ -16,7 +16,7 @@ Every meta-edge factory — not only `subClassOf`/`equivalentTo` — now returns - **`subClassOf`/`equivalentTo` are now generic and carry a structural contract.** Code that annotates a relation's result as `OntologyRelation` still compiles unchanged; code declaring a pair whose child does not structurally extend the parent no longer compiles, and the identical pair is refused at registry build even for callers that bypass the compile-time check (an extension's raw `{ metaEdge: "subClassOf", from, to }` relation, or a persisted document). - **Supertype queries now return subtype rows.** `from()`/`to()`/`fromDynamic()`/`toDynamic()` default to `expansion: "subclasses"`. `search()` and the collection APIs are unchanged and stay exact-kind. - **The `includeSubClasses` / `includeNarrower` booleans are replaced by one `expansion` option** on `from()`/`to()`/`fromDynamic()`/`toDynamic()`: `{ includeSubClasses: false }` becomes `{ expansion: "exact" }`, `{ includeSubClasses: true }` becomes `{ expansion: "subclasses" }`, and `{ includeNarrower: true }` becomes `{ expansion: "narrower" }`. `createStore(..., { queryDefaults: { includeSubClasses } })` becomes `queryDefaults: { expansion }`, `createQueryBuilder`'s `defaultIncludeSubClasses` becomes `defaultExpansion`, and `store.search()`'s `includeSubClasses: true` becomes `expansion: "subclasses"`. `QUERY_ALIAS_EXPANSION_CONFLICT` is removed with the contradiction it described; `QUERY_ALIAS_EXPANSION_INVALID` replaces it for an out-of-domain value. -- **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, because the row may be a subtype. A graph that declares no subsumption relations is unaffected. +- **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, because the row may be a subtype. A relation endpoint declared as a UNION of kinds — a caller helper that declares a subclass of either media root, whether spelled `typeof Audio | typeof Video` or `NodeType<"Audio" | "Video">` — widens every kind the union can hold, since the declared type does not say which one the call passed. A graph that declares no subsumption relations is unaffected. - **`QueryAst`'s `start.includeSubClasses: boolean` becomes `start.expansion: "exact" | "subclasses" | "narrower"`**, so a `narrower`-expanded alias is describable. Only affects code inspecting a compiled AST directly. - **`equivalentTo(nodeKind, edgeKind)` no longer compiles.** The new overload set accepts `(NodeType, NodeType)`, `(NodeType | AnyEdgeType, string)` (the IRI form), and `(AnyEdgeType, NodeType)`, but not a node kind followed by an edge kind — this pairing has always been refused at registry build (`ONTOLOGY_EQUIVALENCE_INVALID_CLASS`) and is now also refused at compile time. diff --git a/apps/docs/src/content/docs/queries/source.md b/apps/docs/src/content/docs/queries/source.md index c3d9967df..d4cdc742c 100644 --- a/apps/docs/src/content/docs/queries/source.md +++ b/apps/docs/src/content/docs/queries/source.md @@ -111,6 +111,16 @@ 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. + **Limitation — `evolve()`-declared subsumption isn't visible to the alias type.** The `kind`/`NodeId` widening above is computed from your compile-time graph definition. A `subClassOf` an [extension](/graph-extensions) diff --git a/packages/typegraph/etc/typegraph-graph-merge.api.md b/packages/typegraph/etc/typegraph-graph-merge.api.md index c81ec6746..b8e50231b 100644 --- a/packages/typegraph/etc/typegraph-graph-merge.api.md +++ b/packages/typegraph/etc/typegraph-graph-merge.api.md @@ -7239,30 +7239,20 @@ type SubgraphResult = NodeKinds, type SubgraphResultEdgeKinds, C extends boolean | undefined> = true extends C ? EdgeKinds : EK; // @public -type SubsumptionAffected = OntologyTypeErased extends true ? true : true extends SubsumptionLiteralsErased ? true : [ -Extract = OntologyTypeErased extends true ? true : true extends SubsumptionLiteralsErased ? true : true extends SubsumptionElementNamesKind ? true : false; + +// @public +type SubsumptionElementNamesKind = Relation extends unknown ? Relation extends ({ metaEdge: { - name: "equivalentTo" | "sameAs"; - }; - to: { - kind: K; + name: infer Name extends string; }; -}> -] extends [never] ? false : true; + from: infer From; + to: infer To; +}) ? [ +Extract, { + kind: string; +}>["kind"], K> +] extends [never] ? false : true : false : never; // @public type SubsumptionLiteralsErased = Relation extends unknown ? Relation extends ({ diff --git a/packages/typegraph/etc/typegraph-interchange.api.md b/packages/typegraph/etc/typegraph-interchange.api.md index 88a3a0400..b5145d592 100644 --- a/packages/typegraph/etc/typegraph-interchange.api.md +++ b/packages/typegraph/etc/typegraph-interchange.api.md @@ -6329,30 +6329,20 @@ type SubgraphResult = NodeKinds, type SubgraphResultEdgeKinds, C extends boolean | undefined> = true extends C ? EdgeKinds : EK; // @public -type SubsumptionAffected = OntologyTypeErased extends true ? true : true extends SubsumptionLiteralsErased ? true : [ -Extract = OntologyTypeErased extends true ? true : true extends SubsumptionLiteralsErased ? true : true extends SubsumptionElementNamesKind ? true : false; + +// @public +type SubsumptionElementNamesKind = Relation extends unknown ? Relation extends ({ metaEdge: { - name: "equivalentTo" | "sameAs"; - }; - to: { - kind: K; + name: infer Name extends string; }; -}> -] extends [never] ? false : true; + from: infer From; + to: infer To; +}) ? [ +Extract, { + kind: string; +}>["kind"], K> +] extends [never] ? false : true : false : never; // @public type SubsumptionLiteralsErased = Relation extends unknown ? Relation extends ({ diff --git a/packages/typegraph/etc/typegraph-postgres-pglite.api.md b/packages/typegraph/etc/typegraph-postgres-pglite.api.md index 0cdfb87c5..9859bd43d 100644 --- a/packages/typegraph/etc/typegraph-postgres-pglite.api.md +++ b/packages/typegraph/etc/typegraph-postgres-pglite.api.md @@ -5762,30 +5762,20 @@ type SubgraphResult = NodeKinds, type SubgraphResultEdgeKinds, C extends boolean | undefined> = true extends C ? EdgeKinds : EK; // @public -type SubsumptionAffected = OntologyTypeErased extends true ? true : true extends SubsumptionLiteralsErased ? true : [ -Extract = OntologyTypeErased extends true ? true : true extends SubsumptionLiteralsErased ? true : true extends SubsumptionElementNamesKind ? true : false; + +// @public +type SubsumptionElementNamesKind = Relation extends unknown ? Relation extends ({ metaEdge: { - name: "equivalentTo" | "sameAs"; - }; - to: { - kind: K; + name: infer Name extends string; }; -}> -] extends [never] ? false : true; + from: infer From; + to: infer To; +}) ? [ +Extract, { + kind: string; +}>["kind"], K> +] extends [never] ? false : true : false : never; // @public type SubsumptionLiteralsErased = Relation extends unknown ? Relation extends ({ diff --git a/packages/typegraph/etc/typegraph-profiler.api.md b/packages/typegraph/etc/typegraph-profiler.api.md index ecd9e8d6f..563a860e1 100644 --- a/packages/typegraph/etc/typegraph-profiler.api.md +++ b/packages/typegraph/etc/typegraph-profiler.api.md @@ -5782,30 +5782,20 @@ type SubgraphResult = NodeKinds, type SubgraphResultEdgeKinds, C extends boolean | undefined> = true extends C ? EdgeKinds : EK; // @public -type SubsumptionAffected = OntologyTypeErased extends true ? true : true extends SubsumptionLiteralsErased ? true : [ -Extract = OntologyTypeErased extends true ? true : true extends SubsumptionLiteralsErased ? true : true extends SubsumptionElementNamesKind ? true : false; + +// @public +type SubsumptionElementNamesKind = Relation extends unknown ? Relation extends ({ metaEdge: { - name: "equivalentTo" | "sameAs"; - }; - to: { - kind: K; + name: infer Name extends string; }; -}> -] extends [never] ? false : true; + from: infer From; + to: infer To; +}) ? [ +Extract, { + kind: string; +}>["kind"], K> +] extends [never] ? false : true : false : never; // @public type SubsumptionLiteralsErased = Relation extends unknown ? Relation extends ({ diff --git a/packages/typegraph/etc/typegraph-provenance.api.md b/packages/typegraph/etc/typegraph-provenance.api.md index dbc7d4928..c5881ce38 100644 --- a/packages/typegraph/etc/typegraph-provenance.api.md +++ b/packages/typegraph/etc/typegraph-provenance.api.md @@ -5776,30 +5776,20 @@ type SubgraphResult = NodeKinds, type SubgraphResultEdgeKinds, C extends boolean | undefined> = true extends C ? EdgeKinds : EK; // @public -type SubsumptionAffected = OntologyTypeErased extends true ? true : true extends SubsumptionLiteralsErased ? true : [ -Extract = OntologyTypeErased extends true ? true : true extends SubsumptionLiteralsErased ? true : true extends SubsumptionElementNamesKind ? true : false; + +// @public +type SubsumptionElementNamesKind = Relation extends unknown ? Relation extends ({ metaEdge: { - name: "equivalentTo" | "sameAs"; - }; - to: { - kind: K; + name: infer Name extends string; }; -}> -] extends [never] ? false : true; + from: infer From; + to: infer To; +}) ? [ +Extract, { + kind: string; +}>["kind"], K> +] extends [never] ? false : true : false : never; // @public type SubsumptionLiteralsErased = Relation extends unknown ? Relation extends ({ diff --git a/packages/typegraph/etc/typegraph-sqlite-local.api.md b/packages/typegraph/etc/typegraph-sqlite-local.api.md index 8b85b54ac..8a02905fb 100644 --- a/packages/typegraph/etc/typegraph-sqlite-local.api.md +++ b/packages/typegraph/etc/typegraph-sqlite-local.api.md @@ -5789,30 +5789,20 @@ type SubgraphResult = NodeKinds, type SubgraphResultEdgeKinds, C extends boolean | undefined> = true extends C ? EdgeKinds : EK; // @public -type SubsumptionAffected = OntologyTypeErased extends true ? true : true extends SubsumptionLiteralsErased ? true : [ -Extract = OntologyTypeErased extends true ? true : true extends SubsumptionLiteralsErased ? true : true extends SubsumptionElementNamesKind ? true : false; + +// @public +type SubsumptionElementNamesKind = Relation extends unknown ? Relation extends ({ metaEdge: { - name: "equivalentTo" | "sameAs"; - }; - to: { - kind: K; + name: infer Name extends string; }; -}> -] extends [never] ? false : true; + from: infer From; + to: infer To; +}) ? [ +Extract, { + kind: string; +}>["kind"], K> +] extends [never] ? false : true : false : never; // @public type SubsumptionLiteralsErased = Relation extends unknown ? Relation extends ({ diff --git a/packages/typegraph/etc/typegraph.api.md b/packages/typegraph/etc/typegraph.api.md index f0b87685a..dd5b960b5 100644 --- a/packages/typegraph/etc/typegraph.api.md +++ b/packages/typegraph/etc/typegraph.api.md @@ -7975,30 +7975,20 @@ export type SubsetNode> = { }[K]; // @public -type SubsumptionAffected = OntologyTypeErased extends true ? true : true extends SubsumptionLiteralsErased ? true : [ -Extract = OntologyTypeErased extends true ? true : true extends SubsumptionLiteralsErased ? true : true extends SubsumptionElementNamesKind ? true : false; + +// @public +type SubsumptionElementNamesKind = Relation extends unknown ? Relation extends ({ metaEdge: { - name: "equivalentTo" | "sameAs"; - }; - to: { - kind: K; + name: infer Name extends string; }; -}> -] extends [never] ? false : true; + from: infer From; + to: infer To; +}) ? [ +Extract, { + kind: string; +}>["kind"], K> +] extends [never] ? false : true : false : never; // @public type SubsumptionLiteralsErased = Relation extends unknown ? Relation extends ({ diff --git a/packages/typegraph/scripts/api-report.ts b/packages/typegraph/scripts/api-report.ts index 36a45e558..ba96d836a 100644 --- a/packages/typegraph/scripts/api-report.ts +++ b/packages/typegraph/scripts/api-report.ts @@ -823,6 +823,19 @@ const EMPTY_FORGOTTEN_EXPORT_DEBT: ForgottenExportDebt = { // 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`). const FORGOTTEN_EXPORT_DEBT: Readonly> = { // Roadmap F (meta-edge removal): removing the public `InferenceType` // union (never re-exported from most entrypoints, only pulled in @@ -855,8 +868,8 @@ const FORGOTTEN_EXPORT_DEBT: Readonly> = { // `.` (see `src/index.ts`), so neither registers as forgotten here — the // debt is back to its pre-E.2 baseline for this entrypoint specifically. ".": { - count: 437, - sha256: "483b3090ab2c966a64300ec0f24a64b8bf6b079d82427a159aa4933b3f35c207", + count: 438, + sha256: "2ce0d0ee9228e5c7053b59df003740ca67ba4bd578e7823aa429f61e967a5f2d", }, "./adapters/drizzle/engine": { count: 338, @@ -913,28 +926,28 @@ const FORGOTTEN_EXPORT_DEBT: Readonly> = { // lists: EDGE_TEMPORAL_READ_NAMES, IDENTITY_READ_NAMES, and NODE_READ_NAMES. // These three implementation constants are referenced, not public exports. "./graph-merge": { - count: 784, - sha256: "a6e3ae537432943622bebf62338664f058a5f91d21052b3717159776f7888c05", + count: 785, + sha256: "3ca7349506e8ed8e10b2915e561c4f40b33a6fcf068ef2cbfced5823b5b7be02", }, "./indexes": { count: 46, sha256: "5a43d419097711d242c6208632e7e498374a5977eb10a7faba904b10e13f35cd", }, "./interchange": { - count: 769, - sha256: "7d6dbfe63c4915f6d01e011ea993a5adf66b9d223cb6045b75d566e026c53ad2", + count: 770, + sha256: "42afe7e70005f3977a1fb9d5adfb681eabbbdb559d4b6a819fc490f84c88dfd2", }, "./postgres/pglite": { - count: 766, - sha256: "b1a2b2161e9d7f3ba04c2338492d6f519ebd674efb4c0a5fe674eef65d1bf060", + count: 767, + sha256: "3e316cfb2ce30e45721203b9d78e22b301506c814ed59388cd4d1894b1b56b63", }, "./profiler": { - count: 771, - sha256: "6c1bb89765c3eba587f5d069627b07d47df0f603962f4aa22ac395472b4dfc8e", + count: 772, + sha256: "ea1feda418e19f8390907019929a87864376b9d184b7fb4db61f98a9c9a7ac7d", }, "./provenance": { - count: 777, - sha256: "1fd7066ec793ddbc80c2d0d1ed00c25b2770695994d2d5c154f2ebd3ad2e601f", + count: 778, + sha256: "73fd45dcc734cd95a29bf86159658e21d8b3659971e81921e4d5c09c9356999b", }, // Identity transition log: `ensureSchema`'s inline `{ preloaded?: ... }` // options type was extracted into the named (but non-exported) @@ -950,8 +963,8 @@ const FORGOTTEN_EXPORT_DEBT: Readonly> = { sha256: "ff190fe91f16dd83780cc450653f95fae7b2bcf2653620dbd2719ab62b8547be", }, "./sqlite/local": { - count: 766, - sha256: "b1a2b2161e9d7f3ba04c2338492d6f519ebd674efb4c0a5fe674eef65d1bf060", + count: 767, + sha256: "3e316cfb2ce30e45721203b9d78e22b301506c814ed59388cd4d1894b1b56b63", }, }; diff --git a/packages/typegraph/src/query/builder/types.ts b/packages/typegraph/src/query/builder/types.ts index 7e9ae609c..a9428a898 100644 --- a/packages/typegraph/src/query/builder/types.ts +++ b/packages/typegraph/src/query/builder/types.ts @@ -157,17 +157,20 @@ type OntologyTypeErased = /** * True when an ontology endpoint carries no `kind` literal for - * {@link SubsumptionAffected}'s `Extract` to match or rule out. An endpoint - * is one of three things: + * {@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 relation does not name + * `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 }` - * arms rule it out soundly. A second kind co-registered on the same IRI - * carries its OWN literal endpoint and widens through that element. + * `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 @@ -178,13 +181,17 @@ type EndpointKindErased = : true; /** - * The endpoints {@link SubsumptionAffected}'s `Extract` reads a `kind` - * literal off, for an element whose meta-edge name is `Name` — the `Extract` - * arms spelled as endpoint positions, so the two cannot drift: a - * `subClassOf` is matched on its `to` alone (the supertype; which subtype - * declares itself under `K` never changes whether `K` is affected), an - * `equivalentTo`/`sameAs` on both sides. `never` for a name that is none of - * the three, whose endpoints the `Extract` never reads. + * 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) @@ -193,7 +200,7 @@ type MatchedEndpoints = /** * True for an ontology-tuple element whose literals are too weak for - * {@link SubsumptionAffected}'s `Extract` to rule it out — the conservative + * {@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` @@ -224,15 +231,69 @@ type SubsumptionLiteralsErased = : true : never; +/** + * 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< + Extract, { kind: string }>["kind"], + 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 `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. + * 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 @@ -253,23 +314,18 @@ type SubsumptionLiteralsErased = * 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 the `Extract`, and that decision - * is sound for the relations the ontology DECLARES. A relation added at - * runtime is a separate, documented limitation — see {@link AliasNodeType}. + * 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 - : [ - Extract< - G["ontology"][number], - | { metaEdge: { name: "subClassOf" }; to: { kind: K } } - | { metaEdge: { name: "equivalentTo" | "sameAs" }; from: { kind: K } } - | { metaEdge: { name: "equivalentTo" | "sameAs" }; to: { kind: K } } - >, - ] extends [never] ? - false - : true; + : true extends SubsumptionElementNamesKind ? true + : false; /** * The alias type a `from(kind, alias)` call with NO explicit diff --git a/packages/typegraph/test-d/ontology.test-d.ts b/packages/typegraph/test-d/ontology.test-d.ts index 4f14316df..d8095a9f6 100644 --- a/packages/typegraph/test-d/ontology.test-d.ts +++ b/packages/typegraph/test-d/ontology.test-d.ts @@ -13,6 +13,7 @@ import { type NodeType, type OntologyRelation, type Store, + subClassOf, } from ".."; const Person = defineNode("Person", { @@ -212,3 +213,129 @@ 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/tests/typed-subsumption-types.test.ts b/packages/typegraph/tests/typed-subsumption-types.test.ts index 1af8cf0e0..a9fa2fd97 100644 --- a/packages/typegraph/tests/typed-subsumption-types.test.ts +++ b/packages/typegraph/tests/typed-subsumption-types.test.ts @@ -720,4 +720,231 @@ describe("R2 — typed relations and conservative widening", () => { 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">(); + }); });