@lde/search 0.19.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/adapter.d.ts CHANGED
@@ -9,6 +9,8 @@
9
9
  export { assertTypeInSchema, physicalFields, irAlias, displayFieldName, displayFieldPattern, displayLangOf, searchableFields, facetableFields, filterableFields, sortableFields, outputFields, isInternalField, isInlineReference, nestedReferenceType, nestedFieldName, referenceFields, referenceTypeNamed, inlineFramingDepth, fieldNamed, datasetField, ID_FIELD, AND_KEY, OR_KEY, labelFieldOf, labelFieldNameOf, DEFAULT_LABEL_FIELD, isRangeFacet, isAbsoluteIri, isoToUnixSeconds, unixSecondsToIso, } from './schema.js';
10
10
  export type { PhysicalFields } from './schema.js';
11
11
  export { physicalNameTokens } from './physical-name.js';
12
+ export { joinGraph, MAX_JOIN_DEPTH } from './join-graph.js';
13
+ export type { JoinGraph } from './join-graph.js';
12
14
  export { filterOperatorFor, filterOperator, filterOn, isUnsatisfiable, validateQuery, assertValidQuery, pageForOffset, } from './query.js';
13
15
  export type { FilterOperator, QueryIssue } from './query.js';
14
16
  //# sourceMappingURL=adapter.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../src/adapter.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EACL,kBAAkB,EAClB,cAAc,EACd,OAAO,EACP,gBAAgB,EAChB,mBAAmB,EACnB,aAAa,EACb,gBAAgB,EAChB,eAAe,EACf,gBAAgB,EAChB,cAAc,EACd,YAAY,EACZ,eAAe,EACf,iBAAiB,EACjB,mBAAmB,EACnB,eAAe,EACf,eAAe,EACf,kBAAkB,EAClB,kBAAkB,EAClB,UAAU,EACV,YAAY,EACZ,QAAQ,EACR,OAAO,EACP,MAAM,EACN,YAAY,EACZ,gBAAgB,EAChB,mBAAmB,EACnB,YAAY,EACZ,aAAa,EACb,gBAAgB,EAChB,gBAAgB,GACjB,MAAM,aAAa,CAAC;AACrB,YAAY,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAClD,OAAO,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAC;AACxD,OAAO,EACL,iBAAiB,EACjB,cAAc,EACd,QAAQ,EACR,eAAe,EACf,aAAa,EACb,gBAAgB,EAChB,aAAa,GACd,MAAM,YAAY,CAAC;AACpB,YAAY,EAAE,cAAc,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC"}
1
+ {"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../src/adapter.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EACL,kBAAkB,EAClB,cAAc,EACd,OAAO,EACP,gBAAgB,EAChB,mBAAmB,EACnB,aAAa,EACb,gBAAgB,EAChB,eAAe,EACf,gBAAgB,EAChB,cAAc,EACd,YAAY,EACZ,eAAe,EACf,iBAAiB,EACjB,mBAAmB,EACnB,eAAe,EACf,eAAe,EACf,kBAAkB,EAClB,kBAAkB,EAClB,UAAU,EACV,YAAY,EACZ,QAAQ,EACR,OAAO,EACP,MAAM,EACN,YAAY,EACZ,gBAAgB,EAChB,mBAAmB,EACnB,YAAY,EACZ,aAAa,EACb,gBAAgB,EAChB,gBAAgB,GACjB,MAAM,aAAa,CAAC;AACrB,YAAY,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAClD,OAAO,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAC;AACxD,OAAO,EAAE,SAAS,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAC5D,YAAY,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AACjD,OAAO,EACL,iBAAiB,EACjB,cAAc,EACd,QAAQ,EACR,eAAe,EACf,aAAa,EACb,gBAAgB,EAChB,aAAa,GACd,MAAM,YAAY,CAAC;AACpB,YAAY,EAAE,cAAc,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC"}
package/dist/adapter.js CHANGED
@@ -8,4 +8,5 @@
8
8
  */
9
9
  export { assertTypeInSchema, physicalFields, irAlias, displayFieldName, displayFieldPattern, displayLangOf, searchableFields, facetableFields, filterableFields, sortableFields, outputFields, isInternalField, isInlineReference, nestedReferenceType, nestedFieldName, referenceFields, referenceTypeNamed, inlineFramingDepth, fieldNamed, datasetField, ID_FIELD, AND_KEY, OR_KEY, labelFieldOf, labelFieldNameOf, DEFAULT_LABEL_FIELD, isRangeFacet, isAbsoluteIri, isoToUnixSeconds, unixSecondsToIso, } from './schema.js';
10
10
  export { physicalNameTokens } from './physical-name.js';
11
+ export { joinGraph, MAX_JOIN_DEPTH } from './join-graph.js';
11
12
  export { filterOperatorFor, filterOperator, filterOn, isUnsatisfiable, validateQuery, assertValidQuery, pageForOffset, } from './query.js';
package/dist/index.d.ts CHANGED
@@ -2,8 +2,10 @@ export { projectRoots } from './project.js';
2
2
  export type { ProjectedNode, ProjectionContext, SearchDocument, } from './project.js';
3
3
  export { defineSearchType, searchSchema, validateSearchType, assertValidSearchType, ID_FIELD, AND_KEY, OR_KEY, } from './schema.js';
4
4
  export type { FieldKind, SearchField, SearchFieldBase, TextField, KeywordField, ReferenceField, NumericField, BooleanField, SearchType, SearchTypeBase, RootType, ReferenceType, RootTypeOf, SearchTypeIssue, SearchSchema, FacetRange, ProjectionValue, } from './schema.js';
5
+ export { joinGraph, MAX_JOIN_DEPTH } from './join-graph.js';
6
+ export type { JoinGraph } from './join-graph.js';
5
7
  export { filterOn } from './query.js';
6
- export type { SearchQuery, Filter, Criterion, Sort } from './query.js';
8
+ export type { SearchQuery, Filter, Criterion, CriterionBase, Sort, } from './query.js';
7
9
  export type { SearchEngine, SearchResult, SearchHit, ResultDocument, SearchValue, LocalizedValue, Reference, NestedDocument, FacetBucket, FacetMap, FacetsOutcome, FacetFieldsOf, OutputFieldsOf, } from './engine.js';
8
10
  export type { FramedNode } from './frame-by-type.js';
