@lde/search-typesense 0.27.2 → 0.27.4

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.
@@ -21,11 +21,19 @@ export interface CollectionDefinitionOptions {
21
21
  /**
22
22
  * The Search Schema the type belongs to – required when the type surfaces an
23
23
  * inline reference (whose nested fields are declared from the
24
- * {@link ReferenceType} the schema resolves) or declares a joinable
24
+ * {@link ReferenceType} the schema resolves), declares a joinable
25
25
  * reference (whose target collection the schema’s {@link joinGraph}
26
26
  * resolves). A type doing neither needs no schema, so a caller passes none;
27
27
  * a type that does fails here rather than building a collection that
28
28
  * silently omits the nesting or the reference.
29
+ *
30
+ * Also what resolves the facet policy a facetable reference inherits from
31
+ * the type it names ({@link physicalFields}): without the schema the field
32
+ * itself is declared the facet and no `${name}_facet` companion is, the same
33
+ * reading the projection makes without one. Whether a policy applies cannot
34
+ * be told without the schema, so this is not guarded: a deployment that
35
+ * declares one and builds its collections schema-less meets the engine’s own
36
+ * error on the first facet query, not a silently narrowed-to-nothing facet.
29
37
  */
30
38
  readonly schema?: SearchSchema;
31
39
  /** Snowball stemming locale for non-localized searchable fields (e.g. `en`).
@@ -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;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
+ {"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;;;;;;;;;;;;;;;;OAgBG;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"}
@@ -138,7 +138,7 @@ function typesenseFields(searchType, field, defaultLocale, defaultSortingField,
138
138
  if (nested !== undefined) {
139
139
  return nestedFields(field.name, field, nested, schema);
140
140
  }
141
- const names = physicalFields(field);
141
+ const names = physicalFields(field, schema);
142
142
  if (field.kind === 'text') {
143
143
  const locales = field.locales;
144
144
  const displayPattern = displayFieldPattern(field);
@@ -184,7 +184,10 @@ function typesenseFields(searchType, field, defaultLocale, defaultSortingField,
184
184
  {
185
185
  name: field.name,
186
186
  type: valueType,
187
- facet: field.facetable ?? false,
187
+ // A reference inheriting a facet policy facets its companion below, and
188
+ // the field itself stays a plain stored value – which is also what keeps
189
+ // a membership filter on it exact (`compileMembership`).
190
+ facet: names.facet === field.name,
188
191
  sort: field.sortable ?? false,
189
192
  // A `required` field is non-optional; so is the `default_sorting_field`,
190
193
  // which Typesense requires to be present. Everything else may be absent.
@@ -205,6 +208,18 @@ function typesenseFields(searchType, field, defaultLocale, defaultSortingField,
205
208
  }),
206
209
  });
207
210
  }
211
+ // The `${name}_facet` companion of a reference inheriting a facet policy:
212
+ // the admitted subset of the field’s values, and the only one of the two
213
+ // the engine facets. Optional, because a document none of whose values the
214
+ // policy admits carries no companion at all.
215
+ if (names.facet !== undefined && names.facet !== field.name) {
216
+ fields.push({
217
+ name: names.facet,
218
+ type: valueType,
219
+ facet: true,
220
+ optional: true,
221
+ });
222
+ }
208
223
  return fields;
209
224
  }
210
225
  /**
@@ -35,9 +35,10 @@ export interface InPlaceRebuildOptions extends RebuildOptions {
35
35
  * `RebuildAlreadyRunning` when another rebuild holds it) and creates
36
36
  * the collection on demand from the {@link SearchType} plus the two
37
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
+ * one exception: it must carry every reference field the declaration asks for
39
+ * and every facet companion its facets read, or the run fails with the
40
+ * drop-and-rebuild that fixes it
41
+ * ({@link assertReferencesAndFacetCompanionsPresent}).
41
42
  *
42
43
  * Document ids must be unique per (source, entity) – the caller keys them –
43
44
  * 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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;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"}
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,EAAgB,UAAU,EAAE,MAAM,aAAa,CAAC;AAG5D,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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;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;IAkB/B,OAAO,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,SAAS,CAAC,SAAS,CAAC,CAAC;IA6HjE;;;;;;OAMG;YACW,cAAc;CAsB7B"}
@@ -1,3 +1,4 @@
1
+ import { facetableFields, physicalFields } from '@lde/search/adapter';
1
2
  import { buildCollectionDefinition } from './collection-definition.js';
2
3
  import { BatchImporter } from './import.js';
3
4
  import { ensureCollectionExists, openLockedRun, releaseLock } from './lock.js';
@@ -39,9 +40,10 @@ const DEFAULT_MAX_SWEEPABLE_SOURCES = 10_000;
39
40
  * `RebuildAlreadyRunning` when another rebuild holds it) and creates
40
41
  * the collection on demand from the {@link SearchType} plus the two
41
42
  * 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}).
43
+ * one exception: it must carry every reference field the declaration asks for
44
+ * and every facet companion its facets read, or the run fails with the
45
+ * drop-and-rebuild that fixes it
46
+ * ({@link assertReferencesAndFacetCompanionsPresent}).
45
47
  *
46
48
  * Document ids must be unique per (source, entity) – the caller keys them –
47
49
  * or documents from different sources overwrite each other.
@@ -70,7 +72,10 @@ export class InPlaceRebuild {
70
72
  this.client = client;
71
73
  this.searchType = searchType;
72
74
  assertNoReservedFields(searchType, [SOURCE_FIELD, LAST_SEEN_FIELD]);
73
- assertSweepableProvenanceField(searchType, { requireFacetable: true });
75
+ assertSweepableProvenanceField(searchType, {
76
+ requireFacetable: true,
77
+ schema: options.schema,
78
+ });
74
79
  this.sourceField = provenanceField(searchType);
75
80
  const { maxSweepableSources = DEFAULT_MAX_SWEEPABLE_SOURCES, ...rebuildOptions } = options;
76
81
  this.maxSweepableSources = maxSweepableSources;
@@ -104,9 +109,10 @@ export class InPlaceRebuild {
104
109
  };
105
110
  });
106
111
  // A collection this run did NOT create may predate a `joinable` being
107
- // declared, and a reference cannot be added to it here.
112
+ // declared, or a facet policy on a type this one references – and
113
+ // neither a reference nor a facet companion can be added to it here.
108
114
  if (!created) {
109
- await assertReferencesPresent(this.client, name, declaredFields);
115
+ await assertReferencesAndFacetCompanionsPresent(this.client, name, declaredFields, facetCompanionsOf(this.searchType, definitionOptions.schema));
110
116
  }
111
117
  const importer = new BatchImporter(this.client, name, batchSize);
112
118
  return {
@@ -199,23 +205,48 @@ export class InPlaceRebuild {
199
205
  * component’s collections come into existence WITH their references, and never
200
206
  * acquire them later.
201
207
  *
202
- * Deliberately scoped to reference fields only. Every other schema difference
203
- * is self-correcting (a new plain field simply starts being written) or
208
+ * The facet companion of a reference inheriting a facet policy
209
+ * (`RootType.facetKeys` on the type it names) is the other field a live
210
+ * collection cannot acquire: the documents carry `${name}_facet`, the
211
+ * collection does not declare it, and every facet on the field fails with
212
+ * *could not find a facet field* – on every type referencing the policy’s
213
+ * type, until someone drops the collection by hand. Rotating a pipeline
214
+ * version reprocesses datasets; it does not recreate collections. So it is
215
+ * checked here too, with the same instruction.
216
+ *
217
+ * Deliberately scoped to those two. Every other schema difference is
218
+ * self-correcting (a new plain field simply starts being written) or
204
219
  * harmless, and general drift detection is a feature of its own.
205
220
  */
206
- async function assertReferencesPresent(client, name, fields) {
207
- const declared = fields.filter((field) => field.reference !== undefined);
208
- if (declared.length === 0) {
221
+ async function assertReferencesAndFacetCompanionsPresent(client, name, fields, facetCompanions) {
222
+ const references = fields.filter((field) => field.reference !== undefined);
223
+ if (references.length === 0 && facetCompanions.length === 0) {
209
224
  return;
210
225
  }
211
226
  const existing = new Map((await client.collections(name).retrieve()).fields.map((field) => [
212
227
  field.name,
213
- field.reference,
228
+ field,
214
229
  ]));
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
230
+ const missingReferences = references.filter((field) => existing.get(field.name)?.reference !== field.reference);
231
+ if (missingReferences.length > 0) {
232
+ throw new Error(`Typesense collection “${name}” exists without the reference field(s) ${missingReferences
218
233
  .map((field) => `“${field.name}” → “${field.reference}”`)
219
234
  .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
235
  }
236
+ const missingCompanions = facetCompanions.filter((companion) => existing.get(companion)?.facet !== true);
237
+ if (missingCompanions.length > 0) {
238
+ throw new Error(`Typesense collection “${name}” exists without the facet field(s) ${missingCompanions
239
+ .map((companion) => `“${companion}”`)
240
+ .join(', ')} its search type facets on – the companion of a reference to a type that declares a facet policy (“facetKeys”). A facet field cannot be added to a live collection here: drop “${name}” (and the collections that join to it) and let the next run rebuild them.`);
241
+ }
242
+ }
243
+ /** The `${name}_facet` companions a type’s facets read instead of the fields
244
+ * themselves – one per facetable reference inheriting a facet policy
245
+ * ({@link physicalFields}). */
246
+ function facetCompanionsOf(searchType, schema) {
247
+ return facetableFields(searchType).flatMap((field) => {
248
+ // A facetable field always has a facet field.
249
+ const facet = physicalFields(field, schema).facet;
250
+ return facet === field.name ? [] : [facet];
251
+ });
221
252
  }
@@ -1,5 +1,5 @@
1
1
  import type { SearchParams } from 'typesense/lib/Typesense/Documents.js';
2
- import { type Filter, type RootType, type SearchQuery, type SearchType } from '@lde/search';
2
+ import { type Filter, type RootType, type SearchQuery, type SearchSchema, type SearchType } from '@lde/search';
3
3
  /** One hop of a join path, resolved: the Root Type it reaches and the
4
4
  * Typesense collection that type is stored in. */
5
5
  export interface JoinTarget {
@@ -38,6 +38,16 @@ export interface BuildSearchParamsOptions {
38
38
  * range count is still safe.
39
39
  */
40
40
  readonly maxFacetValues?: number;
41
+ /**
42
+ * The Search Schema the searched type belongs to – what resolves the facet
43
+ * policy a reference inherits from the type it names
44
+ * (`physicalFields(field, schema).facet`): the `${name}_facet` companion a
45
+ * facet on such a field reads, and the membership operator a filter on it
46
+ * compiles to. Left unset, a facet reads the field itself – which, against a
47
+ * collection built with the schema, is a field the engine does not facet.
48
+ * Through the engine it is always set.
49
+ */
50
+ readonly schema?: SearchSchema;
41
51
  /**
42
52
  * Called for each `where` clause that does not compile as the caller wrote
43
53
  * it – either because it states no constraint (an empty `in` list, a `range`
@@ -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,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;;;;;;;;;;;OAWG;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;AA+UD;;;;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,YAAY,EACjB,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,MAAM,CAAC,EAAE,YAAY,CAAC;IAC/B;;;;;;;;;;;OAWG;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;AA6VD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAEvD"}
@@ -35,7 +35,7 @@ export function buildSearchParams(query, searchType, options = {}) {
35
35
  params.sort_by = sortBy;
36
36
  }
37
37
  if (query.facets.length > 0) {
38
- params.facet_by = compileFacetBy(query.facets, searchType);
38
+ params.facet_by = compileFacetBy(query.facets, searchType, options.schema);
39
39
  if (options.maxFacetValues !== undefined) {
40
40
  params.max_facet_values = options.maxFacetValues;
41
41
  }
@@ -45,17 +45,22 @@ export function buildSearchParams(query, searchType, options = {}) {
45
45
  /**
46
46
  * The `facet_by` clause. A facet on a numeric field that declares
47
47
  * {@link SearchField.facetRanges} faceted into those fixed half-open `[min, max)`
48
- * bins (a histogram); every other facet is a plain per-value facet on its field
49
- * name. Typesense range syntax is already start-inclusive/end-exclusive, so the
48
+ * bins (a histogram); every other facet is a plain per-value facet on the
49
+ * field the declaration facets ({@link physicalFields} – the field itself, or
50
+ * the `${name}_facet` companion of a reference inheriting a facet policy).
51
+ * Typesense range syntax is already start-inclusive/end-exclusive, so the
50
52
  * declared bounds pass straight through with no boundary fix-up.
51
53
  */
52
- function compileFacetBy(facets, searchType) {
54
+ function compileFacetBy(facets, searchType, schema) {
53
55
  return facets
54
56
  .map((name) => {
55
57
  const field = fieldNamed(searchType, name);
56
- return field !== undefined && isRangeFacet(field)
58
+ if (field === undefined) {
59
+ return name;
60
+ }
61
+ return isRangeFacet(field)
57
62
  ? compileRangeFacet(field.name, field.facetRanges)
58
- : name;
63
+ : (physicalFields(field, schema).facet ?? name);
59
64
  })
60
65
  .join(',');
61
66
  }
@@ -196,7 +201,7 @@ const UNUSABLE = Symbol('unusable');
196
201
  function compileCriterion(criterion, searchType, options) {
197
202
  const on = criterion.on ?? [];
198
203
  if (on.length === 0) {
199
- return compileLeaf(criterion, searchType);
204
+ return compileLeaf(criterion, searchType, options.schema);
200
205
  }
201
206
  // A joined criterion constrains a document in ANOTHER collection, so the leaf
202
207
  // is compiled against the type that path reaches and then wrapped, one
@@ -215,7 +220,7 @@ function compileCriterion(criterion, searchType, options) {
215
220
  collections.push(resolved.collection);
216
221
  target = resolved.searchType;
217
222
  }
218
- const leaf = compileLeaf(criterion, target);
223
+ const leaf = compileLeaf(criterion, target, options.schema);
219
224
  // A vacuous leaf states no constraint on the referent, so the join as a whole
220
225
  // states none either – the reading, and so the outcome, passes straight
221
226
  // through the hops.
@@ -226,7 +231,7 @@ function compileCriterion(criterion, searchType, options) {
226
231
  }
227
232
  /** The criterion’s own term, against the type it actually constrains – the
228
233
  * searched type, or the one its join path reaches. */
229
- function compileLeaf(criterion, searchType) {
234
+ function compileLeaf(criterion, searchType, schema) {
230
235
  // `id` is the Typesense document key, not a declared field. Exact `:=`
231
236
  // membership, like a non-facet field ({@link compileMembership}), so an IRI
232
237
  // cannot partial-match on a shared path segment. (`fetchLabels` resolves
@@ -249,7 +254,7 @@ function compileLeaf(criterion, searchType) {
249
254
  }
250
255
  if ('in' in criterion) {
251
256
  return criterion.in.length > 0
252
- ? compileMembership(field, criterion.in)
257
+ ? compileMembership(field, criterion.in, schema)
253
258
  : VACUOUS;
254
259
  }
255
260
  if ('range' in criterion) {
@@ -260,12 +265,18 @@ function compileLeaf(criterion, searchType) {
260
265
  /**
261
266
  * A membership clause. A non-facet (tokenized) field uses the exact `:=`
262
267
  * operator so an IRI cannot partial-match on a shared path segment.
268
+ *
269
+ * *Non-facet* is the **engine’s** facet status, not the declaration’s: a
270
+ * reference inheriting a facet policy is `facetable` in the schema while the
271
+ * engine facets its companion and stores the field itself plain – and a
272
+ * filter on such a field is exactly what the policy promises stays whole, so
273
+ * it must compile exact, not tokenized.
263
274
  */
264
- function compileMembership(field, values) {
275
+ function compileMembership(field, values, schema) {
265
276
  const list = `[${values.map(escapeFilterValue).join(',')}]`;
266
- return field.facetable !== true
267
- ? `${field.name}:=${list}`
268
- : `${field.name}:${list}`;
277
+ return physicalFields(field, schema).facet === field.name
278
+ ? `${field.name}:${list}`
279
+ : `${field.name}:=${list}`;
269
280
  }
270
281
  /** An inclusive Typesense range clause, or `undefined` when neither bound is
271
282
  * usable. Which of the two readings that is – the caller set no bounds, or the
@@ -1,5 +1,5 @@
1
1
  import type { Client } from 'typesense';
2
- import type { SearchType } from '@lde/search';
2
+ import type { SearchSchema, SearchType } from '@lde/search';
3
3
  import { type CollectionDefinitionOptions } from './collection-definition.js';
4
4
  /**
5
5
  * The tuning knobs both rebuild writers share, on top of the collection-definition
@@ -60,13 +60,19 @@ export declare function assertNoReservedFields(searchType: SearchType, reserved:
60
60
  * selection, which carries raw dataset IRIs, so a transformed value would
61
61
  * match nothing and the sweep would silently stop deleting;
62
62
  * - **`facetable`** where the writer enumerates the indexed sources by faceting
63
- * it (the In-place writer does; Blue/green only ever filters by a known IRI).
63
+ * it (the In-place writer does; Blue/green only ever filters by a known IRI)
64
+ * – and faceted **on its own name**: a reference inheriting a facet policy
65
+ * from the type it names facets a companion holding only the admitted keys
66
+ * (`physicalFields(field, schema).facet`), so enumerating the sources
67
+ * through it would miss every dataset the policy excludes. `schema` is what
68
+ * resolves that; without one no policy is visible, as nowhere else.
64
69
  *
65
70
  * Thrown at writer construction, so a declaration that cannot be swept fails
66
71
  * before a run touches the index rather than at the sweep, after the writes.
67
72
  */
68
73
  export declare function assertSweepableProvenanceField(searchType: SearchType, options: {
69
74
  readonly requireFacetable: boolean;
75
+ readonly schema?: SearchSchema;
70
76
  }): void;
71
77
  /** Stamp each document with fixed bookkeeping fields as it streams past. */
72
78
  export declare function stampDocuments<TDocument>(documents: AsyncIterable<TDocument>, stamp: Readonly<Record<string, string>>): AsyncIterable<TDocument & Record<string, string>>;
@@ -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;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
+ {"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,YAAY,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAQ5D,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;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,8BAA8B,CAC5C,UAAU,EAAE,UAAU,EACtB,OAAO,EAAE;IACP,QAAQ,CAAC,gBAAgB,EAAE,OAAO,CAAC;IACnC,QAAQ,CAAC,MAAM,CAAC,EAAE,YAAY,CAAC;CAChC,GACA,IAAI,CAiCN;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,4 +1,4 @@
1
- import { datasetField, isInternalField, joinGraph, referenceFields, } from '@lde/search/adapter';
1
+ import { datasetField, isInternalField, joinGraph, physicalFields, 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';
@@ -87,7 +87,12 @@ export function assertNoReservedFields(searchType, reserved) {
87
87
  * selection, which carries raw dataset IRIs, so a transformed value would
88
88
  * match nothing and the sweep would silently stop deleting;
89
89
  * - **`facetable`** where the writer enumerates the indexed sources by faceting
90
- * it (the In-place writer does; Blue/green only ever filters by a known IRI).
90
+ * it (the In-place writer does; Blue/green only ever filters by a known IRI)
91
+ * – and faceted **on its own name**: a reference inheriting a facet policy
92
+ * from the type it names facets a companion holding only the admitted keys
93
+ * (`physicalFields(field, schema).facet`), so enumerating the sources
94
+ * through it would miss every dataset the policy excludes. `schema` is what
95
+ * resolves that; without one no policy is visible, as nowhere else.
91
96
  *
92
97
  * Thrown at writer construction, so a declaration that cannot be swept fails
93
98
  * before a run touches the index rather than at the sweep, after the writes.
@@ -107,6 +112,10 @@ export function assertSweepableProvenanceField(searchType, options) {
107
112
  if (options.requireFacetable && field.facetable !== true) {
108
113
  problems.push('it is not facetable, and the membership sweep enumerates the indexed datasets by faceting it');
109
114
  }
115
+ else if (options.requireFacetable &&
116
+ physicalFields(field, options.schema).facet !== field.name) {
117
+ problems.push('it inherits a facet policy from the type it names, so the engine facets only the datasets the policy admits, and the membership sweep enumerates the indexed datasets by faceting it');
118
+ }
110
119
  if (problems.length > 0) {
111
120
  throw new Error(`SearchType “${searchType.name}” declares “${field.name}” over the indexed dataset, which this writer keeps its provenance bookkeeping on, but ${problems.join('; and ')}.`);
112
121
  }
package/dist/search.d.ts CHANGED
@@ -108,6 +108,9 @@ export interface TypesenseSearchResponse {
108
108
  readonly document: Record<string, unknown>;
109
109
  }[];
110
110
  readonly facet_counts?: readonly {
111
+ /** The field faceted – by the time a response reaches
112
+ * {@link parseSearchResponse}, the **declared** field name: the engine
113
+ * maps a facet companion’s name back at the response boundary. */
111
114
  readonly field_name: string;
112
115
  readonly counts: readonly {
113
116
  readonly value: string;
@@ -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, joinGraph, labelFieldOf, labelSourceNameOf, nestedReferenceType, outputFields, physicalFields, referenceFields, } from '@lde/search/adapter';
1
+ import { assertTypeInSchema, assertValidQuery, displayLangOf, facetableFields, fieldNamed, isInlineReference, isRangeFacet, isUnsatisfiable, joinGraph, labelFieldOf, labelSourceNameOf, 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
  import { resolveProjection } from './lookup.js';
@@ -48,6 +48,7 @@ export function createTypesenseSearchEngine(client, schema, options = {}) {
48
48
  const joins = joinGraph(schema);
49
49
  const searchParamsOptions = {
50
50
  ...options,
51
+ schema,
51
52
  joinTargetFor: (from, path) => {
52
53
  // Both lookups always hit: `assertValidQuery` runs first and rejects
53
54
  // every path this could not resolve, and each type resolved a collection
@@ -81,6 +82,22 @@ export function createTypesenseSearchEngine(client, schema, options = {}) {
81
82
  ];
82
83
  })),
83
84
  ]));
85
+ // Typesense reports a facet under the field it faceted, which for a reference
86
+ // inheriting a facet policy is the `${name}_facet` companion, not the
87
+ // declared field. Resolved back to the declared name per type ONCE, from the
88
+ // same `physicalFields` that named it, and applied to every response at the
89
+ // boundary – before `labelLookupGroups` (which finds a facet’s label source
90
+ // by declared name) and `parseSearchResponse` (which files its counts under
91
+ // it). Either consumer meeting the engine name would silently lose the
92
+ // facet’s labels, or its counts, or both.
93
+ const declaredFacetNames = new Map([...schema.values()].map((searchType) => [
94
+ searchType.class,
95
+ new Map(facetableFields(searchType).map((field) => [
96
+ // A facetable field always has a facet field.
97
+ physicalFields(field, schema).facet,
98
+ field.name,
99
+ ])),
100
+ ]));
84
101
  // The distinct source collections per type, for the cached path – fixed at
85
102
  // construction, so no per-search dedup dance.
86
103
  const distinctLabelSources = new Map([...labelSources].map(([type, sources]) => [
@@ -210,7 +227,7 @@ export function createTypesenseSearchEngine(client, schema, options = {}) {
210
227
  // would overflow Typesense's 4000-char query-string limit. POST puts it
211
228
  // in the body, so the filter is bounded by the caller's request, not by
212
229
  // the URL.
213
- const response = await performSingleSearch(client, collections.get(searchType.class), params);
230
+ const response = withDeclaredFacetNames(await performSingleSearch(client, collections.get(searchType.class), params), declaredFacetNames.get(searchType.class));
214
231
  const labels = await resolveLabels(cachedLabelsPromise, () => labelLookupGroups([response], labelSources.get(searchType.class), outputReferenceSources.get(searchType.class) ?? []));
215
232
  // What the caller asked to resolve, level by level. Independent of the
216
233
  // label lookup above: that one serves facet buckets and unprojected
@@ -243,12 +260,16 @@ export function createTypesenseSearchEngine(client, schema, options = {}) {
243
260
  // compiles as facet-only regardless of what it carries: no hits
244
261
  // (per_page 0) and no ordering – nothing is transferred or sorted that
245
262
  // this method cannot return.
246
- const { results } = (await client.multiSearch.perform({
263
+ const { results: rawResults } = (await client.multiSearch.perform({
247
264
  searches: dispatched.map(({ query }) => ({
248
265
  collection,
249
266
  ...buildSearchParams({ ...query, orderBy: [], limit: 0, offset: 0 }, searchType, searchParamsOptions),
250
267
  })),
251
268
  }));
269
+ const declaredNames = declaredFacetNames.get(searchType.class);
270
+ const results = rawResults.map((result) => 'error' in result
271
+ ? result
272
+ : withDeclaredFacetNames(result, declaredNames));
252
273
  // One label lookup serves every successful facet result in the batch.
253
274
  const responses = results.filter((result) => !('error' in result));
254
275
  const labels = await resolveLabels(cachedLabelsPromise, () => labelLookupGroups(responses, labelSources.get(searchType.class), outputReferenceSources.get(searchType.class) ?? []));
@@ -384,6 +405,25 @@ function labelLookupGroups(responses, sources, outputSources) {
384
405
  iris: [...iris],
385
406
  }));
386
407
  }
408
+ /**
409
+ * The response with each facet filed under the declared field name its engine
410
+ * facet field stands for – the one place an engine facet name is translated,
411
+ * so that nothing downstream of it meets one.
412
+ */
413
+ function withDeclaredFacetNames(response, declaredNames) {
414
+ if (response.facet_counts === undefined) {
415
+ return response;
416
+ }
417
+ return {
418
+ ...response,
419
+ facet_counts: response.facet_counts.map((facet) => ({
420
+ ...facet,
421
+ // Every facet the engine reports was asked for by a declared, facetable
422
+ // field (`assertValidQuery`), and the map holds each of those.
423
+ field_name: declaredNames.get(facet.field_name),
424
+ })),
425
+ };
426
+ }
387
427
  /**
388
428
  * Dispatch one search as a single-entry `multi_search` (POST). `multi_search`
389
429
  * reports a failed entry inline instead of rejecting the call, so the entry is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lde/search-typesense",
3
- "version": "0.27.2",
3
+ "version": "0.27.4",
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,16 +25,17 @@
25
25
  "!**/*.tsbuildinfo"
26
26
  ],
27
27
  "dependencies": {
28
- "@lde/search": "^0.23.1",
28
+ "@lde/search": "^0.23.2",
29
29
  "@lde/text-normalization": "^0.1.2",
30
30
  "tslib": "^2.3.0",
31
31
  "typesense": "^3.0.6"
32
32
  },
33
33
  "devDependencies": {
34
+ "n3": "^2.1.2",
34
35
  "testcontainers": "^12.1.0"
35
36
  },
36
37
  "peerDependencies": {
37
38
  "@lde/dataset": "^0.7.9",
38
- "@lde/pipeline": "^0.36.1"
39
+ "@lde/pipeline": "^0.36.3"
39
40
  }
40
41
  }