@lde/search-typesense 0.22.0 → 0.23.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.
@@ -1 +1 @@
1
- {"version":3,"file":"blue-green-rebuild.d.ts","sourceRoot":"","sources":["../src/blue-green-rebuild.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AACxC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAC9C,OAAO,KAAK,EAEV,UAAU,EACV,SAAS,EACT,MAAM,EACP,MAAM,eAAe,CAAC;AAUvB,OAAO,EAML,KAAK,cAAc,EAEpB,MAAM,sBAAsB,CAAC;AAE9B;;yDAEyD;AACzD,MAAM,MAAM,uBAAuB,GAAG,cAAc,CAAC;AAErD;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,qBAAa,gBAAgB,CAC3B,SAAS,SAAS;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE,CAChC,YAAW,MAAM,CAAC,SAAS,CAAC;IAiB1B,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,UAAU;IAjB7B;;;;;;;OAOG;IACH,SAAgB,cAAc,EAAE,MAAM,CAAC;IACvC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAyB;IAClD;;4EAEwE;IACxE,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAS;gBAGlB,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,UAAU,EACvC,OAAO,GAAE,uBAA4B;IAYjC,OAAO,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,SAAS,CAAC,SAAS,CAAC,CAAC;IAmGjE,4EAA4E;YAC9D,WAAW;CAW1B"}
1
+ {"version":3,"file":"blue-green-rebuild.d.ts","sourceRoot":"","sources":["../src/blue-green-rebuild.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AACxC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAC9C,OAAO,KAAK,EAEV,UAAU,EACV,SAAS,EACT,MAAM,EACP,MAAM,eAAe,CAAC;AAUvB,OAAO,EAML,KAAK,cAAc,EAEpB,MAAM,sBAAsB,CAAC;AAE9B;;yDAEyD;AACzD,MAAM,MAAM,uBAAuB,GAAG,cAAc,CAAC;AAErD;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,qBAAa,gBAAgB,CAC3B,SAAS,SAAS;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE,CAChC,YAAW,MAAM,CAAC,SAAS,CAAC;IAiB1B,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,UAAU;IAjB7B;;;;;;;OAOG;IACH,SAAgB,cAAc,EAAE,MAAM,CAAC;IACvC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAyB;IAClD;;4EAEwE;IACxE,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAS;gBAGlB,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,UAAU,EACvC,OAAO,GAAE,uBAA4B;IAajC,OAAO,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,SAAS,CAAC,SAAS,CAAC,CAAC;IAwHjE,4EAA4E;YAC9D,WAAW;CAW1B"}
@@ -59,7 +59,8 @@ export class BlueGreenRebuild {
59
59
  assertSweepableProvenanceField(searchType, { requireFacetable: false });
60
60
  this.sourceField = provenanceField(searchType);
61
61
  this.resolved = resolveRebuildOptions(searchType, options);
62
- this.collectionName = this.resolved.definitionOptions.name;
62
+ this.collectionName =
63
+ this.resolved.definitionOptions.collectionNameFor(searchType);
63
64
  }
64
65
  async openRun(context) {
65
66
  const { batchSize, lockTtlMs, definitionOptions } = this.resolved;
@@ -69,12 +70,25 @@ export class BlueGreenRebuild {
69
70
  // Create the fresh (blue) collection up front, so a failure surfaces
70
71
  // before any dataset is processed. startedAt orders the versioned names;
71
72
  // concurrent same-name runs are excluded by the lock.
72
- const collection = `${name}_${Date.parse(context.startedAt)}`;
73
+ //
74
+ // The SAME versioning applies to every peer this type joins to, so an
75
+ // emitted reference field names the peer’s FRESH collection rather than
76
+ // its live one – Typesense resolves an alias to a concrete collection at
77
+ // create time and keeps the concrete name, so referencing the alias would
78
+ // pin the new build to the collection the peer is about to supersede. No
79
+ // coordinator is needed: every writer in the run gets the same
80
+ // `RunContext`, so `startedAt` – and therefore every derived name – is
81
+ // identical across them.
82
+ const version = Date.parse(context.startedAt);
83
+ const versioned = (searchType) => `${definitionOptions.collectionNameFor(searchType)}_${version}`;
84
+ const collection = versioned(this.searchType);
73
85
  const previous = await this.aliasTarget(name);
74
- const definition = buildCollectionDefinition(this.searchType, definitionOptions);
86
+ const definition = buildCollectionDefinition(this.searchType, {
87
+ ...definitionOptions,
88
+ collectionNameFor: versioned,
89
+ });
75
90
  await this.client.collections().create({
76
91
  ...definition,
77
- name: collection,
78
92
  // The private `source` only when the type declares no dataset field of
79
93
  // its own; a declared one is already in the definition and carries the
80
94
  // same IRI.
@@ -134,6 +148,15 @@ export class BlueGreenRebuild {
134
148
  .catch(() => undefined);
135
149
  await releaseLock(this.client, name);
136
150
  },
151
+ abandon: async () => {
152
+ // Everything `abort` does EXCEPT the drop. Asked for when a peer
153
+ // whose documents reference this collection by concrete name has
154
+ // already gone live: dropping it would break that live collection’s
155
+ // joins, so the collection is kept for a later run to supersede.
156
+ // Releasing the lock is the whole point – without it the index stays
157
+ // locked for its full TTL and the next run cannot rebuild it.
158
+ await releaseLock(this.client, name);
159
+ },
137
160
  };
138
161
  });
139
162
  }
@@ -2,17 +2,30 @@ import type { CollectionCreateSchema } from 'typesense';
2
2
  import { type SearchSchema, type SearchType } from '@lde/search';
3
3
  /** Deployment-specific options the generic field model does not carry. */
4
4
  export interface CollectionDefinitionOptions {
5
- /** The Typesense collection (or alias) name. Omit to derive it from the
6
- * type’s `name` ({@link deriveCollectionName}); supply one to override that
7
- * (an env prefix, a multi-tenant name, an existing collection). */
8
- readonly name?: string;
9
5
  /**
10
- * The Search Schema the type belongs to – required only when the type
11
- * surfaces an inline reference, whose nested fields are declared from the
12
- * {@link ReferenceType} the schema resolves. A type without one needs no
13
- * schema, so a caller that declares no nesting passes none; a type that does
14
- * fails here rather than building a collection that silently omits the
15
- * nesting.
6
+ * How a search type is named in Typesense – for **this** type and, where it
7
+ * declares a {@link ReferenceField.joinable} reference, for the peer type
8
+ * that reference points at. Omit to derive every name from the type’s own
9
+ * `name` ({@link deriveCollectionName}); supply one to override that (an env
10
+ * prefix, a multi-tenant name, an existing collection).
11
+ *
12
+ * A function of the type rather than a bare name, because an emitted
13
+ * reference field must name the peer’s **concrete** collection: a
14
+ * blue/green writer therefore hands in a function that appends the run’s
15
+ * version to every name in the component, so a fresh build references its
16
+ * peer’s fresh collection rather than the live one. No coordinator is
17
+ * needed – every writer in a run receives the same `RunContext`, so they
18
+ * derive identical names.
19
+ */
20
+ readonly collectionNameFor?: (searchType: SearchType) => string;
21
+ /**
22
+ * The Search Schema the type belongs to – required when the type surfaces an
23
+ * inline reference (whose nested fields are declared from the
24
+ * {@link ReferenceType} the schema resolves) or declares a joinable
25
+ * reference (whose target collection the schema’s {@link joinGraph}
26
+ * resolves). A type doing neither needs no schema, so a caller passes none;
27
+ * a type that does fails here rather than building a collection that
28
+ * silently omits the nesting or the reference.
16
29
  */
17
30
  readonly schema?: SearchSchema;
18
31
  /** Snowball stemming locale for non-localized searchable fields (e.g. `en`).
@@ -37,9 +50,15 @@ export interface CollectionDefinitionOptions {
37
50
  * stems in `defaultLocale` when one is set, and is left unstemmed (folded
38
51
  * only) otherwise.
39
52
  *
40
- * The collection `name` is optional: omitted, it is derived from the type’s own
41
- * `name` ({@link deriveCollectionName}), so a caller that has no naming opinion
42
- * states none.
53
+ * Collection naming is optional: omitted, every name – this type’s and the peer
54
+ * types its joins point at – is derived from the type’s own `name`
55
+ * ({@link deriveCollectionName}), so a caller that has no naming opinion states
56
+ * none.
57
+ *
58
+ * A {@link ReferenceField.joinable} reference is emitted as a Typesense
59
+ * **reference field** pointing at its target collection’s `id`, so a query can
60
+ * filter this type by a condition on the referent
61
+ * ({@link referenceDeclaration}).
43
62
  *
44
63
  * Memory lever: Typesense holds the index in RAM (with a raw copy of each
45
64
  * document on disk), so RAM tracks the *indexed* surface – roughly 2–3× the
@@ -1 +1 @@
1
- {"version":3,"file":"collection-definition.d.ts","sourceRoot":"","sources":["../src/collection-definition.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,WAAW,CAAC;AAExD,OAAO,EAGL,KAAK,YAAY,EACjB,KAAK,UAAU,EAChB,MAAM,aAAa,CAAC;AAWrB,0EAA0E;AAC1E,MAAM,WAAW,2BAA2B;IAC1C;;wEAEoE;IACpE,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB;;;;;;;OAOG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,YAAY,CAAC;IAC/B;;;4BAGwB;IACxB,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAC;IAChC,kEAAkE;IAClE,QAAQ,CAAC,mBAAmB,CAAC,EAAE,MAAM,CAAC;IACtC,kEAAkE;IAClE,QAAQ,CAAC,WAAW,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC1C;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AACH,wBAAgB,yBAAyB,CACvC,UAAU,EAAE,UAAU,EACtB,OAAO,GAAE,2BAAgC,GACxC,sBAAsB,CA0BxB"}
1
+ {"version":3,"file":"collection-definition.d.ts","sourceRoot":"","sources":["../src/collection-definition.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,WAAW,CAAC;AAExD,OAAO,EAGL,KAAK,YAAY,EACjB,KAAK,UAAU,EAChB,MAAM,aAAa,CAAC;AAarB,0EAA0E;AAC1E,MAAM,WAAW,2BAA2B;IAC1C;;;;;;;;;;;;;;OAcG;IACH,QAAQ,CAAC,iBAAiB,CAAC,EAAE,CAAC,UAAU,EAAE,UAAU,KAAK,MAAM,CAAC;IAChE;;;;;;;;OAQG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,YAAY,CAAC;IAC/B;;;4BAGwB;IACxB,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAC;IAChC,kEAAkE;IAClE,QAAQ,CAAC,mBAAmB,CAAC,EAAE,MAAM,CAAC;IACtC,kEAAkE;IAClE,QAAQ,CAAC,WAAW,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC1C;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AACH,wBAAgB,yBAAyB,CACvC,UAAU,EAAE,UAAU,EACtB,OAAO,GAAE,2BAAgC,GACxC,sBAAsB,CA2BxB"}
@@ -1,4 +1,4 @@
1
- import { displayFieldPattern, isInlineReference, isInternalField, nestedFieldName, nestedReferenceType, physicalFields, } from '@lde/search/adapter';
1
+ import { displayFieldPattern, ID_FIELD, isInlineReference, isInternalField, joinGraph, nestedFieldName, nestedReferenceType, physicalFields, } from '@lde/search/adapter';
2
2
  import { deriveCollectionName } from './collection-name.js';
3
3
  /**
4
4
  * Build a Typesense collection definition from the unified {@link SearchType}, so
@@ -12,9 +12,15 @@ import { deriveCollectionName } from './collection-name.js';
12
12
  * stems in `defaultLocale` when one is set, and is left unstemmed (folded
13
13
  * only) otherwise.
14
14
  *
15
- * The collection `name` is optional: omitted, it is derived from the type’s own
16
- * `name` ({@link deriveCollectionName}), so a caller that has no naming opinion
17
- * states none.
15
+ * Collection naming is optional: omitted, every name – this type’s and the peer
16
+ * types its joins point at – is derived from the type’s own `name`
17
+ * ({@link deriveCollectionName}), so a caller that has no naming opinion states
18
+ * none.
19
+ *
20
+ * A {@link ReferenceField.joinable} reference is emitted as a Typesense
21
+ * **reference field** pointing at its target collection’s `id`, so a query can
22
+ * filter this type by a condition on the referent
23
+ * ({@link referenceDeclaration}).
18
24
  *
19
25
  * Memory lever: Typesense holds the index in RAM (with a raw copy of each
20
26
  * document on disk), so RAM tracks the *indexed* surface – roughly 2–3× the
@@ -39,10 +45,10 @@ import { deriveCollectionName } from './collection-name.js';
39
45
  * display labels.
40
46
  */
41
47
  export function buildCollectionDefinition(searchType, options = {}) {
42
- const { defaultLocale } = options;
48
+ const { defaultLocale, collectionNameFor = deriveCollectionName } = options;
43
49
  const collection = {
44
- name: options.name ?? deriveCollectionName(searchType),
45
- fields: searchType.fields.flatMap((field) => typesenseFields(searchType, field, defaultLocale, options.defaultSortingField, options.schema)),
50
+ name: collectionNameFor(searchType),
51
+ fields: searchType.fields.flatMap((field) => typesenseFields(searchType, field, defaultLocale, options.defaultSortingField, options.schema, collectionNameFor)),
46
52
  };
47
53
  // Typesense rejects an `object`/`object[]` field unless nesting is enabled,
48
54
  // so the flag is a consequence of the declaration – never a knob.
@@ -73,8 +79,54 @@ function nestedTypeOf(searchType, field, schema) {
73
79
  }
74
80
  return nested;
75
81
  }
82
+ /**
83
+ * The Typesense reference declaration a {@link ReferenceField.joinable} field
84
+ * carries, or `undefined` for every other field. All three keys are forced,
85
+ * none is a knob:
86
+ *
87
+ * - **`reference` always targets `.id`.** A reference match hitting more than
88
+ * one document is a 400, and `id` is the only field the schema guarantees
89
+ * unique.
90
+ * - **`async_reference: true`.** Without it, a document whose referent is not
91
+ * indexed yet is rejected with a 400 – and the batch import runs
92
+ * `throwOnFail: false`, so those would be silently dropped documents.
93
+ * Documents stream per dataset ([ADR 13](../../../docs/decisions/0013-project-inside-the-batch-per-root-type.md)),
94
+ * so out-of-order arrival is normal rather than exceptional; the engine
95
+ * back-fills the reference when the referent lands.
96
+ * - **`cascade_delete: false`.** It defaults to `true`, so a sweep removing a
97
+ * departed source’s referent documents would delete other sources’ referring
98
+ * documents with them. Disabling it requires `async_reference`, so the two
99
+ * travel together.
100
+ *
101
+ * Throws when the type declares a joinable reference the caller gave no schema
102
+ * to resolve the target of – the collection would otherwise come into existence
103
+ * without its reference and 400 on every join query.
104
+ */
105
+ function referenceDeclaration(searchType, field, schema, collectionNameFor) {
106
+ if (field.kind !== 'reference' || field.joinable !== true) {
107
+ return undefined;
108
+ }
109
+ if (schema === undefined) {
110
+ throw new Error(`Building the collection for “${searchType.name}” needs the search schema its joinable reference “${field.name}” resolves against; pass it as the collection-definition option “schema”.`);
111
+ }
112
+ const target = joinGraph(schema).resolve(searchType, [field.name]);
113
+ if (target === undefined) {
114
+ // A schema WAS given and the edge still does not resolve. The rules that
115
+ // would reject the declaration itself all ran when the schema was built, so
116
+ // what is left is identity: `joinGraph` resolves from the exact declaration
117
+ // object the schema holds, and this is a lookalike – a re-declared type, or
118
+ // one belonging to another schema. Saying “pass the schema” here would send
119
+ // a caller to do the thing they already did.
120
+ throw new Error(`Building the collection for “${searchType.name}” cannot resolve its joinable reference “${field.name}”: the given search schema does not declare this exact type. Pass the declaration the schema was built from, not a copy of it.`);
121
+ }
122
+ return {
123
+ reference: `${collectionNameFor(target)}.${ID_FIELD}`,
124
+ async_reference: true,
125
+ cascade_delete: false,
126
+ };
127
+ }
76
128
  /** The physical Typesense fields one declaration produces. */
77
- function typesenseFields(searchType, field, defaultLocale, defaultSortingField, schema) {
129
+ function typesenseFields(searchType, field, defaultLocale, defaultSortingField, schema, collectionNameFor) {
78
130
  // An internal field (no role) is projected as a reading device for the
79
131
  // derives and pruned before the writer, so the collection stores nothing for
80
132
  // it: not stored, not indexed, no RAM. Same predicate the projection prunes
@@ -137,6 +189,7 @@ function typesenseFields(searchType, field, defaultLocale, defaultSortingField,
137
189
  // A `required` field is non-optional; so is the `default_sorting_field`,
138
190
  // which Typesense requires to be present. Everything else may be absent.
139
191
  optional: field.required !== true && field.name !== defaultSortingField,
192
+ ...referenceDeclaration(searchType, field, schema, collectionNameFor),
140
193
  },
141
194
  ];
142
195
  // `names.search` is non-empty exactly when the field projects a folded
@@ -34,7 +34,10 @@ export interface InPlaceRebuildOptions extends RebuildOptions {
34
34
  * `openRun` takes the single-flight cross-pod lock (throwing
35
35
  * `RebuildAlreadyRunning` when another rebuild holds it) and creates
36
36
  * the collection on demand from the {@link SearchType} plus the two
37
- * bookkeeping fields.
37
+ * bookkeeping fields. A collection that already exists is used as it is, with
38
+ * one exception: it must carry every reference field the declaration asks for,
39
+ * or the run fails with the drop-and-rebuild that fixes it
40
+ * ({@link assertReferencesPresent}).
38
41
  *
39
42
  * Document ids must be unique per (source, entity) – the caller keys them –
40
43
  * or documents from different sources overwrite each other.
@@ -1 +1 @@
1
- {"version":3,"file":"in-place-rebuild.d.ts","sourceRoot":"","sources":["../src/in-place-rebuild.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AAExC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAE9C,OAAO,KAAK,EAEV,UAAU,EACV,SAAS,EACT,MAAM,EACP,MAAM,eAAe,CAAC;AAavB,OAAO,EAML,KAAK,cAAc,EAEpB,MAAM,sBAAsB,CAAC;AAa9B;gCACgC;AAChC,MAAM,WAAW,qBAAsB,SAAQ,cAAc;IAC3D;sEACkE;IAClE,QAAQ,CAAC,mBAAmB,CAAC,EAAE,MAAM,CAAC;CACvC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,qBAAa,cAAc,CACzB,SAAS,SAAS;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE,CAChC,YAAW,MAAM,CAAC,SAAS,CAAC;IAiB1B,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,UAAU;IAjB7B;;;;;OAKG;IACH,SAAgB,cAAc,EAAE,MAAM,CAAC;IACvC,OAAO,CAAC,QAAQ,CAAC,mBAAmB,CAAS;IAC7C,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAyB;IAClD;;;6CAGyC;IACzC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAS;gBAGlB,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,UAAU,EACvC,OAAO,GAAE,qBAA0B;IAc/B,OAAO,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,SAAS,CAAC,SAAS,CAAC,CAAC;IAiHjE;;;;;;OAMG;YACW,cAAc;CAsB7B"}
1
+ {"version":3,"file":"in-place-rebuild.d.ts","sourceRoot":"","sources":["../src/in-place-rebuild.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AAExC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAE9C,OAAO,KAAK,EAEV,UAAU,EACV,SAAS,EACT,MAAM,EACP,MAAM,eAAe,CAAC;AAavB,OAAO,EAML,KAAK,cAAc,EAEpB,MAAM,sBAAsB,CAAC;AAa9B;gCACgC;AAChC,MAAM,WAAW,qBAAsB,SAAQ,cAAc;IAC3D;sEACkE;IAClE,QAAQ,CAAC,mBAAmB,CAAC,EAAE,MAAM,CAAC;CACvC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,qBAAa,cAAc,CACzB,SAAS,SAAS;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE,CAChC,YAAW,MAAM,CAAC,SAAS,CAAC;IAiB1B,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,UAAU;IAjB7B;;;;;OAKG;IACH,SAAgB,cAAc,EAAE,MAAM,CAAC;IACvC,OAAO,CAAC,QAAQ,CAAC,mBAAmB,CAAS;IAC7C,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAyB;IAClD;;;6CAGyC;IACzC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAS;gBAGlB,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,UAAU,EACvC,OAAO,GAAE,qBAA0B;IAe/B,OAAO,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,SAAS,CAAC,SAAS,CAAC,CAAC;IAuHjE;;;;;;OAMG;YACW,cAAc;CAsB7B"}
@@ -38,7 +38,10 @@ const DEFAULT_MAX_SWEEPABLE_SOURCES = 10_000;
38
38
  * `openRun` takes the single-flight cross-pod lock (throwing
39
39
  * `RebuildAlreadyRunning` when another rebuild holds it) and creates
40
40
  * the collection on demand from the {@link SearchType} plus the two
41
- * bookkeeping fields.
41
+ * bookkeeping fields. A collection that already exists is used as it is, with
42
+ * one exception: it must carry every reference field the declaration asks for,
43
+ * or the run fails with the drop-and-rebuild that fixes it
44
+ * ({@link assertReferencesPresent}).
42
45
  *
43
46
  * Document ids must be unique per (source, entity) – the caller keys them –
44
47
  * or documents from different sources overwrite each other.
@@ -72,7 +75,8 @@ export class InPlaceRebuild {
72
75
  const { maxSweepableSources = DEFAULT_MAX_SWEEPABLE_SOURCES, ...rebuildOptions } = options;
73
76
  this.maxSweepableSources = maxSweepableSources;
74
77
  this.resolved = resolveRebuildOptions(searchType, rebuildOptions);
75
- this.collectionName = this.resolved.definitionOptions.name;
78
+ this.collectionName =
79
+ this.resolved.definitionOptions.collectionNameFor(searchType);
76
80
  }
77
81
  async openRun(context) {
78
82
  const { batchSize, lockTtlMs, definitionOptions } = this.resolved;
@@ -85,8 +89,9 @@ export class InPlaceRebuild {
85
89
  // field of its own; when it does, that declared field already carries the
86
90
  // IRI (and its own `facet: true`), and a second column would be the same
87
91
  // value twice, free to drift.
88
- await ensureCollectionExists(this.client, name, () => {
89
- const definition = buildCollectionDefinition(this.searchType, definitionOptions);
92
+ const definition = buildCollectionDefinition(this.searchType, definitionOptions);
93
+ const declaredFields = definition.fields ?? [];
94
+ const created = await ensureCollectionExists(this.client, name, () => {
90
95
  const bookkeeping = [
91
96
  ...(sourceField === SOURCE_FIELD
92
97
  ? [{ name: SOURCE_FIELD, type: 'string', facet: true }]
@@ -95,9 +100,14 @@ export class InPlaceRebuild {
95
100
  ];
96
101
  return {
97
102
  ...definition,
98
- fields: [...(definition.fields ?? []), ...bookkeeping],
103
+ fields: [...declaredFields, ...bookkeeping],
99
104
  };
100
105
  });
106
+ // A collection this run did NOT create may predate a `joinable` being
107
+ // declared, and a reference cannot be added to it here.
108
+ if (!created) {
109
+ await assertReferencesPresent(this.client, name, declaredFields);
110
+ }
101
111
  const importer = new BatchImporter(this.client, name, batchSize);
102
112
  return {
103
113
  // Stamping the provenance field – rather than trusting the projection
@@ -175,3 +185,37 @@ export class InPlaceRebuild {
175
185
  return counts.map((count) => count.value);
176
186
  }
177
187
  }
188
+ /**
189
+ * Fail loudly when an EXISTING collection does not carry every reference field
190
+ * the declaration asks for, naming the drop-and-rebuild that fixes it. Never
191
+ * alters: an In-place rebuild creates a collection on demand and otherwise
192
+ * leaves its shape alone, and a reference field is the one difference that is
193
+ * not self-correcting.
194
+ *
195
+ * Without this a deployment that added `joinable` to an existing schema would
196
+ * index and commit perfectly happily, and then 400 on every join query – the
197
+ * collection has the *values*, it just has no reference to join through.
198
+ * Failing here keeps the invariant a component-scoped rebuild rests on: a
199
+ * component’s collections come into existence WITH their references, and never
200
+ * acquire them later.
201
+ *
202
+ * Deliberately scoped to reference fields only. Every other schema difference
203
+ * is self-correcting (a new plain field simply starts being written) or
204
+ * harmless, and general drift detection is a feature of its own.
205
+ */
206
+ async function assertReferencesPresent(client, name, fields) {
207
+ const declared = fields.filter((field) => field.reference !== undefined);
208
+ if (declared.length === 0) {
209
+ return;
210
+ }
211
+ const existing = new Map((await client.collections(name).retrieve()).fields.map((field) => [
212
+ field.name,
213
+ field.reference,
214
+ ]));
215
+ const missing = declared.filter((field) => existing.get(field.name) !== field.reference);
216
+ if (missing.length > 0) {
217
+ throw new Error(`Typesense collection “${name}” exists without the reference field(s) ${missing
218
+ .map((field) => `“${field.name}” → “${field.reference}”`)
219
+ .join(', ')} its search type declares. A reference cannot be added to a live collection here: drop “${name}” (and the collections that join to it) and let the next run rebuild them.`);
220
+ }
221
+ }
package/dist/index.d.ts CHANGED
@@ -8,7 +8,7 @@ export { buildCollectionDefinition } from './collection-definition.js';
8
8
  export type { CollectionDefinitionOptions } from './collection-definition.js';
9
9
  export { deriveCollectionName } from './collection-name.js';
10
10
  export { buildSearchParams } from './query-compiler.js';
11
- export type { BuildSearchParamsOptions } from './query-compiler.js';
11
+ export type { BuildSearchParamsOptions, JoinTarget } from './query-compiler.js';
12
12
  export { createTypesenseSearchEngine, parseSearchResponse } from './search.js';
13
13
  export type { TypesenseSearchEngine, TypesenseSearchEngineOptions, TypesenseSearchResponse, } from './search.js';
14
14
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;AAC3D,YAAY,EAAE,uBAAuB,EAAE,MAAM,yBAAyB,CAAC;AACvE,OAAO,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AACvD,YAAY,EAAE,qBAAqB,EAAE,MAAM,uBAAuB,CAAC;AACnE,OAAO,EAAE,qBAAqB,EAAE,MAAM,WAAW,CAAC;AAClD,OAAO,EACL,eAAe,EACf,sBAAsB,EACtB,qBAAqB,EACrB,oBAAoB,GACrB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,yBAAyB,EAAE,MAAM,4BAA4B,CAAC;AACvE,YAAY,EAAE,2BAA2B,EAAE,MAAM,4BAA4B,CAAC;AAC9E,OAAO,EAAE,oBAAoB,EAAE,MAAM,sBAAsB,CAAC;AAC5D,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AACxD,YAAY,EAAE,wBAAwB,EAAE,MAAM,qBAAqB,CAAC;AACpE,OAAO,EAAE,2BAA2B,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AAC/E,YAAY,EACV,qBAAqB,EACrB,4BAA4B,EAC5B,uBAAuB,GACxB,MAAM,aAAa,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;AAC3D,YAAY,EAAE,uBAAuB,EAAE,MAAM,yBAAyB,CAAC;AACvE,OAAO,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AACvD,YAAY,EAAE,qBAAqB,EAAE,MAAM,uBAAuB,CAAC;AACnE,OAAO,EAAE,qBAAqB,EAAE,MAAM,WAAW,CAAC;AAClD,OAAO,EACL,eAAe,EACf,sBAAsB,EACtB,qBAAqB,EACrB,oBAAoB,GACrB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,yBAAyB,EAAE,MAAM,4BAA4B,CAAC;AACvE,YAAY,EAAE,2BAA2B,EAAE,MAAM,4BAA4B,CAAC;AAC9E,OAAO,EAAE,oBAAoB,EAAE,MAAM,sBAAsB,CAAC;AAC5D,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AACxD,YAAY,EAAE,wBAAwB,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAC;AAChF,OAAO,EAAE,2BAA2B,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AAC/E,YAAY,EACV,qBAAqB,EACrB,4BAA4B,EAC5B,uBAAuB,GACxB,MAAM,aAAa,CAAC"}
package/dist/lock.d.ts CHANGED
@@ -35,8 +35,13 @@ export declare function releaseLock(client: Client, name: string): Promise<void>
35
35
  /**
36
36
  * Create a collection on demand: retrieve it, and only on a 404 create it
37
37
  * from the lazily built `schema`, tolerating a concurrent creator (409).
38
+ *
39
+ * Returns whether the collection was **brought into existence** by this call –
40
+ * which is what tells a caller that everything the definition declares is
41
+ * actually in the collection. An existing one may predate a declaration
42
+ * change, and some of those differences an engine cannot correct in place.
38
43
  */
39
- export declare function ensureCollectionExists(client: Client, name: string, schema: () => CollectionCreateSchema): Promise<void>;
44
+ export declare function ensureCollectionExists(client: Client, name: string, schema: () => CollectionCreateSchema): Promise<boolean>;
40
45
  /** The HTTP status a Typesense client error carries, if any. */
41
46
  export declare function httpStatus(error: unknown): number | undefined;
42
47
  //# sourceMappingURL=lock.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"lock.d.ts","sourceRoot":"","sources":["../src/lock.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,sBAAsB,EAAE,MAAM,WAAW,CAAC;AAIhE,qDAAqD;AACrD,eAAO,MAAM,mBAAmB,QAAiB,CAAC;AAElD;;;;GAIG;AACH,qBAAa,qBAAsB,SAAQ,KAAK;gBAClC,IAAI,EAAE,MAAM;CAIzB;AAED;;;;;;GAMG;AACH,wBAAsB,aAAa,CAAC,GAAG,EACrC,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,MAAM,OAAO,CAAC,GAAG,CAAC,GACxB,OAAO,CAAC,GAAG,CAAC,CAUd;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,WAAW,CAC/B,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,GACZ,OAAO,CAAC,OAAO,CAAC,CAwBlB;AAED,yEAAyE;AACzE,wBAAsB,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAQ7E;AAED;;;GAGG;AACH,wBAAsB,sBAAsB,CAC1C,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,MAAM,sBAAsB,GACnC,OAAO,CAAC,IAAI,CAAC,CAgBf;AA+BD,gEAAgE;AAChE,wBAAgB,UAAU,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAE7D"}
1
+ {"version":3,"file":"lock.d.ts","sourceRoot":"","sources":["../src/lock.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,sBAAsB,EAAE,MAAM,WAAW,CAAC;AAIhE,qDAAqD;AACrD,eAAO,MAAM,mBAAmB,QAAiB,CAAC;AAElD;;;;GAIG;AACH,qBAAa,qBAAsB,SAAQ,KAAK;gBAClC,IAAI,EAAE,MAAM;CAIzB;AAED;;;;;;GAMG;AACH,wBAAsB,aAAa,CAAC,GAAG,EACrC,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,MAAM,OAAO,CAAC,GAAG,CAAC,GACxB,OAAO,CAAC,GAAG,CAAC,CAUd;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,WAAW,CAC/B,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,GACZ,OAAO,CAAC,OAAO,CAAC,CAwBlB;AAED,yEAAyE;AACzE,wBAAsB,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAQ7E;AAED;;;;;;;;GAQG;AACH,wBAAsB,sBAAsB,CAC1C,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,MAAM,sBAAsB,GACnC,OAAO,CAAC,OAAO,CAAC,CAmBlB;AA+BD,gEAAgE;AAChE,wBAAgB,UAAU,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAE7D"}
package/dist/lock.js CHANGED
@@ -83,11 +83,16 @@ export async function releaseLock(client, name) {
83
83
  /**
84
84
  * Create a collection on demand: retrieve it, and only on a 404 create it
85
85
  * from the lazily built `schema`, tolerating a concurrent creator (409).
86
+ *
87
+ * Returns whether the collection was **brought into existence** by this call –
88
+ * which is what tells a caller that everything the definition declares is
89
+ * actually in the collection. An existing one may predate a declaration
90
+ * change, and some of those differences an engine cannot correct in place.
86
91
  */
87
92
  export async function ensureCollectionExists(client, name, schema) {
88
93
  try {
89
94
  await client.collections(name).retrieve();
90
- return;
95
+ return false;
91
96
  }
92
97
  catch (error) {
93
98
  if (httpStatus(error) !== 404) {
@@ -96,11 +101,14 @@ export async function ensureCollectionExists(client, name, schema) {
96
101
  }
97
102
  try {
98
103
  await client.collections().create(schema());
104
+ return true;
99
105
  }
100
106
  catch (error) {
101
107
  if (httpStatus(error) !== 409) {
102
108
  throw error;
103
109
  }
110
+ // A concurrent caller won the race; the collection exists, but not by us.
111
+ return false;
104
112
  }
105
113
  }
106
114
  /** Take over the lock if its holder has not refreshed it within `ttlMs`. */
@@ -113,7 +121,7 @@ async function reclaimIfStale(client, name, ttlMs) {
113
121
  .retrieve());
114
122
  }
115
123
  catch (error) {
116
- // Released between our create and this read — leave it for the next try.
124
+ // Released between our create and this read – leave it for the next try.
117
125
  if (httpStatus(error) === 404) {
118
126
  return false;
119
127
  }
@@ -1,11 +1,35 @@
1
1
  import type { SearchParams } from 'typesense/lib/Typesense/Documents.js';
2
- import { type Filter, type SearchQuery, type SearchType } from '@lde/search';
2
+ import { type Filter, type RootType, type SearchQuery, type SearchType } from '@lde/search';
3
+ /** One hop of a join path, resolved: the Root Type it reaches and the
4
+ * Typesense collection that type is stored in. */
5
+ export interface JoinTarget {
6
+ readonly searchType: RootType;
7
+ readonly collection: string;
8
+ }
3
9
  /**
4
10
  * Options for {@link buildSearchParams} – the query half of the engine
5
11
  * adapter. {@link TypesenseSearchEngineOptions} extends this, so each knob is
6
12
  * declared once and the engine forwards its options wholesale.
7
13
  */
8
14
  export interface BuildSearchParamsOptions {
15
+ /**
16
+ * Resolves a criterion’s {@link CriterionBase.on} join path to the collection
17
+ * it constrains – the one thing the engine-neutral {@link JoinGraph}
18
+ * deliberately does not answer, because collection naming is engine- and
19
+ * deployment-specific.
20
+ *
21
+ * Called once per **prefix** of a path, so a two-hop
22
+ * `['dataset', 'publisher']` yields the collection of each hop and compiles
23
+ * to the nested `$datasets($publishers(…))`. The declaration comes back with
24
+ * it, because the leaf term is compiled against the *target* type’s field
25
+ * (its kind, its facet flag), not the searched one.
26
+ *
27
+ * Left unset – or returning `undefined` for a path – a joined criterion
28
+ * compiles to nothing and drops out of its clause. Through the engine it can
29
+ * never get that far: `assertValidQuery` rejects an unresolvable path up
30
+ * front.
31
+ */
32
+ readonly joinTargetFor?: (from: SearchType, path: readonly string[]) => JoinTarget | undefined;
9
33
  /**
10
34
  * Cap on the number of buckets returned per facet (`max_facet_values`). Left
11
35
  * unset, Typesense defaults to 10 – too few for high-cardinality facets
@@ -31,6 +55,11 @@ export interface BuildSearchParamsOptions {
31
55
  * so the mapping is asserted directly in unit tests. Field names come from
32
56
  * {@link physicalFields}, the same convention the projection and the collection
33
57
  * schema use, so a query can never reference a field the index does not carry.
58
+ *
59
+ * A criterion carrying a {@link CriterionBase.on} join path compiles to a
60
+ * nested `$collection(…)` clause – one per hop – which is why the compiler
61
+ * takes {@link BuildSearchParamsOptions.joinTargetFor}: the join graph names
62
+ * the type, the deployment names the collection.
34
63
  */
35
64
  export declare function buildSearchParams(query: SearchQuery, searchType: SearchType, options?: BuildSearchParamsOptions): SearchParams<object>;
36
65
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"query-compiler.d.ts","sourceRoot":"","sources":["../src/query-compiler.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,sCAAsC,CAAC;AAEzE,OAAO,EAGL,KAAK,MAAM,EAEX,KAAK,WAAW,EAChB,KAAK,UAAU,EAEhB,MAAM,aAAa,CAAC;AAarB;;;;GAIG;AACH,MAAM,WAAW,wBAAwB;IACvC;;;;;;OAMG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC;IACjC;;;;;;;;OAQG;IACH,QAAQ,CAAC,eAAe,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;CACrD;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,WAAW,EAClB,UAAU,EAAE,UAAU,EACtB,OAAO,GAAE,wBAA6B,GACrC,YAAY,CAAC,MAAM,CAAC,CAkCtB;AA4PD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAEvD"}
1
+ {"version":3,"file":"query-compiler.d.ts","sourceRoot":"","sources":["../src/query-compiler.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,sCAAsC,CAAC;AAEzE,OAAO,EAGL,KAAK,MAAM,EACX,KAAK,QAAQ,EAEb,KAAK,WAAW,EAChB,KAAK,UAAU,EAEhB,MAAM,aAAa,CAAC;AAarB;mDACmD;AACnD,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,UAAU,EAAE,QAAQ,CAAC;IAC9B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;CAC7B;AAED;;;;GAIG;AACH,MAAM,WAAW,wBAAwB;IACvC;;;;;;;;;;;;;;;;OAgBG;IACH,QAAQ,CAAC,aAAa,CAAC,EAAE,CACvB,IAAI,EAAE,UAAU,EAChB,IAAI,EAAE,SAAS,MAAM,EAAE,KACpB,UAAU,GAAG,SAAS,CAAC;IAC5B;;;;;;OAMG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC;IACjC;;;;;;;;OAQG;IACH,QAAQ,CAAC,eAAe,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;CACrD;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,WAAW,EAClB,UAAU,EAAE,UAAU,EACtB,OAAO,GAAE,wBAA6B,GACrC,YAAY,CAAC,MAAM,CAAC,CA8BtB;AAsSD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAEvD"}
@@ -6,13 +6,18 @@ import { fieldNamed, filterOperator, filterOperatorFor, ID_FIELD, isoToUnixSecon
6
6
  * so the mapping is asserted directly in unit tests. Field names come from
7
7
  * {@link physicalFields}, the same convention the projection and the collection
8
8
  * schema use, so a query can never reference a field the index does not carry.
9
+ *
10
+ * A criterion carrying a {@link CriterionBase.on} join path compiles to a
11
+ * nested `$collection(…)` clause – one per hop – which is why the compiler
12
+ * takes {@link BuildSearchParamsOptions.joinTargetFor}: the join graph names
13
+ * the type, the deployment names the collection.
9
14
  */
10
15
  export function buildSearchParams(query, searchType, options = {}) {
11
16
  const folded = query.text !== undefined && query.text.length > 0
12
17
  ? fold(query.text)
13
18
  : undefined;
14
19
  const { names, weights } = queryFields(searchType, query.locale);
15
- const filterBy = compileFilterBy(query.where, searchType, options.onIgnoredFilter);
20
+ const filterBy = compileFilterBy(query.where, searchType, options);
16
21
  const sortBy = query.orderBy
17
22
  .map((sort) => compileSort(sort, searchType, query.locale))
18
23
  .join(',');
@@ -97,12 +102,12 @@ function queryFields(searchType, locale) {
97
102
  }
98
103
  /** AND-join the compiled `where` clauses; a clause that compiles to nothing is
99
104
  * skipped and reported to `onIgnoredFilter`. */
100
- function compileFilterBy(where, searchType, onIgnoredFilter) {
105
+ function compileFilterBy(where, searchType, options) {
101
106
  return where
102
107
  .map((filter) => {
103
- const clause = compileFilter(filter, searchType);
108
+ const clause = compileFilter(filter, searchType, options);
104
109
  if (clause === undefined) {
105
- onIgnoredFilter?.(filter);
110
+ options.onIgnoredFilter?.(filter);
106
111
  }
107
112
  return clause;
108
113
  })
@@ -132,10 +137,10 @@ function compileFilterBy(where, searchType, onIgnoredFilter) {
132
137
  * A clause left with no terms either way is skipped and reported to
133
138
  * `onIgnoredFilter`.
134
139
  */
135
- function compileFilter(filter, searchType) {
140
+ function compileFilter(filter, searchType, options) {
136
141
  const terms = [];
137
142
  for (const criterion of filter.or) {
138
- const outcome = compileCriterion(criterion, searchType);
143
+ const outcome = compileCriterion(criterion, searchType, options);
139
144
  if (outcome === VACUOUS) {
140
145
  return undefined;
141
146
  }
@@ -158,7 +163,40 @@ const UNUSABLE = Symbol('unusable');
158
163
  * and cannot be recovered from a bare `undefined`, so it is returned rather than
159
164
  * re-derived by a second pass over the same rules.
160
165
  */
161
- function compileCriterion(criterion, searchType) {
166
+ function compileCriterion(criterion, searchType, options) {
167
+ const on = criterion.on ?? [];
168
+ if (on.length === 0) {
169
+ return compileLeaf(criterion, searchType);
170
+ }
171
+ // A joined criterion constrains a document in ANOTHER collection, so the leaf
172
+ // is compiled against the type that path reaches and then wrapped, one
173
+ // `$collection(…)` per hop, outermost hop first:
174
+ // `['dataset', 'publisher']` → `$datasets($publishers(id:=…))`.
175
+ const collections = [];
176
+ let target = searchType;
177
+ for (let hop = 1; hop <= on.length; hop++) {
178
+ const resolved = options.joinTargetFor?.(searchType, on.slice(0, hop));
179
+ if (resolved === undefined) {
180
+ // An unresolvable path is a criterion that cannot be compiled at all –
181
+ // false, so it drops out and its siblings still stand. Through the
182
+ // engine, `assertValidQuery` has already rejected it.
183
+ return UNUSABLE;
184
+ }
185
+ collections.push(resolved.collection);
186
+ target = resolved.searchType;
187
+ }
188
+ const leaf = compileLeaf(criterion, target);
189
+ // A vacuous leaf states no constraint on the referent, so the join as a whole
190
+ // states none either – the reading, and so the outcome, passes straight
191
+ // through the hops.
192
+ if (leaf === VACUOUS || leaf === UNUSABLE) {
193
+ return leaf;
194
+ }
195
+ return collections.reduceRight((inner, collection) => `$${collection}(${inner})`, leaf);
196
+ }
197
+ /** The criterion’s own term, against the type it actually constrains – the
198
+ * searched type, or the one its join path reaches. */
199
+ function compileLeaf(criterion, searchType) {
162
200
  // `id` is the Typesense document key, not a declared field. Exact `:=`
163
201
  // membership, like a non-facet field ({@link compileMembership}), so an IRI
164
202
  // cannot partial-match on a shared path segment. (`fetchLabels` resolves
@@ -16,25 +16,27 @@ export interface RebuildOptions extends CollectionDefinitionOptions {
16
16
  export interface ResolvedRebuildOptions {
17
17
  readonly batchSize: number;
18
18
  readonly lockTtlMs: number;
19
- /** The collection-definition options with the `name` resolved – the one place
20
- * the resolved name lives, so the collection a writer talks to and the
21
- * definition it creates cannot say different things. */
19
+ /** The collection-definition options with `collectionNameFor` resolved to a
20
+ * total function – the one place the naming convention lives, so the
21
+ * collection a writer talks to, the definition it creates and the peer
22
+ * collections its references name cannot say different things. */
22
23
  readonly definitionOptions: CollectionDefinitionOptions & {
23
- readonly name: string;
24
+ readonly collectionNameFor: (searchType: SearchType) => string;
24
25
  };
25
26
  }
26
27
  /**
27
28
  * Apply the shared defaults, once, so neither writer restates them – including
28
- * the collection name, derived from the type ({@link deriveCollectionName})
29
+ * the collection naming, derived from each type ({@link deriveCollectionName})
29
30
  * when the deployment supplies none.
30
31
  *
31
32
  * Writers resolve at construction rather than per run, so an underivable name
32
33
  * throws when the writer is built, not on the first rebuild. The collection
33
34
  * definition is built here and discarded for the same reason: every way a
34
35
  * declaration can fail to describe a collection – an unresolvable surfaced
35
- * inline reference, whose nesting needs the schema – then fails at construction
36
- * rather than inside the first run, after a lock is held and a whole extraction
37
- * has been paid for.
36
+ * inline reference, whose nesting needs the schema, or a joinable reference
37
+ * whose target it resolves the same way – then fails at construction rather
38
+ * than inside the first run, after a lock is held and a whole extraction has
39
+ * been paid for.
38
40
  */
39
41
  export declare function resolveRebuildOptions(searchType: SearchType, options: RebuildOptions): ResolvedRebuildOptions;
40
42
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"rebuild-support.d.ts","sourceRoot":"","sources":["../src/rebuild-support.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AACxC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAE9C,OAAO,EAEL,KAAK,2BAA2B,EACjC,MAAM,4BAA4B,CAAC;AAKpC;;;GAGG;AACH,MAAM,WAAW,cAAe,SAAQ,2BAA2B;IACjE,+DAA+D;IAC/D,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,0EAA0E;IAC1E,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;CAC7B;AAED;0FAC0F;AAC1F,MAAM,WAAW,sBAAsB;IACrC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B;;6DAEyD;IACzD,QAAQ,CAAC,iBAAiB,EAAE,2BAA2B,GAAG;QACxD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;KACvB,CAAC;CACH;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,qBAAqB,CACnC,UAAU,EAAE,UAAU,EACtB,OAAO,EAAE,cAAc,GACtB,sBAAsB,CAgBxB;AAED;;;;GAIG;AACH,wBAAgB,sBAAsB,CACpC,UAAU,EAAE,UAAU,EACtB,QAAQ,EAAE,SAAS,MAAM,EAAE,GAC1B,IAAI,CAWN;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,8BAA8B,CAC5C,UAAU,EAAE,UAAU,EACtB,OAAO,EAAE;IAAE,QAAQ,CAAC,gBAAgB,EAAE,OAAO,CAAA;CAAE,GAC9C,IAAI,CA0BN;AAED,4EAA4E;AAC5E,wBAAuB,cAAc,CAAC,SAAS,EAC7C,SAAS,EAAE,aAAa,CAAC,SAAS,CAAC,EACnC,KAAK,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,GACtC,aAAa,CAAC,SAAS,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAInD;AAED,mEAAmE;AACnE,wBAAsB,cAAc,CAClC,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,IAAI,CAAC,CAKf"}
1
+ {"version":3,"file":"rebuild-support.d.ts","sourceRoot":"","sources":["../src/rebuild-support.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AACxC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAO9C,OAAO,EAEL,KAAK,2BAA2B,EACjC,MAAM,4BAA4B,CAAC;AAKpC;;;GAGG;AACH,MAAM,WAAW,cAAe,SAAQ,2BAA2B;IACjE,+DAA+D;IAC/D,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,0EAA0E;IAC1E,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;CAC7B;AAED;0FAC0F;AAC1F,MAAM,WAAW,sBAAsB;IACrC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B;;;uEAGmE;IACnE,QAAQ,CAAC,iBAAiB,EAAE,2BAA2B,GAAG;QACxD,QAAQ,CAAC,iBAAiB,EAAE,CAAC,UAAU,EAAE,UAAU,KAAK,MAAM,CAAC;KAChE,CAAC;CACH;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,qBAAqB,CACnC,UAAU,EAAE,UAAU,EACtB,OAAO,EAAE,cAAc,GACtB,sBAAsB,CAkBxB;AAsCD;;;;GAIG;AACH,wBAAgB,sBAAsB,CACpC,UAAU,EAAE,UAAU,EACtB,QAAQ,EAAE,SAAS,MAAM,EAAE,GAC1B,IAAI,CAWN;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,8BAA8B,CAC5C,UAAU,EAAE,UAAU,EACtB,OAAO,EAAE;IAAE,QAAQ,CAAC,gBAAgB,EAAE,OAAO,CAAA;CAAE,GAC9C,IAAI,CA0BN;AAED,4EAA4E;AAC5E,wBAAuB,cAAc,CAAC,SAAS,EAC7C,SAAS,EAAE,aAAa,CAAC,SAAS,CAAC,EACnC,KAAK,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,GACtC,aAAa,CAAC,SAAS,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAInD;AAED,mEAAmE;AACnE,wBAAsB,cAAc,CAClC,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,IAAI,CAAC,CAKf"}
@@ -1,20 +1,21 @@
1
- import { datasetField, isInternalField } from '@lde/search/adapter';
1
+ import { datasetField, isInternalField, joinGraph, referenceFields, } from '@lde/search/adapter';
2
2
  import { buildCollectionDefinition, } from './collection-definition.js';
3
3
  import { deriveCollectionName } from './collection-name.js';
4
4
  import { DEFAULT_LOCK_TTL_MS } from './lock.js';
5
5
  import { DEFAULT_BATCH_SIZE } from './import.js';
6
6
  /**
7
7
  * Apply the shared defaults, once, so neither writer restates them – including
8
- * the collection name, derived from the type ({@link deriveCollectionName})
8
+ * the collection naming, derived from each type ({@link deriveCollectionName})
9
9
  * when the deployment supplies none.
10
10
  *
11
11
  * Writers resolve at construction rather than per run, so an underivable name
12
12
  * throws when the writer is built, not on the first rebuild. The collection
13
13
  * definition is built here and discarded for the same reason: every way a
14
14
  * declaration can fail to describe a collection – an unresolvable surfaced
15
- * inline reference, whose nesting needs the schema – then fails at construction
16
- * rather than inside the first run, after a lock is held and a whole extraction
17
- * has been paid for.
15
+ * inline reference, whose nesting needs the schema, or a joinable reference
16
+ * whose target it resolves the same way – then fails at construction rather
17
+ * than inside the first run, after a lock is held and a whole extraction has
18
+ * been paid for.
18
19
  */
19
20
  export function resolveRebuildOptions(searchType, options) {
20
21
  const { batchSize = DEFAULT_BATCH_SIZE, lockTtlMs = DEFAULT_LOCK_TTL_MS, ...definitionOptions } = options;
@@ -23,12 +24,41 @@ export function resolveRebuildOptions(searchType, options) {
23
24
  lockTtlMs,
24
25
  definitionOptions: {
25
26
  ...definitionOptions,
26
- name: definitionOptions.name ?? deriveCollectionName(searchType),
27
+ collectionNameFor: definitionOptions.collectionNameFor ?? deriveCollectionName,
27
28
  },
28
29
  };
30
+ assertDistinctJoinTargetNames(searchType, resolved.definitionOptions);
29
31
  buildCollectionDefinition(searchType, resolved.definitionOptions);
30
32
  return resolved;
31
33
  }
34
+ /**
35
+ * Reject a `collectionNameFor` that gives a join target the same name as the
36
+ * type doing the joining.
37
+ *
38
+ * The trap is the obvious migration from the `name` option this replaced:
39
+ * `name: 'staging_works'` reads as `collectionNameFor: () => 'staging_works'`,
40
+ * a constant function – which now also names every peer, so the emitted
41
+ * reference points the collection at ITSELF. Nothing downstream complains:
42
+ * Typesense accepts the self-reference and every join through it then answers
43
+ * nothing.
44
+ *
45
+ * Only the (type, target) pairs this type actually joins to are checked, so a
46
+ * deployment deliberately sharing one collection between unrelated types – the
47
+ * several-label-sources-in-one-`labels` case – stays allowed.
48
+ */
49
+ function assertDistinctJoinTargetNames(searchType, options) {
50
+ const { schema, collectionNameFor } = options;
51
+ if (schema === undefined) {
52
+ return;
53
+ }
54
+ const own = collectionNameFor(searchType);
55
+ for (const field of referenceFields(searchType)) {
56
+ const target = joinGraph(schema).resolve(searchType, [field.name]);
57
+ if (target !== undefined && collectionNameFor(target) === own) {
58
+ throw new Error(`The collection naming for “${searchType.name}” gives its join target “${target.name}” the same collection “${own}”, so the reference on “${field.name}” would point the collection at itself and every join through it would answer nothing. A constant “collectionNameFor” does this – derive the name from the type it is given (e.g. \`(type) => \`prefix_\${deriveCollectionName(type)}\`\`).`);
59
+ }
60
+ }
61
+ }
32
62
  /**
33
63
  * Reject a {@link SearchType} that declares any of the bookkeeping field names
34
64
  * a writer stamps, so a domain field can never collide with `source` /
package/dist/search.d.ts CHANGED
@@ -6,7 +6,7 @@ import { type BuildSearchParamsOptions } from './query-compiler.js';
6
6
  * wholesale into each search. Reference labels resolve per field from the
7
7
  * collection of the SearchType its `labelSource` names – a label source is
8
8
  * just another type, so it is named the same way. */
9
- export interface TypesenseSearchEngineOptions<TypeName extends string = string> extends BuildSearchParamsOptions {
9
+ export interface TypesenseSearchEngineOptions<TypeName extends string = string> extends Omit<BuildSearchParamsOptions, 'joinTargetFor'> {
10
10
  /**
11
11
  * Overrides the collection (or alias) a search type reads, keyed by the
12
12
  * type’s `name`. Every type not named here reads the collection derived from
@@ -1 +1 @@
1
- {"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../src/search.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../src/search.ts"],"names":[],"mappings":"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"}
package/dist/search.js CHANGED
@@ -1,4 +1,4 @@
1
- import { assertTypeInSchema, assertValidQuery, displayLangOf, fieldNamed, isInlineReference, isRangeFacet, isUnsatisfiable, labelFieldOf, nestedReferenceType, outputFields, physicalFields, referenceFields, } from '@lde/search/adapter';
1
+ import { assertTypeInSchema, assertValidQuery, displayLangOf, fieldNamed, isInlineReference, isRangeFacet, isUnsatisfiable, joinGraph, labelFieldOf, nestedReferenceType, outputFields, physicalFields, referenceFields, } from '@lde/search/adapter';
2
2
  import { buildSearchParams, escapeFilterValue, } from './query-compiler.js';
3
3
  import { deriveCollectionName } from './collection-name.js';
4
4
  /**
@@ -39,6 +39,25 @@ export function createTypesenseSearchEngine(client, schema, options = {}) {
39
39
  searchType.class,
40
40
  collection,
41
41
  ]));
42
+ // The schema's join graph, built once (it is cached per schema anyway), plus
43
+ // the collection half the graph deliberately leaves to the adapter. Together
44
+ // they are what lets a `where` criterion carry an `on` path: validation
45
+ // resolves the path to a type, the compiler turns each hop into
46
+ // `$collection(…)`.
47
+ const joins = joinGraph(schema);
48
+ const searchParamsOptions = {
49
+ ...options,
50
+ joinTargetFor: (from, path) => {
51
+ // Both lookups always hit: `assertValidQuery` runs first and rejects
52
+ // every path this could not resolve, and each type resolved a collection
53
+ // above.
54
+ const target = joins.resolve(from, path);
55
+ return {
56
+ searchType: target,
57
+ collection: collections.get(target.class),
58
+ };
59
+ },
60
+ };
42
61
  // Resolve every reference field's label source ONCE at construction. The
43
62
  // schema already guarantees the named type exists and serves labels
44
63
  // (`searchSchema`/`labelFieldOf`), and every type resolved a collection
@@ -175,14 +194,14 @@ export function createTypesenseSearchEngine(client, schema, options = {}) {
175
194
  // invalid query (unknown field, wrong operator, unknown facet) are both
176
195
  // rejected up front, for EVERY caller.
177
196
  assertTypeInSchema(schema, searchType);
178
- assertValidQuery(query, searchType);
197
+ assertValidQuery(query, searchType, joins);
179
198
  // Asks for no document (an empty `id` membership): answer it without a
180
199
  // round-trip rather than dispatching a query whose only filter compiles
181
200
  // away, which would hand back the whole collection.
182
201
  if (isUnsatisfiable(query)) {
183
202
  return { total: 0, hits: [], facets: {} };
184
203
  }
185
- const params = buildSearchParams(query, searchType, options);
204
+ const params = buildSearchParams(query, searchType, searchParamsOptions);
186
205
  const cachedLabelsPromise = startCachedLabels(searchType);
187
206
  // ONE search, but through `multi_search` (POST) like the facet batch and
188
207
  // the label lookup: `documents().search()` is a GET, so a long
@@ -197,7 +216,7 @@ export function createTypesenseSearchEngine(client, schema, options = {}) {
197
216
  async searchFacets(searchType, queries) {
198
217
  assertTypeInSchema(schema, searchType);
199
218
  for (const query of queries) {
200
- assertValidQuery(query, searchType);
219
+ assertValidQuery(query, searchType, joins);
201
220
  }
202
221
  if (queries.length === 0) {
203
222
  return [];
@@ -222,7 +241,7 @@ export function createTypesenseSearchEngine(client, schema, options = {}) {
222
241
  const { results } = (await client.multiSearch.perform({
223
242
  searches: dispatched.map(({ query }) => ({
224
243
  collection,
225
- ...buildSearchParams({ ...query, orderBy: [], limit: 0, offset: 0 }, searchType, options),
244
+ ...buildSearchParams({ ...query, orderBy: [], limit: 0, offset: 0 }, searchType, searchParamsOptions),
226
245
  })),
227
246
  }));
228
247
  // One label lookup serves every successful facet result in the batch.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lde/search-typesense",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "Typesense implementation of the @lde/search SearchEngine port: collection-schema builder, query compiler, label-resolving result reconstruction, and blue/green index lifecycle. Engine-specific (Typesense) but domain-agnostic.",
5
5
  "repository": {
6
6
  "url": "git+https://github.com/ldelements/lde.git",
@@ -25,7 +25,7 @@
25
25
  "!**/*.tsbuildinfo"
26
26
  ],
27
27
  "dependencies": {
28
- "@lde/search": "^0.19.0",
28
+ "@lde/search": "^0.20.0",
29
29
  "@lde/text-normalization": "^0.1.2",
30
30
  "tslib": "^2.3.0",
31
31
  "typesense": "^3.0.6"
@@ -35,6 +35,6 @@
35
35
  },
36
36
  "peerDependencies": {
37
37
  "@lde/dataset": "^0.7.9",
38
- "@lde/pipeline": "^0.35.6"
38
+ "@lde/pipeline": "^0.36.0"
39
39
  }
40
40
  }