9
11
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AASA,OAAO,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAC5C,YAAY,EACV,aAAa,EACb,iBAAiB,EACjB,cAAc,GACf,MAAM,cAAc,CAAC;AAKtB,OAAO,EACL,gBAAgB,EAChB,YAAY,EACZ,kBAAkB,EAClB,qBAAqB,EACrB,QAAQ,EACR,OAAO,EACP,MAAM,GACP,MAAM,aAAa,CAAC;AACrB,YAAY,EACV,SAAS,EACT,WAAW,EACX,eAAe,EACf,SAAS,EACT,YAAY,EACZ,cAAc,EACd,YAAY,EACZ,YAAY,EACZ,UAAU,EACV,cAAc,EACd,QAAQ,EACR,aAAa,EACb,UAAU,EACV,eAAe,EACf,YAAY,EACZ,UAAU,EACV,eAAe,GAChB,MAAM,aAAa,CAAC;AAOrB,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AACtC,YAAY,EAAE,WAAW,EAAE,MAAM,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,YAAY,CAAC;AAIvE,YAAY,EACV,YAAY,EACZ,YAAY,EACZ,SAAS,EACT,cAAc,EACd,WAAW,EACX,cAAc,EACd,SAAS,EACT,cAAc,EACd,WAAW,EACX,QAAQ,EACR,aAAa,EACb,aAAa,EACb,cAAc,GACf,MAAM,aAAa,CAAC;AAErB,YAAY,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AASA,OAAO,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAC5C,YAAY,EACV,aAAa,EACb,iBAAiB,EACjB,cAAc,GACf,MAAM,cAAc,CAAC;AAKtB,OAAO,EACL,gBAAgB,EAChB,YAAY,EACZ,kBAAkB,EAClB,qBAAqB,EACrB,QAAQ,EACR,OAAO,EACP,MAAM,GACP,MAAM,aAAa,CAAC;AACrB,YAAY,EACV,SAAS,EACT,WAAW,EACX,eAAe,EACf,SAAS,EACT,YAAY,EACZ,cAAc,EACd,YAAY,EACZ,YAAY,EACZ,UAAU,EACV,cAAc,EACd,QAAQ,EACR,aAAa,EACb,UAAU,EACV,eAAe,EACf,YAAY,EACZ,UAAU,EACV,eAAe,GAChB,MAAM,aAAa,CAAC;AAKrB,OAAO,EAAE,SAAS,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAC5D,YAAY,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAOjD,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AACtC,YAAY,EACV,WAAW,EACX,MAAM,EACN,SAAS,EACT,aAAa,EACb,IAAI,GACL,MAAM,YAAY,CAAC;AAIpB,YAAY,EACV,YAAY,EACZ,YAAY,EACZ,SAAS,EACT,cAAc,EACd,WAAW,EACX,cAAc,EACd,SAAS,EACT,cAAc,EACd,WAAW,EACX,QAAQ,EACR,aAAa,EACb,aAAa,EACb,cAAc,GACf,MAAM,aAAa,CAAC;AAErB,YAAY,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC"}
package/dist/index.js CHANGED
@@ -11,6 +11,10 @@ export { projectRoots } from './project.js';
11
11
  // schema, query semantics and the GraphQL surface – a discriminated union by
12
12
  // `kind`, validated again at runtime when the schema is built.
13
13
  export { defineSearchType, searchSchema, validateSearchType, assertValidSearchType, ID_FIELD, AND_KEY, OR_KEY, } from './schema.js';
14
+ // The declared join edges between root types: which types a query can filter
15
+ // across, and which collections must be rebuilt together. Built eagerly by
16
+ // `searchSchema`, so declaring a schema is what validates the joins.
17
+ export { joinGraph, MAX_JOIN_DEPTH } from './join-graph.js';
14
18
  // Engine- and protocol-neutral query IR (what a `queryDefaults` policy or an
15
19
  // in-process caller writes).
16
20
  // `filterOn` builds the ordinary one-criterion clause – the shape a consumer
@@ -0,0 +1,63 @@
1
+ import { type RootType, type SearchSchema, type SearchType } from './schema.js';
2
+ /**
3
+ * The deepest `on` path a query may state: `dataset → publisher → country` is
4
+ * three hops. Fixed rather than configurable, and enforced in the query IR
5
+ * ({@link validateQuery}) rather than in an adapter, so a later REST surface
6
+ * inherits the same ceiling. Typesense imposes no limit of its own, and the
7
+ * GraphQL max-depth guard counts selection sets rather than input nesting, so
8
+ * without this a `where` could nest a join arbitrarily deep and each hop is an
9
+ * extra collection scan.
10
+ */
11
+ export declare const MAX_JOIN_DEPTH = 3;
12
+ /**
13
+ * The declared join edges of a {@link SearchSchema}: which Root Types are
14
+ * reachable from which through {@link ReferenceField.joinable} references, and
15
+ * which of them must be rebuilt together.
16
+ *
17
+ * Two members, because a consumer needs exactly two answers. A **query**
18
+ * compiler asks *what does this path of field names reach* ({@link
19
+ * JoinGraph.resolve}); a **writer** asks *which collections come into existence
20
+ * together* ({@link JoinGraph.components}). Everything else – deriving the
21
+ * edges from `joinable` + `labelSource`, the one-edge-per-target rule, cycle
22
+ * rejection, the depth cap, and the asymmetry between undirected membership and
23
+ * directed creation order – is hidden here, so no consumer restates it.
24
+ *
25
+ * `resolve` returns a {@link RootType}, never a collection name: how a type is
26
+ * named in an engine is engine- and deployment-specific and stays in the
27
+ * adapter.
28
+ */
29
+ export interface JoinGraph {
30
+ /**
31
+ * The **join components**: groups of Root Types that reference one another,
32
+ * directly or transitively, and therefore share a rebuild
33
+ * ([ADR 19](../../docs/decisions/0019-filter-across-collections-through-declared-joins.md)).
34
+ * Every Root Type appears in exactly one; a type with no joinable edge is a
35
+ * singleton and keeps per-collection isolation exactly as before.
36
+ *
37
+ * Membership is the **undirected** connected component – a referrer and its
38
+ * referent must rebuild together whichever way the edge points – while the
39
+ * order **within** a component is the **directed** topological sort,
40
+ * referenced first. That asymmetry is load-bearing: an engine cannot create a
41
+ * collection whose reference names a collection that does not exist yet, so
42
+ * the referent’s collection must be opened first.
43
+ */
44
+ readonly components: readonly (readonly RootType[])[];
45
+ /**
46
+ * The Root Type a path of joinable field names reaches from `from`, or
47
+ * `undefined` when the path does not resolve – an unknown field name, a
48
+ * reference that is not `joinable`, or a path deeper than
49
+ * {@link MAX_JOIN_DEPTH}. An empty path resolves to `from` itself only when
50
+ * `from` is a Root Type; a Reference Type has no collection to join to.
51
+ */
52
+ resolve(from: SearchType, path: readonly string[]): RootType | undefined;
53
+ }
54
+ /**
55
+ * The join graph of a {@link SearchSchema}, built once per schema and cached:
56
+ * {@link searchSchema} builds it eagerly, so a schema whose joins do not hold
57
+ * up – two joinable fields at one target, a cycle – fails at startup rather
58
+ * than on the first query or the first rebuild.
59
+ *
60
+ * Calling it again for the same schema returns the same graph.
61
+ */
62
+ export declare function joinGraph(schema: SearchSchema): JoinGraph;
63
+ //# sourceMappingURL=join-graph.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"join-graph.d.ts","sourceRoot":"","sources":["../src/join-graph.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,KAAK,QAAQ,EACb,KAAK,YAAY,EACjB,KAAK,UAAU,EAChB,MAAM,aAAa,CAAC;AAErB;;;;;;;;GAQG;AACH,eAAO,MAAM,cAAc,IAAI,CAAC;AAEhC;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,WAAW,SAAS;IACxB;;;;;;;;;;;;;OAaG;IACH,QAAQ,CAAC,UAAU,EAAE,SAAS,CAAC,SAAS,QAAQ,EAAE,CAAC,EAAE,CAAC;IACtD;;;;;;OAMG;IACH,OAAO,CAAC,IAAI,EAAE,UAAU,EAAE,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,QAAQ,GAAG,SAAS,CAAC;CAC1E;AAED;;;;;;;GAOG;AACH,wBAAgB,SAAS,CAAC,MAAM,EAAE,YAAY,GAAG,SAAS,CAQzD"}
@@ -0,0 +1,192 @@
1
+ import { referenceFields, } from './schema.js';
2
+ /**
3
+ * The deepest `on` path a query may state: `dataset → publisher → country` is
4
+ * three hops. Fixed rather than configurable, and enforced in the query IR
5
+ * ({@link validateQuery}) rather than in an adapter, so a later REST surface
6
+ * inherits the same ceiling. Typesense imposes no limit of its own, and the
7
+ * GraphQL max-depth guard counts selection sets rather than input nesting, so
8
+ * without this a `where` could nest a join arbitrarily deep and each hop is an
9
+ * extra collection scan.
10
+ */
11
+ export const MAX_JOIN_DEPTH = 3;
12
+ /**
13
+ * The join graph of a {@link SearchSchema}, built once per schema and cached:
14
+ * {@link searchSchema} builds it eagerly, so a schema whose joins do not hold
15
+ * up – two joinable fields at one target, a cycle – fails at startup rather
16
+ * than on the first query or the first rebuild.
17
+ *
18
+ * Calling it again for the same schema returns the same graph.
19
+ */
20
+ export function joinGraph(schema) {
21
+ const cached = graphsBySchema.get(schema);
22
+ if (cached !== undefined) {
23
+ return cached;
24
+ }
25
+ const built = buildJoinGraph(schema);
26
+ graphsBySchema.set(schema, built);
27
+ return built;
28
+ }
29
+ /** One graph per schema, held beside it rather than on it, so a
30
+ * {@link SearchSchema} stays a plain branded `Map`. */
31
+ const graphsBySchema = new WeakMap();
32
+ /** The edges leaving one Root Type, keyed by field name. Total by
33
+ * construction: the index is built with an entry for EVERY Root Type, an empty
34
+ * map where a type declares none, so no caller needs a fallback it can never
35
+ * take. */
36
+ function edgesOf(edges, searchType) {
37
+ return edges.get(searchType.class);
38
+ }
39
+ /** The edges leaving one Root Type, in declaration order. */
40
+ function edgesFrom(edges, searchType) {
41
+ return edgesOf(edges, searchType).values();
42
+ }
43
+ /**
44
+ * Derive, validate and index the edges. Throws – rather than reporting issues –
45
+ * because it is called from {@link searchSchema}, whose contract is that an
46
+ * invalid declaration fails at startup.
47
+ */
48
+ function buildJoinGraph(schema) {
49
+ const rootTypes = [...schema.values()];
50
+ const byName = new Map(rootTypes.map((type) => [type.name, type]));
51
+ const edges = new Map();
52
+ for (const searchType of rootTypes) {
53
+ edges.set(searchType.class, declaredEdges(searchType, byName));
54
+ }
55
+ assertAcyclic(rootTypes, edges);
56
+ return {
57
+ components: componentsOf(rootTypes, edges),
58
+ resolve(from, path) {
59
+ if (path.length > MAX_JOIN_DEPTH) {
60
+ return undefined;
61
+ }
62
+ // A Reference Type is never indexed, so it has no collection to join
63
+ // from; only a member of this schema’s class map can start a path.
64
+ const start = from.class === undefined ? undefined : schema.get(from.class);
65
+ if (start === undefined || start !== from) {
66
+ return undefined;
67
+ }
68
+ let current = start;
69
+ for (const name of path) {
70
+ const edge = edgesOf(edges, current).get(name);
71
+ if (edge === undefined) {
72
+ return undefined;
73
+ }
74
+ current = edge.target;
75
+ }
76
+ return current;
77
+ },
78
+ };
79
+ }
80
+ /**
81
+ * The joinable edges one Root Type declares, keyed by field name.
82
+ *
83
+ * Enforces the **one edge per (type, target)** rule here rather than in a
84
+ * consumer: a Typesense join addresses the referent by *collection*, not by
85
+ * field, so a second reference to the same collection is accepted, indexed and
86
+ * then unreachable – the engine resolves every join through the first field
87
+ * that matched and silently ignores the rest
88
+ * ({@link https://github.com/typesense/typesense/issues/3021}). `publisher` and
89
+ * `creator` both resolving to `Organization` is the ordinary case, so it must
90
+ * be a declaration error naming both fields rather than a surprise at query
91
+ * time.
92
+ */
93
+ function declaredEdges(searchType, byName) {
94
+ const edges = new Map();
95
+ const claimedBy = new Map();
96
+ for (const field of referenceFields(searchType)) {
97
+ if (field.joinable !== true) {
98
+ continue;
99
+ }
100
+ // Both lookups always hit: `validateSearchType` rejects `joinable` without
101
+ // a `labelSource`, and a label source is always an indexed Root Type – it
102
+ // must declare a `searchable` `label` field, which a Reference Type cannot
103
+ // carry (a nested field is `output` only). So a joinable edge always has a
104
+ // collection at the far end without a rule of its own.
105
+ const target = byName.get(field.labelSource);
106
+ const claimed = claimedBy.get(target.name);
107
+ if (claimed !== undefined) {
108
+ throw new Error(`Search type “${searchType.name}” declares two joinable references to “${target.name}” (“${claimed}” and “${field.name}”); an engine addresses a join by collection, not by field, so only one reference per target can be joinable. Drop “joinable” from one of them – it keeps its labels, facets and id filtering.`);
109
+ }
110
+ claimedBy.set(target.name, field.name);
111
+ edges.set(field.name, { field, target });
112
+ }
113
+ return edges;
114
+ }
115
+ /**
116
+ * Reject a cycle in the **directed** join graph. A cycle would make the
117
+ * topological creation order below undefinable – there is no collection to
118
+ * create first – and, with the component as the unit of rebuild, no order in
119
+ * which a component’s collections could come into existence at all.
120
+ */
121
+ function assertAcyclic(rootTypes, edges) {
122
+ const done = new Set();
123
+ const visit = (searchType, onPath) => {
124
+ if (onPath.some((ancestor) => ancestor.class === searchType.class)) {
125
+ const cycle = [...onPath, searchType]
126
+ .map((type) => `“${type.name}”`)
127
+ .join(' → ');
128
+ throw new Error(`Join cycle ${cycle}; the joinable references between search types must be acyclic, so a component’s collections have an order to be created in.`);
129
+ }
130
+ if (done.has(searchType.class)) {
131
+ return;
132
+ }
133
+ const extended = [...onPath, searchType];
134
+ for (const edge of edgesFrom(edges, searchType)) {
135
+ visit(edge.target, extended);
136
+ }
137
+ done.add(searchType.class);
138
+ };
139
+ for (const searchType of rootTypes) {
140
+ visit(searchType, []);
141
+ }
142
+ }
143
+ /**
144
+ * Partition the Root Types into join components, each topologically ordered.
145
+ *
146
+ * Membership is the undirected connected component (union-find over the
147
+ * edges); the order within one is a depth-first topological sort over the
148
+ * directed edges, so a type is emitted only after everything it references.
149
+ * Components come out in declaration order of their first member, so a schema’s
150
+ * rebuild order is stable across runs.
151
+ */
152
+ function componentsOf(rootTypes, edges) {
153
+ const parent = new Map(rootTypes.map((type) => [type.class, type.class]));
154
+ const find = (iri) => {
155
+ let root = iri;
156
+ while (parent.get(root) !== root) {
157
+ root = parent.get(root);
158
+ }
159
+ return root;
160
+ };
161
+ for (const searchType of rootTypes) {
162
+ for (const edge of edgesFrom(edges, searchType)) {
163
+ parent.set(find(searchType.class), find(edge.target.class));
164
+ }
165
+ }
166
+ const members = new Map();
167
+ for (const searchType of rootTypes) {
168
+ const root = find(searchType.class);
169
+ members.set(root, [...(members.get(root) ?? []), searchType]);
170
+ }
171
+ return [...members.values()].map((component) => topologicallyOrdered(component, edges));
172
+ }
173
+ /** One component’s members, everything a type references before the type
174
+ * itself. Terminates because {@link assertAcyclic} ran first. */
175
+ function topologicallyOrdered(component, edges) {
176
+ const ordered = [];
177
+ const emitted = new Set();
178
+ const visit = (searchType) => {
179
+ if (emitted.has(searchType.class)) {
180
+ return;
181
+ }
182
+ emitted.add(searchType.class);
183
+ for (const edge of edgesFrom(edges, searchType)) {
184
+ visit(edge.target);
185
+ }
186
+ ordered.push(searchType);
187
+ };
188
+ for (const searchType of component) {
189
+ visit(searchType);
190
+ }
191
+ return ordered;
192
+ }
package/dist/project.js CHANGED
@@ -121,7 +121,16 @@ function applyField(document, node, field, searchType, schema, context) {
121
121
  return applyProjectionValue(document, field, field.from, context);
122
122
  }
123
123
  if (field.derive !== undefined) {
124
- const value = field.derive(document, context);
124
+ const derived = field.derive(document, context);
125
+ // A derived `reference` is held to the same rule as a read one: what it
126
+ // stores is a selection key, and the surface types it `IRI`. ADR 19 sends
127
+ // an IRI-valued `keyword` derive here by converting it to `reference`, so
128
+ // this is the busiest way a non-IRI could otherwise reach the index. A
129
+ // value that survives nothing leaves the field absent, exactly as a derive
130
+ // returning `undefined` does.
131
+ const value = field.kind === 'reference' && derived !== undefined
132
+ ? derivedIris(derived)
133
+ : derived;
125
134
  if (value !== undefined) {
126
135
  document[field.name] = value;
127
136
  }
@@ -236,7 +245,14 @@ function foldedSearchValue(values) {
236
245
  * document – a value the API cannot serialize.
237
246
  */
238
247
  function applyFacet(document, raw, field) {
239
- const values = dedupe(field.transform ? raw.map(field.transform) : raw);
248
+ // A `reference` stores identity, so what it stores must be an IRI whatever
249
+ // route the value arrived by. {@link iriString} guards the graph path, but a
250
+ // `transform` runs after it and a `from` projection value never passes it at
251
+ // all – so both are checked here, at the one point they share. Without this
252
+ // the surface would promise `IRI` over a value the projection let through.
253
+ const referenced = field.kind === 'reference';
254
+ const transformed = field.transform ? raw.map(field.transform) : raw;
255
+ const values = dedupe(referenced ? transformed.filter(isAbsoluteIri) : transformed);
240
256
  const folded = dedupe(values.map((value) => fold(value)));
241
257
  const searchField = physicalFields(field).search[0];
242
258
  if (field.array === true) {
@@ -384,6 +400,17 @@ function iriString(value) {
384
400
  const iri = isObject(value) ? value['@id'] : value;
385
401
  return typeof iri === 'string' && isAbsoluteIri(iri) ? iri : undefined;
386
402
  }
403
+ /** What a `derive` on a `reference` field is allowed to have produced: the
404
+ * absolute IRIs among its return value, keeping the shape the declaration
405
+ * promises (a list stays a list, a single value stays single) so `array` still
406
+ * decides the stored shape. A lone non-IRI becomes `undefined`, i.e. absent –
407
+ * the same outcome the graph path gives it. */
408
+ function derivedIris(value) {
409
+ if (Array.isArray(value)) {
410
+ return value.filter((entry) => typeof entry === 'string' && isAbsoluteIri(entry));
411
+ }
412
+ return typeof value === 'string' && isAbsoluteIri(value) ? value : undefined;
413
+ }
387
414
  function toInteger(literal) {
388
415
  return literal === undefined ? undefined : Math.trunc(Number(literal));
389
416
  }
package/dist/query.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { type SearchType } from './schema.js';
2
+ import { type JoinGraph } from './join-graph.js';
2
3
  /**
3
4
  * The engine- and protocol-neutral query IR. Every API surface compiles its
4
5
  * input into this; every engine adapter compiles it into an engine query. One
@@ -20,6 +21,35 @@ export interface SearchQuery {
20
21
  /** Selects the per-locale fields to query/sort on (from `Accept-Language`). */
21
22
  readonly locale: string;
22
23
  }
24
+ /**
25
+ * What every {@link Criterion} carries, whatever its operator: the field it
26
+ * constrains, and – for a criterion that constrains a *joined* document rather
27
+ * than this one – the path of joinable references to walk to reach it.
28
+ */
29
+ export interface CriterionBase {
30
+ /** The logical field name, on the type the {@link CriterionBase.on} path
31
+ * reaches (or on the searched type when there is none). */
32
+ readonly field: string;
33
+ /**
34
+ * A **join path**: the names of the {@link ReferenceField.joinable}
35
+ * references to follow, in order, to the type this criterion constrains.
36
+ * `['dataset', 'publisher']` on a `CreativeWork` criterion means *the
37
+ * publisher of the dataset this work belongs to*. Omitted (or empty) for the
38
+ * ordinary case: the criterion constrains the searched type itself.
39
+ *
40
+ * A path, deliberately, and not boolean structure – so the criterion stays an
41
+ * **atom** and `where` stays the flat conjunction of disjunctions
42
+ * [ADR 18](../../docs/decisions/0018-filter-across-several-fields-with-one-clause.md)
43
+ * made it. `on` sits on the criterion rather than on the {@link Filter} for
44
+ * the same reason a criterion carries its own operator: a `Filter`-level
45
+ * `on` would scope the whole disjunction and make
46
+ * `“published by X, or titled Y”` inexpressible.
47
+ *
48
+ * Capped at {@link MAX_JOIN_DEPTH} hops and resolved by
49
+ * {@link validateQuery} against the schema’s {@link JoinGraph}.
50
+ */
51
+ readonly on?: readonly string[];
52
+ }
23
53
  /**
24
54
  * One criterion on one field. The operator is fixed by that field’s
25
55
  * {@link FieldKind} ({@link filterOperatorFor}): keyword/reference use `in` (OR
@@ -29,21 +59,20 @@ export interface SearchQuery {
29
59
  * Criteria are the atoms a {@link Filter} is built from, so each carries its own
30
60
  * field AND its own operator: one clause may range over a `reference` and a
31
61
  * `keyword` field at once, and each field is constrained the way its kind
32
- * allows.
62
+ * allows. Each equally carries its own {@link CriterionBase.on} join path, so
63
+ * one clause may mix a condition on this document with one on a joined
64
+ * document.
33
65
  */
34
- export type Criterion = {
35
- readonly field: string;
66
+ export type Criterion = (CriterionBase & {
36
67
  readonly in: readonly string[];
37
- } | {
38
- readonly field: string;
68
+ }) | (CriterionBase & {
39
69
  readonly range: {
40
70
  readonly min?: number | string;
41
71
  readonly max?: number | string;
42
72
  };
43
- } | {
44
- readonly field: string;
73
+ }) | (CriterionBase & {
45
74
  readonly is: boolean;
46
- };
75
+ });
47
76
  /**
48
77
  * One `where` clause: a disjunction, matching a document that satisfies **any**
49
78
  * of its criteria. An ordinary single-field filter is the one-criterion case,
@@ -92,6 +121,10 @@ export declare function filterOn(criterion: Criterion): Filter;
92
121
  * is a disjunction that happens to include identity, not an enumeration of
93
122
  * wanted documents, so it stays the vacuous no-op every other empty `in` is.
94
123
  *
124
+ * A joined identity clause (`{ on: […], field: 'id', in: [] }`) reads the same
125
+ * way one hop out: it enumerates the *referents* wanted, and no referent means
126
+ * no document can match through that edge.
127
+ *
95
128
  * An adapter MUST answer an unsatisfiable query with an empty result rather
96
129
  * than dispatching it.
97
130
  */
@@ -108,7 +141,12 @@ export declare function isUnsatisfiable(query: SearchQuery): boolean;
108
141
  export interface QueryIssue {
109
142
  readonly part: 'where' | 'facets' | 'orderBy';
110
143
  readonly field: string;
111
- readonly reason: 'unknown-field' | 'not-filterable' | 'operator-mismatch' | 'not-facetable';
144
+ readonly reason: 'unknown-field' | 'not-filterable' | 'operator-mismatch' | 'not-facetable'
145
+ /** The criterion’s `on` path is longer than {@link MAX_JOIN_DEPTH}. */
146
+ | 'join-too-deep'
147
+ /** The `on` path does not resolve: a name that is not a field, a reference
148
+ * that is not `joinable`, or no join graph to resolve it against. */
149
+ | 'unknown-join';
112
150
  }
113
151
  /**
114
152
  * Structurally validate a query against its search type: **every criterion of
@@ -121,15 +159,24 @@ export interface QueryIssue {
121
159
  * checks declaration only, not the `sortable` flag: that flag means *publicly
122
160
  * selectable*, and a deployment policy may sort on a private tie-break field.
123
161
  *
162
+ * A criterion carrying an {@link CriterionBase.on} join path is checked against
163
+ * the type that path reaches instead: the path must be at most
164
+ * {@link MAX_JOIN_DEPTH} hops (`join-too-deep`) and must resolve through
165
+ * `joins` (`unknown-join`), and the leaf field must then be filterable on the
166
+ * *target* type. The cap lives here, in the IR, rather than in an adapter or a
167
+ * surface, so every surface inherits it. `joins` is optional because a query
168
+ * with no join path needs none; a joined criterion validated without one is an
169
+ * `unknown-join`, never a silently unchecked filter.
170
+ *
124
171
  * This is the port’s always-on guard: every {@link SearchEngine} adapter MUST
125
172
  * reject a query with issues ({@link assertValidQuery}) instead of passing
126
173
  * garbage to its engine, so validation holds for every caller – including
127
174
  * `queryDefaults` policies and surfaces weaker than GraphQL.
128
175
  */
129
- export declare function validateQuery(query: SearchQuery, searchType: SearchType): readonly QueryIssue[];
176
+ export declare function validateQuery(query: SearchQuery, searchType: SearchType, joins?: JoinGraph): readonly QueryIssue[];
130
177
  /** Throw on the first structurally invalid query part ({@link validateQuery}),
131
178
  * naming every issue. The always-on entry point for engine adapters. */
132
- export declare function assertValidQuery(query: SearchQuery, searchType: SearchType): void;
179
+ export declare function assertValidQuery(query: SearchQuery, searchType: SearchType, joins?: JoinGraph): void;
133
180
  /**
134
181
  * The 1-based page an `offset` falls on – the numbered-pagination presentation
135
182
  * of the IR, shared by the surfaces and the adapters. `limit: 0` (a facet-only
@@ -1 +1 @@
1
- {"version":3,"file":"query.d.ts","sourceRoot":"","sources":["../src/query.ts"],"names":[],"mappings":"AAAA,OAAO,EAIL,KAAK,UAAU,EAChB,MAAM,aAAa,CAAC;AAErB;;;;;GAKG;AACH,MAAM,WAAW,WAAW;IAC1B,wEAAwE;IACxE,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,yEAAyE;IACzE,QAAQ,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,CAAC;IAClC,2EAA2E;IAC3E,QAAQ,CAAC,OAAO,EAAE,SAAS,IAAI,EAAE,CAAC;IAClC,2BAA2B;IAC3B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,uDAAuD;IACvD,QAAQ,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC;IACnC,+EAA+E;IAC/E,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED;;;;;;;;;;GAUG;AACH,MAAM,MAAM,SAAS,GACjB;IAAE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,EAAE,SAAS,MAAM,EAAE,CAAA;CAAE,GAC1D;IACE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,KAAK,EAAE;QACd,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;QAC/B,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;KAChC,CAAC;CACH,GACD;IAAE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,EAAE,OAAO,CAAA;CAAE,CAAC;AAErD;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,MAAM;IACrB,QAAQ,CAAC,EAAE,EAAE,SAAS,SAAS,EAAE,CAAC;CACnC;AAED,+BAA+B;AAC/B,MAAM,WAAW,IAAI;IACnB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,SAAS,EAAE,KAAK,GAAG,MAAM,CAAC;CACpC;AAKD,OAAO,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAChD,YAAY,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAClD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAElD,6EAA6E;AAC7E,wBAAgB,cAAc,CAAC,SAAS,EAAE,SAAS,GAAG,cAAc,CAEnE;AAED;wDACwD;AACxD,wBAAgB,QAAQ,CAAC,SAAS,EAAE,SAAS,GAAG,MAAM,CAErD;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,WAAW,GAAG,OAAO,CAW3D;AAED;;;;;;;;GAQG;AACH,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,IAAI,EAAE,OAAO,GAAG,QAAQ,GAAG,SAAS,CAAC;IAC9C,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,MAAM,EACb,eAAe,GAAG,gBAAgB,GAAG,mBAAmB,GAAG,eAAe,CAAC;CAC9E;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,aAAa,CAC3B,KAAK,EAAE,WAAW,EAClB,UAAU,EAAE,UAAU,GACrB,SAAS,UAAU,EAAE,CA0DvB;AAED;yEACyE;AACzE,wBAAgB,gBAAgB,CAC9B,KAAK,EAAE,WAAW,EAClB,UAAU,EAAE,UAAU,GACrB,IAAI,CAUN;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAEnE"}
1
+ {"version":3,"file":"query.d.ts","sourceRoot":"","sources":["../src/query.ts"],"names":[],"mappings":"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"}
package/dist/query.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { fieldNamed, filterOperatorFor, ID_FIELD, } from './schema.js';
2
+ import { MAX_JOIN_DEPTH } from './join-graph.js';
2
3
  // The kind→operator table lives with the field model in schema.ts (one source
3
4
  // for the query IR, the surfaces, the adapters AND declaration validation);
4
5
  // re-exported here so the query-IR module stays the natural import site.
@@ -32,6 +33,10 @@ export function filterOn(criterion) {
32
33
  * is a disjunction that happens to include identity, not an enumeration of
33
34
  * wanted documents, so it stays the vacuous no-op every other empty `in` is.
34
35
  *
36
+ * A joined identity clause (`{ on: […], field: 'id', in: [] }`) reads the same
37
+ * way one hop out: it enumerates the *referents* wanted, and no referent means
38
+ * no document can match through that edge.
39
+ *
35
40
  * An adapter MUST answer an unsatisfiable query with an empty result rather
36
41
  * than dispatching it.
37
42
  */
@@ -45,6 +50,13 @@ export function isUnsatisfiable(query) {
45
50
  criterion.in.length === 0);
46
51
  });
47
52
  }
53
+ /** The `field` a joined issue is reported under: the path and the leaf name
54
+ * together, so a message says which hop and which field, not just which
55
+ * field. `dataset.publisher.id` reads the way the criterion was written. */
56
+ function issueField(criterion) {
57
+ const on = criterion.on ?? [];
58
+ return on.length === 0 ? criterion.field : [...on, criterion.field].join('.');
59
+ }
48
60
  /**
49
61
  * Structurally validate a query against its search type: **every criterion of
50
62
  * every `where` clause** names a declared, `filterable` field whose kind accepts
@@ -56,12 +68,21 @@ export function isUnsatisfiable(query) {
56
68
  * checks declaration only, not the `sortable` flag: that flag means *publicly
57
69
  * selectable*, and a deployment policy may sort on a private tie-break field.
58
70
  *
71
+ * A criterion carrying an {@link CriterionBase.on} join path is checked against
72
+ * the type that path reaches instead: the path must be at most
73
+ * {@link MAX_JOIN_DEPTH} hops (`join-too-deep`) and must resolve through
74
+ * `joins` (`unknown-join`), and the leaf field must then be filterable on the
75
+ * *target* type. The cap lives here, in the IR, rather than in an adapter or a
76
+ * surface, so every surface inherits it. `joins` is optional because a query
77
+ * with no join path needs none; a joined criterion validated without one is an
78
+ * `unknown-join`, never a silently unchecked filter.
79
+ *
59
80
  * This is the port’s always-on guard: every {@link SearchEngine} adapter MUST
60
81
  * reject a query with issues ({@link assertValidQuery}) instead of passing
61
82
  * garbage to its engine, so validation holds for every caller – including
62
83
  * `queryDefaults` policies and surfaces weaker than GraphQL.
63
84
  */
64
- export function validateQuery(query, searchType) {
85
+ export function validateQuery(query, searchType, joins) {
65
86
  const issues = [];
66
87
  for (const filter of query.where) {
67
88
  // Each criterion is checked on its own, so a multi-criterion clause needs
@@ -71,6 +92,25 @@ export function validateQuery(query, searchType) {
71
92
  // a `reference` membership with a `date` range without either being wrong.
72
93
  for (const criterion of filter.or) {
73
94
  const name = criterion.field;
95
+ const field = issueField(criterion);
96
+ // A criterion may constrain a JOINED document, and then every rule below
97
+ // is about the type its `on` path reaches, not about the searched one.
98
+ // Resolving the path needs the schema’s join graph, so a caller that
99
+ // supplied none cannot serve a joined criterion at all.
100
+ const on = criterion.on ?? [];
101
+ let constrained = searchType;
102
+ if (on.length > 0) {
103
+ if (on.length > MAX_JOIN_DEPTH) {
104
+ issues.push({ part: 'where', field, reason: 'join-too-deep' });
105
+ continue;
106
+ }
107
+ const target = joins?.resolve(searchType, on);
108
+ if (target === undefined) {
109
+ issues.push({ part: 'where', field, reason: 'unknown-join' });
110
+ continue;
111
+ }
112
+ constrained = target;
113
+ }
74
114
  // `id` is filterable on every type without being declared by any: it is
75
115
  // the document’s IRI, so the lookup exists wherever documents do.
76
116
  // Membership only – an IRI has no range and no truth value.
@@ -78,23 +118,23 @@ export function validateQuery(query, searchType) {
78
118
  if (filterOperator(criterion) !== 'in') {
79
119
  issues.push({
80
120
  part: 'where',
81
- field: name,
121
+ field,
82
122
  reason: 'operator-mismatch',
83
123
  });
84
124
  }
85
125
  continue;
86
126
  }
87
- const field = fieldNamed(searchType, name);
88
- if (field === undefined) {
89
- issues.push({ part: 'where', field: name, reason: 'unknown-field' });
127
+ const declared = fieldNamed(constrained, name);
128
+ if (declared === undefined) {
129
+ issues.push({ part: 'where', field, reason: 'unknown-field' });
90
130
  }
91
- else if (field.filterable !== true) {
92
- issues.push({ part: 'where', field: name, reason: 'not-filterable' });
131
+ else if (declared.filterable !== true) {
132
+ issues.push({ part: 'where', field, reason: 'not-filterable' });
93
133
  }
94
- else if (filterOperatorFor(field.kind) !== filterOperator(criterion)) {
134
+ else if (filterOperatorFor(declared.kind) !== filterOperator(criterion)) {
95
135
  issues.push({
96
136
  part: 'where',
97
- field: name,
137
+ field,
98
138
  reason: 'operator-mismatch',
99
139
  });
100
140
  }
@@ -123,8 +163,8 @@ export function validateQuery(query, searchType) {
123
163
  }
124
164
  /** Throw on the first structurally invalid query part ({@link validateQuery}),
125
165
  * naming every issue. The always-on entry point for engine adapters. */
126
- export function assertValidQuery(query, searchType) {
127
- const issues = validateQuery(query, searchType);
166
+ export function assertValidQuery(query, searchType, joins) {
167
+ const issues = validateQuery(query, searchType, joins);
128
168
  if (issues.length > 0) {
129
169
  const detail = issues
130
170
  .map((issue) => `${issue.part}: “${issue.field}” (${issue.reason})`)
package/dist/schema.d.ts CHANGED
@@ -189,6 +189,28 @@ export interface ReferenceField extends SearchFieldBase, Searchable {
189
189
  * resolution.
190
190
  */
191
191
  readonly labelSource?: string;
192
+ /**
193
+ * Turn this reference into an **engine-level join**, so a query can filter
194
+ * this type by a condition on the referent – `“every object published by
195
+ * institution X”` in one round-trip instead of two. Valid only alongside
196
+ * {@link ReferenceField.labelSource}, which already asserts that this
197
+ * field’s values are ids of documents in that type’s collection – exactly
198
+ * the fact a join needs.
199
+ *
200
+ * A capability flag in the vocabulary of `filterable`/`facetable`/`sortable`,
201
+ * and deliberately not derived from `labelSource`: an engine may refuse to
202
+ * index a mutual reference, so auto-deriving would make an existing schema
203
+ * silently lose a field. A `labelSource` added for display therefore costs
204
+ * nothing it does not cost today; only a `joinable` edge pays the
205
+ * component-scoped rebuild coupling.
206
+ *
207
+ * Never on an `inline` reference: that carries its referent as a nested
208
+ * object rather than as an id an engine can point a reference field at.
209
+ *
210
+ * At most ONE joinable field per (type, label source) – see
211
+ * {@link joinGraph}.
212
+ */
213
+ readonly joinable?: boolean;
192
214
  /**
193
215
  * The referenced entity’s shape and how much of it to carry. Required when
194
216
  * the field is `output` – the strategy is what decides the output shape, so
@@ -377,7 +399,8 @@ export interface SearchSchema<Types extends readonly SearchType[] = readonly Sea
377
399
  *
378
400
  * Every declaration is validated ({@link assertValidSearchType}) – the
379
401
  * declaration-time counterpart of the port’s `assertValidQuery` – and the
380
- * schema-wide invariants are enforced: no two Root Types may share a `class` IRI
402
+ * schema-wide invariants are enforced – including the join rules, by building
403
+ * the {@link joinGraph} eagerly: no two Root Types may share a `class` IRI
381
404
  * (they would silently overwrite each other in the map) and no two types may
382
405
  * share a `name` (names key the API surfaces, across Root and Reference Types
383
406
  * alike). Every inline reference must resolve to a declared Reference Type, and
@@ -461,7 +484,7 @@ export declare function labelFieldOf(searchType: SearchType): TextField | undefi
461
484
  */
462
485
  export interface SearchTypeIssue {
463
486
  readonly field: string;
464
- readonly reason: 'duplicate-field-name' | 'invalid-field-name' | 'unknown-kind' | 'invalid-locale' | 'missing-ref' | 'ref-not-allowed' | 'text-requires-locales' | 'locales-not-allowed' | 'facet-ranges-not-allowed' | 'searchable-not-allowed' | 'transform-not-allowed' | 'derive-with-path' | 'from-not-allowed' | 'from-with-path' | 'from-with-derive' | 'from-with-inline-ref' | 'unknown-projection-value' | 'duplicate-projection-value' | 'text-not-filterable' | 'text-not-facetable' | 'reserved-field-name';
487
+ readonly reason: 'duplicate-field-name' | 'invalid-field-name' | 'unknown-kind' | 'invalid-locale' | 'missing-ref' | 'missing-ref-type-name' | 'ref-not-allowed' | 'text-requires-locales' | 'locales-not-allowed' | 'facet-ranges-not-allowed' | 'searchable-not-allowed' | 'transform-not-allowed' | 'derive-with-path' | 'from-not-allowed' | 'from-with-path' | 'from-with-derive' | 'from-with-inline-ref' | 'unknown-projection-value' | 'duplicate-projection-value' | 'text-not-filterable' | 'text-not-facetable' | 'joinable-not-allowed' | 'joinable-without-label-source' | 'joinable-with-inline-ref' | 'reserved-field-name';
465
488
  }
466
489
  /**
467
490
  * The reserved logical name of a document’s IRI – the one field every indexed
@@ -503,7 +526,16 @@ export declare const OR_KEY = "or";
503
526
  * - a `text` field’s declared locales are BCP-47-shaped (no `_`, which is the
504
527
  * reserved name↔locale separator);
505
528
  * - a `reference` field that is `output` declares `ref` (the API surfaces
506
- * need the reference type name); `ref` on any other kind is meaningless;
529
+ * need the reference type name), and – unless its strategy is `idOnly`, which
530
+ * emits no type of its own – that `ref` declares a `typeName`; `ref` on any
531
+ * other kind is meaningless;
532
+ * - `joinable` only on a `reference` field, and only alongside a `labelSource`
533
+ * – the join addresses the referent’s collection, which is the one the label
534
+ * source names, so without it the flag states an edge to nowhere – and never
535
+ * on an `inline` reference, which is stored as a nested object rather than as
536
+ * an id a reference field can point at. The schema-wide half of the rule (at
537
+ * most one joinable field per (type, label source), no cycles) is
538
+ * {@link joinGraph}’s;
507
539
  * - a `text` field declares at least one locale (`und` = untagged; projection and
508
540
  * result reconstruction have no representation for unlocalized text – use
509
541
  * `keyword` for untagged strings); `locales` on any other kind is
@@ -609,11 +641,20 @@ export declare function sortableFields(searchType: SearchType): readonly SearchF
609
641
  export declare function outputFields(searchType: SearchType): readonly SearchField[];
610
642
  /**
611
643
  * Whether a field declares **no** role – none of `output`, `searchable`,
612
- * `filterable`, `facetable`, `sortable`. Such a field is an **internal field**:
613
- * the projection populates it (so a later {@link SearchFieldBase.derive} can
614
- * read it), then prunes it before the document reaches a writer, and the engine
615
- * collection definition omits it entirely – not stored, not indexed, no RAM.
616
- * Absence of a role declares that intent; there is no separate marker flag.
644
+ * `filterable`, `facetable`, `sortable`, `joinable`. Such a field is an
645
+ * **internal field**: the projection populates it (so a later
646
+ * {@link SearchFieldBase.derive} can read it), then prunes it before the
647
+ * document reaches a writer, and the engine collection definition omits it
648
+ * entirely – not stored, not indexed, no RAM. Absence of a role declares that
649
+ * intent; there is no separate marker flag.
650
+ *
651
+ * {@link ReferenceField.joinable} counts as a role for exactly the reason the
652
+ * others do: it is a promise about what the *engine* can do with the field, and
653
+ * an engine can only join through a value it actually stores. Leaving it out
654
+ * would let a joinable-but-otherwise-role-less reference declare an edge the
655
+ * schema resolves, the query validates and the compiler emits – against a
656
+ * collection that never stored the column, so every such query fails at the
657
+ * engine.
617
658
  *
618
659
  * The single predicate the projection and the collection definition share, so
619
660
  * they cannot disagree on what is internal. See the Search context’s load-bearing
@@ -1 +1 @@
1
- {"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../src/schema.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../src/schema.ts"],"names":[],"mappings":"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"}
package/dist/schema.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { joinGraph } from './join-graph.js';
1
2
  const OPERATOR_BY_KIND = {
2
3
  text: undefined,
3
4
  keyword: 'in',
@@ -49,7 +50,8 @@ function isRootType(searchType) {
49
50
  *
50
51
  * Every declaration is validated ({@link assertValidSearchType}) – the
51
52
  * declaration-time counterpart of the port’s `assertValidQuery` – and the
52
- * schema-wide invariants are enforced: no two Root Types may share a `class` IRI
53
+ * schema-wide invariants are enforced – including the join rules, by building
54
+ * the {@link joinGraph} eagerly: no two Root Types may share a `class` IRI
53
55
  * (they would silently overwrite each other in the map) and no two types may
54
56
  * share a `name` (names key the API surfaces, across Root and Reference Types
55
57
  * alike). Every inline reference must resolve to a declared Reference Type, and
@@ -84,6 +86,12 @@ export function searchSchema(...types) {
84
86
  .filter(isRootType)
85
87
  .map((searchType) => [searchType.class, searchType]));
86
88
  referenceTypesBySchema.set(schema, referenceTypes);
89
+ // Build the join graph eagerly and discard it: it is cached per schema, and
90
+ // building it is what enforces the schema-wide join rules (one joinable
91
+ // reference per target, every target an indexed Root Type, no cycles). A
92
+ // schema whose joins do not hold up therefore fails HERE, not on the first
93
+ // query or – worse – halfway through the first rebuild.
94
+ joinGraph(schema);
87
95
  return schema;
88
96
  }
89
97
  /**
@@ -384,7 +392,16 @@ const LOCALE_PATTERN = /^[A-Za-z0-9]+(-[A-Za-z0-9]+)*$/;
384
392
  * - a `text` field’s declared locales are BCP-47-shaped (no `_`, which is the
385
393
  * reserved name↔locale separator);
386
394
  * - a `reference` field that is `output` declares `ref` (the API surfaces
387
- * need the reference type name); `ref` on any other kind is meaningless;
395
+ * need the reference type name), and – unless its strategy is `idOnly`, which
396
+ * emits no type of its own – that `ref` declares a `typeName`; `ref` on any
397
+ * other kind is meaningless;
398
+ * - `joinable` only on a `reference` field, and only alongside a `labelSource`
399
+ * – the join addresses the referent’s collection, which is the one the label
400
+ * source names, so without it the flag states an edge to nowhere – and never
401
+ * on an `inline` reference, which is stored as a nested object rather than as
402
+ * an id a reference field can point at. The schema-wide half of the rule (at
403
+ * most one joinable field per (type, label source), no cycles) is
404
+ * {@link joinGraph}’s;
388
405
  * - a `text` field declares at least one locale (`und` = untagged; projection and
389
406
  * result reconstruction have no representation for unlocalized text – use
390
407
  * `keyword` for untagged strings); `locales` on any other kind is
@@ -444,9 +461,38 @@ export function validateSearchType(searchType) {
444
461
  if (field.output === true && field.ref === undefined) {
445
462
  issue('missing-ref');
446
463
  }
464
+ // `typeName` is optional only for `idOnly`, which emits no type and so
465
+ // needs no name to emit one under. Every other surfaced strategy is
466
+ // served AS a named type, and without a name an API surface has nothing
467
+ // to emit – the GraphQL one silently prints `undefined` as the field’s
468
+ // type, publishing a contract that is not a schema.
469
+ if (field.output === true &&
470
+ field.ref !== undefined &&
471
+ field.ref.strategy !== 'idOnly' &&
472
+ field.ref.typeName === undefined) {
473
+ issue('missing-ref-type-name');
474
+ }
475
+ // A join addresses the referent’s collection, which is the collection the
476
+ // label source names: without one the flag states an edge to nowhere.
477
+ if (field.joinable === true && field.labelSource === undefined) {
478
+ issue('joinable-without-label-source');
479
+ }
480
+ // An inline reference is stored as a NESTED OBJECT, not as an id an
481
+ // engine can point a reference field at, so the two cannot both hold:
482
+ // the collection definition would emit the nesting and silently drop the
483
+ // reference, leaving a schema whose joins validate, compile and then fail
484
+ // at the engine. Carry the referent inline or join to it, not both.
485
+ if (field.joinable === true && field.ref?.strategy === 'inline') {
486
+ issue('joinable-with-inline-ref');
487
+ }
447
488
  }
448
- else if (field.ref !== undefined) {
449
- issue('ref-not-allowed');
489
+ else {
490
+ if (field.ref !== undefined) {
491
+ issue('ref-not-allowed');
492
+ }
493
+ if (field.joinable === true) {
494
+ issue('joinable-not-allowed');
495
+ }
450
496
  }
451
497
  if (field.kind === 'text') {
452
498
  if ((field.locales ?? []).length === 0) {
@@ -622,11 +668,20 @@ export function outputFields(searchType) {
622
668
  }
623
669
  /**
624
670
  * Whether a field declares **no** role – none of `output`, `searchable`,
625
- * `filterable`, `facetable`, `sortable`. Such a field is an **internal field**:
626
- * the projection populates it (so a later {@link SearchFieldBase.derive} can
627
- * read it), then prunes it before the document reaches a writer, and the engine
628
- * collection definition omits it entirely – not stored, not indexed, no RAM.
629
- * Absence of a role declares that intent; there is no separate marker flag.
671
+ * `filterable`, `facetable`, `sortable`, `joinable`. Such a field is an
672
+ * **internal field**: the projection populates it (so a later
673
+ * {@link SearchFieldBase.derive} can read it), then prunes it before the
674
+ * document reaches a writer, and the engine collection definition omits it
675
+ * entirely – not stored, not indexed, no RAM. Absence of a role declares that
676
+ * intent; there is no separate marker flag.
677
+ *
678
+ * {@link ReferenceField.joinable} counts as a role for exactly the reason the
679
+ * others do: it is a promise about what the *engine* can do with the field, and
680
+ * an engine can only join through a value it actually stores. Leaving it out
681
+ * would let a joinable-but-otherwise-role-less reference declare an edge the
682
+ * schema resolves, the query validates and the compiler emits – against a
683
+ * collection that never stored the column, so every such query fails at the
684
+ * engine.
630
685
  *
631
686
  * The single predicate the projection and the collection definition share, so
632
687
  * they cannot disagree on what is internal. See the Search context’s load-bearing
@@ -637,7 +692,8 @@ export function isInternalField(field) {
637
692
  field.searchable === undefined &&
638
693
  field.filterable !== true &&
639
694
  field.facetable !== true &&
640
- field.sortable !== true);
695
+ field.sortable !== true &&
696
+ (field.kind !== 'reference' || field.joinable !== true));
641
697
  }
642
698
  /** Fields of kind `reference` (IRI-valued, label-resolved), in declaration order. */
643
699
  export function referenceFields(searchType) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lde/search",
3
- "version": "0.19.0",
3
+ "version": "0.21.0",
4
4
  "description": "Engine- and domain-agnostic core for RDF-backed search: a unified declarative field model (SearchField/SearchType/SearchSchema), a neutral query IR, the SearchEngine port with logical result types, and a streaming CONSTRUCT-to-document projection. Bakes in no engine, protocol, or domain.",
5
5
  "repository": {
6
6
  "url": "git+https://github.com/ldelements/lde.git",