@lde/search-typesense 0.27.4 → 0.29.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,5 +1,5 @@
1
1
  import { fold } from '@lde/text-normalization';
2
- import { fieldNamed, filterOperator, filterOperatorFor, ID_FIELD, isoToUnixSeconds, isRangeFacet, pageForOffset, physicalFields, searchableFields, } from '@lde/search/adapter';
2
+ import { fieldNamed, filterOperator, filterOperatorFor, ID_FIELD, isoToUnixSeconds, isInternalField, isRangeFacet, isWelded, joinGraph, localLookupTypeOf, pageForOffset, physicalFields, nestedReferenceType, resolvePath, searchableFields, } from '@lde/search/adapter';
3
3
  /**
4
4
  * Compile the engine-neutral {@link SearchQuery} into Typesense search
5
5
  * parameters – the query half of the engine adapter. Pure (no client, no env),
@@ -16,7 +16,7 @@ export function buildSearchParams(query, searchType, options = {}) {
16
16
  const folded = query.text !== undefined && query.text.length > 0
17
17
  ? fold(query.text)
18
18
  : undefined;
19
- const { names, weights } = queryFields(searchType, query.locale);
19
+ const { names, weights } = queryFields(searchType, query.locale, options.schema);
20
20
  const filterBy = compileFilterBy(query.where, searchType, options);
21
21
  const sortBy = query.orderBy
22
22
  .map((sort) => compileSort(sort, searchType, query.locale))
@@ -78,16 +78,41 @@ function compileRangeFacet(name, ranges) {
78
78
  * 1), so a match in the user’s language ranks higher while cross-language matches
79
79
  * still surface.
80
80
  */
81
- function queryFields(searchType, locale) {
81
+ function queryFields(searchType, locale, schema) {
82
82
  const names = [];
83
83
  const weights = [];
84
+ collectSearchable(searchType, locale, schema, '', new Set(), names, weights);
85
+ return { names, weights };
86
+ }
87
+ /**
88
+ * Collect the searchable physical fields of a type and of everything it nests,
89
+ * each under the path an engine addresses it by.
90
+ *
91
+ * Nested fields are walked because a nested field may declare `searchable`
92
+ * (ADR 24) – and a companion that is indexed but absent from `query_by` is the
93
+ * worst of both: it costs the RAM of an indexed field and matches nothing, in
94
+ * silence. So the walk here is what makes that Role mean anything.
95
+ *
96
+ * `onPath` guards the walk, and is scoped to the PATH rather than to the walk
97
+ * as a whole: two fields may nest one type – `creator` and `contributor` over
98
+ * the same edge – and each reaches it under its own prefix, so each must
99
+ * contribute its own companions. A set shared across the walk would let the
100
+ * first field claim the type and leave the second's companions indexed but
101
+ * unqueried. What it does guard is a type reached from ITSELF, which a
102
+ * {@link ReferenceStrategy.local local} lookup can do.
103
+ */
104
+ function collectSearchable(searchType, locale, schema, prefix, onPath, names, weights) {
105
+ if (onPath.has(searchType.name)) {
106
+ return;
107
+ }
108
+ const walked = new Set(onPath).add(searchType.name);
84
109
  for (const field of searchableFields(searchType)) {
85
110
  const search = physicalFields(field).search;
86
111
  const baseWeight = field.searchable.weight;
87
112
  if (field.kind === 'text') {
88
113
  const locales = field.locales;
89
114
  search.forEach((name, index) => {
90
- names.push(name);
115
+ names.push(qualify(prefix, name));
91
116
  // The active locale keeps full weight; `und` is language-neutral, so
92
117
  // it is never demoted (an untagged-only field would otherwise always
93
118
  // rank below its declared weight).
@@ -98,12 +123,26 @@ function queryFields(searchType, locale) {
98
123
  }
99
124
  else {
100
125
  for (const name of search) {
101
- names.push(name);
126
+ names.push(qualify(prefix, name));
102
127
  weights.push(baseWeight);
103
128
  }
104
129
  }
105
130
  }
106
- return { names, weights };
131
+ if (schema === undefined) {
132
+ return;
133
+ }
134
+ for (const field of searchType.fields) {
135
+ // An internal field is pruned before the writer and declared in no
136
+ // collection, so its target's companions exist nowhere – asking `query_by`
137
+ // for one makes the ENGINE reject every search on the collection.
138
+ if (isInternalField(field)) {
139
+ continue;
140
+ }
141
+ const nested = nestedReferenceType(schema, field) ?? localLookupTypeOf(field, schema);
142
+ if (nested !== undefined) {
143
+ collectSearchable(nested, locale, schema, qualify(prefix, field.name), walked, names, weights);
144
+ }
145
+ }
107
146
  }
108
147
  /** AND-join the compiled `where` clauses. A clause that states no constraint is
109
148
  * skipped; one that can match nothing compiles to {@link MATCHES_NOTHING}
@@ -201,16 +240,31 @@ const UNUSABLE = Symbol('unusable');
201
240
  function compileCriterion(criterion, searchType, options) {
202
241
  const on = criterion.on ?? [];
203
242
  if (on.length === 0) {
204
- return compileLeaf(criterion, searchType, options.schema);
243
+ return isWelded(criterion)
244
+ ? compileWelded(criterion, searchType, options.schema, '')
245
+ : compileLeaf(criterion, searchType, options.schema);
205
246
  }
247
+ // The path carries two kinds of hop, and they compile differently: a JOIN
248
+ // crosses into another collection and wraps the leaf in `$collection(…)`; a
249
+ // NESTING stays in this document and qualifies the leaf’s field name. The
250
+ // schema is what tells them apart, so without one every hop is read as a
251
+ // join – exactly the behaviour before nesting existed.
252
+ const resolvedPath = options.schema === undefined
253
+ ? undefined
254
+ : resolvePath(searchType, on, options.schema, joinGraph(options.schema));
255
+ if (typeof resolvedPath === 'string') {
256
+ return UNUSABLE;
257
+ }
258
+ const joinHops = resolvedPath?.joinPath ?? on;
259
+ const nestedHops = resolvedPath?.nestedPath ?? [];
206
260
  // A joined criterion constrains a document in ANOTHER collection, so the leaf
207
261
  // is compiled against the type that path reaches and then wrapped, one
208
262
  // `$collection(…)` per hop, outermost hop first:
209
263
  // `['dataset', 'publisher']` → `$datasets($publishers(id:=…))`.
210
264
  const collections = [];
211
265
  let target = searchType;
212
- for (let hop = 1; hop <= on.length; hop++) {
213
- const resolved = options.joinTargetFor?.(searchType, on.slice(0, hop));
266
+ for (let hop = 1; hop <= joinHops.length; hop++) {
267
+ const resolved = options.joinTargetFor?.(searchType, joinHops.slice(0, hop));
214
268
  if (resolved === undefined) {
215
269
  // An unresolvable path is a criterion that cannot be compiled at all –
216
270
  // false, so it drops out and its siblings still stand. Through the
@@ -220,7 +274,11 @@ function compileCriterion(criterion, searchType, options) {
220
274
  collections.push(resolved.collection);
221
275
  target = resolved.searchType;
222
276
  }
223
- const leaf = compileLeaf(criterion, target, options.schema);
277
+ const leafType = resolvedPath?.leafType ?? target;
278
+ const prefix = nestedHops.join('.');
279
+ const leaf = isWelded(criterion)
280
+ ? compileWelded(criterion, leafType, options.schema, prefix)
281
+ : compileLeaf(criterion, leafType, options.schema, prefix);
224
282
  // A vacuous leaf states no constraint on the referent, so the join as a whole
225
283
  // states none either – the reading, and so the outcome, passes straight
226
284
  // through the hops.
@@ -229,9 +287,16 @@ function compileCriterion(criterion, searchType, options) {
229
287
  }
230
288
  return collections.reduceRight((inner, collection) => `$${collection}(${inner})`, leaf);
231
289
  }
232
- /** The criterion’s own term, against the type it actually constrains – the
233
- * searched type, or the one its join path reaches. */
234
- function compileLeaf(criterion, searchType, schema) {
290
+ /**
291
+ * The criterion’s own term, against the type it actually constrains – the
292
+ * searched type, the one its join path reaches, or the reference type its
293
+ * nesting path walks into.
294
+ *
295
+ * `prefix` is that nesting path, dotted: an engine addresses a stored nested
296
+ * document by qualifying the field name, so `role` inside `creator` becomes
297
+ * `creator.role`. Empty for every unnested criterion.
298
+ */
299
+ function compileLeaf(criterion, searchType, schema, prefix = '') {
235
300
  // `id` is the Typesense document key, not a declared field. Exact `:=`
236
301
  // membership, like a non-facet field ({@link compileMembership}), so an IRI
237
302
  // cannot partial-match on a shared path segment. (`fetchLabels` resolves
@@ -239,7 +304,12 @@ function compileLeaf(criterion, searchType, schema) {
239
304
  // clause.) An empty identity membership enumerates NO document, so it is
240
305
  // unusable rather than vacuous – the one place the two readings diverge.
241
306
  if (criterion.field === ID_FIELD) {
242
- return 'in' in criterion && criterion.in.length > 0
307
+ // `id` names the DOCUMENT key, which exists only at the top level: inside a
308
+ // nested reference there is no document to key. Compiling it under a prefix
309
+ // would filter the root collection's `id` instead – a different question,
310
+ // silently answered. `assertValidQuery` rejects it first; this is the guard
311
+ // for a hand-built query.
312
+ return prefix === '' && 'in' in criterion && criterion.in.length > 0
243
313
  ? `${ID_FIELD}:=[${criterion.in.map(escapeFilterValue).join(',')}]`
244
314
  : UNUSABLE;
245
315
  }
@@ -254,13 +324,57 @@ function compileLeaf(criterion, searchType, schema) {
254
324
  }
255
325
  if ('in' in criterion) {
256
326
  return criterion.in.length > 0
257
- ? compileMembership(field, criterion.in, schema)
327
+ ? compileMembership(field, criterion.in, schema, prefix)
258
328
  : VACUOUS;
259
329
  }
260
330
  if ('range' in criterion) {
261
- return compileRange(field, criterion.range) ?? VACUOUS;
331
+ return compileRange(field, criterion.range, prefix) ?? VACUOUS;
332
+ }
333
+ // Every other shape returned above; a welded criterion never reaches here
334
+ // (compileWelded handles it before the leaf compiler is called).
335
+ return `${qualify(prefix, field.name)}:=${criterion.is}`;
336
+ }
337
+ /** A field name under its nesting path, if any – the one place the dotted
338
+ * addressing an engine uses for a stored nested document is spelled. */
339
+ function qualify(prefix, name) {
340
+ return prefix === '' ? name : `${prefix}.${name}`;
341
+ }
342
+ /**
343
+ * A **welded** criterion: `path.{a && b}`, matching a document with ONE entry
344
+ * that satisfies every condition – as against `path.a && path.b`, which two
345
+ * different entries can satisfy between them.
346
+ *
347
+ * Each condition is compiled against the reference type with an EMPTY prefix,
348
+ * so what goes inside the braces is always a leaf name. That is a correctness
349
+ * requirement, not tidiness: Typesense 30.2 **hangs** on a dotted path inside a
350
+ * group (`creator.{creator.name:=…}` never returns – no error, no result), so
351
+ * the compiler must be structurally incapable of emitting one. A field whose
352
+ * value is itself nested is reached through its identity companion, which is a
353
+ * leaf ({@link physicalFields}).
354
+ *
355
+ * A condition that states no constraint drops out, and the weld is whatever
356
+ * remains; a weld left with nothing is vacuous like any other empty clause.
357
+ */
358
+ function compileWelded(criterion, searchType, schema, prefix) {
359
+ const entryType = schema &&
360
+ nestedReferenceType(schema, fieldNamed(searchType, criterion.field) ?? {});
361
+ if (entryType === undefined) {
362
+ return UNUSABLE;
363
+ }
364
+ const conditions = [];
365
+ for (const condition of criterion.entry) {
366
+ const compiled = compileLeaf(condition, entryType, schema);
367
+ if (compiled === UNUSABLE) {
368
+ return UNUSABLE;
369
+ }
370
+ if (compiled !== VACUOUS) {
371
+ conditions.push(compiled);
372
+ }
373
+ }
374
+ if (conditions.length === 0) {
375
+ return VACUOUS;
262
376
  }
263
- return `${field.name}:=${criterion.is}`;
377
+ return `${qualify(prefix, criterion.field)}.{${conditions.join(' && ')}}`;
264
378
  }
265
379
  /**
266
380
  * A membership clause. A non-facet (tokenized) field uses the exact `:=`
@@ -272,11 +386,15 @@ function compileLeaf(criterion, searchType, schema) {
272
386
  * filter on such a field is exactly what the policy promises stays whole, so
273
387
  * it must compile exact, not tokenized.
274
388
  */
275
- function compileMembership(field, values, schema) {
389
+ function compileMembership(field, values, schema, prefix = '') {
276
390
  const list = `[${values.map(escapeFilterValue).join(',')}]`;
277
- return physicalFields(field, schema).facet === field.name
278
- ? `${field.name}:${list}`
279
- : `${field.name}:=${list}`;
391
+ const names = physicalFields(field, schema);
392
+ // An inline reference stores a nested object, which an engine cannot filter.
393
+ // Its identity companion is the flat id field standing in for it – so the
394
+ // logical name a consumer writes and the physical name the engine reads
395
+ // differ here, and only here.
396
+ const name = qualify(prefix, names.identity ?? field.name);
397
+ return names.facet === name ? `${name}:${list}` : `${name}:=${list}`;
280
398
  }
281
399
  /** An inclusive Typesense range clause, or `undefined` when neither bound is
282
400
  * usable. Which of the two readings that is – the caller set no bounds, or the
@@ -285,8 +403,8 @@ function compileMembership(field, values, schema) {
285
403
  * from the conjunction widens it, so the same reading cannot be right in both
286
404
  * positions. `validateQuery` rejects an unreadable bound outright instead,
287
405
  * which is the only answer that neither widens nor narrows. */
288
- function compileRange(field, range) {
289
- const name = field.name;
406
+ function compileRange(field, range, prefix = '') {
407
+ const name = qualify(prefix, field.name);
290
408
  // A bound a caller sent as `null` – a GraphQL variable left unfilled, which
291
409
  // the surface passes through – is a bound NOT SET, not a bound of `null`.
292
410
  // Read literally it reaches the engine as `datePosted:[null..…]`, which
@@ -1,5 +1,4 @@
1
- import type { Client } from 'typesense';
2
- import type { SearchSchema, SearchType } from '@lde/search';
1
+ import type { SearchType } from '@lde/search';
3
2
  import { type CollectionDefinitionOptions } from './collection-definition.js';
4
3
  /**
5
4
  * The tuning knobs both rebuild writers share, on top of the collection-definition
@@ -39,43 +38,4 @@ export interface ResolvedRebuildOptions {
39
38
  * been paid for.
40
39
  */
41
40
  export declare function resolveRebuildOptions(searchType: SearchType, options: RebuildOptions): ResolvedRebuildOptions;
42
- /**
43
- * Reject a {@link SearchType} that declares any of the bookkeeping field names
44
- * a writer stamps, so a domain field can never collide with `source` /
45
- * `last_seen`.
46
- */
47
- export declare function assertNoReservedFields(searchType: SearchType, reserved: readonly string[]): void;
48
- /**
49
- * Reject a declared dataset field the provenance bookkeeping cannot be kept on.
50
- * A type declaring one makes it the collection’s provenance field – the column
51
- * the membership sweep enumerates and deletes by – so the declaration has to answer
52
- * *which single dataset is this document’s* the way the private `source` field
53
- * did:
54
- *
55
- * - **not `array`**: Typesense reads `field:=[a,b]` over a `string[]` as
56
- * *contains any*, so a departed source would take every document that merely
57
- * mentions it – including entities another selected dataset still
58
- * contributes;
59
- * - **no `transform`**: the sweep compares the stored value against the run’s
60
- * selection, which carries raw dataset IRIs, so a transformed value would
61
- * match nothing and the sweep would silently stop deleting;
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)
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.
69
- *
70
- * Thrown at writer construction, so a declaration that cannot be swept fails
71
- * before a run touches the index rather than at the sweep, after the writes.
72
- */
73
- export declare function assertSweepableProvenanceField(searchType: SearchType, options: {
74
- readonly requireFacetable: boolean;
75
- readonly schema?: SearchSchema;
76
- }): void;
77
- /** Stamp each document with fixed bookkeeping fields as it streams past. */
78
- export declare function stampDocuments<TDocument>(documents: AsyncIterable<TDocument>, stamp: Readonly<Record<string, string>>): AsyncIterable<TDocument & Record<string, string>>;
79
- /** Delete a collection’s documents matching a Typesense filter. */
80
- export declare function deleteByFilter(client: Client, collection: string, filterBy: string): Promise<void>;
81
41
  //# sourceMappingURL=rebuild-support.d.ts.map
@@ -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,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
+ {"version":3,"file":"rebuild-support.d.ts","sourceRoot":"","sources":["../src/rebuild-support.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAE9C,OAAO,EAEL,KAAK,2BAA2B,EACjC,MAAM,4BAA4B,CAAC;AAOpC;;;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"}
@@ -1,8 +1,9 @@
1
- import { datasetField, isInternalField, joinGraph, physicalFields, referenceFields, } from '@lde/search/adapter';
1
+ import { 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
- import { DEFAULT_BATCH_SIZE } from './import.js';
5
+ /** Documents imported per Typesense request unless a writer is told otherwise. */
6
+ const DEFAULT_BATCH_SIZE = 1000;
6
7
  /**
7
8
  * Apply the shared defaults, once, so neither writer restates them – including
8
9
  * the collection naming, derived from each type ({@link deriveCollectionName})
@@ -59,77 +60,3 @@ function assertDistinctJoinTargetNames(searchType, options) {
59
60
  }
60
61
  }
61
62
  }
62
- /**
63
- * Reject a {@link SearchType} that declares any of the bookkeeping field names
64
- * a writer stamps, so a domain field can never collide with `source` /
65
- * `last_seen`.
66
- */
67
- export function assertNoReservedFields(searchType, reserved) {
68
- const clashing = searchType.fields.filter((field) => reserved.includes(field.name));
69
- if (clashing.length > 0) {
70
- throw new Error(`SearchType “${searchType.name}” declares reserved bookkeeping field(s) ${clashing
71
- .map((field) => `“${field.name}”`)
72
- .join(', ')}`);
73
- }
74
- }
75
- /**
76
- * Reject a declared dataset field the provenance bookkeeping cannot be kept on.
77
- * A type declaring one makes it the collection’s provenance field – the column
78
- * the membership sweep enumerates and deletes by – so the declaration has to answer
79
- * *which single dataset is this document’s* the way the private `source` field
80
- * did:
81
- *
82
- * - **not `array`**: Typesense reads `field:=[a,b]` over a `string[]` as
83
- * *contains any*, so a departed source would take every document that merely
84
- * mentions it – including entities another selected dataset still
85
- * contributes;
86
- * - **no `transform`**: the sweep compares the stored value against the run’s
87
- * selection, which carries raw dataset IRIs, so a transformed value would
88
- * match nothing and the sweep would silently stop deleting;
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)
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.
96
- *
97
- * Thrown at writer construction, so a declaration that cannot be swept fails
98
- * before a run touches the index rather than at the sweep, after the writes.
99
- */
100
- export function assertSweepableProvenanceField(searchType, options) {
101
- const field = datasetField(searchType);
102
- if (field === undefined || isInternalField(field)) {
103
- return;
104
- }
105
- const problems = [];
106
- if (field.array === true) {
107
- problems.push('it is an array, and a membership sweep over one would delete every document merely mentioning a departed dataset');
108
- }
109
- if (field.transform !== undefined) {
110
- problems.push('it declares a transform, and the sweep matches the stored value against the run’s raw dataset IRIs');
111
- }
112
- if (options.requireFacetable && field.facetable !== true) {
113
- problems.push('it is not facetable, and the membership sweep enumerates the indexed datasets by faceting it');
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
- }
119
- if (problems.length > 0) {
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 ')}.`);
121
- }
122
- }
123
- /** Stamp each document with fixed bookkeeping fields as it streams past. */
124
- export async function* stampDocuments(documents, stamp) {
125
- for await (const document of documents) {
126
- yield { ...document, ...stamp };
127
- }
128
- }
129
- /** Delete a collection’s documents matching a Typesense filter. */
130
- export async function deleteByFilter(client, collection, filterBy) {
131
- await client
132
- .collections(collection)
133
- .documents()
134
- .delete({ filter_by: filterBy });
135
- }
@@ -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, facetableFields, 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, labelTargetNameOf, localLookupTypeOf, 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';
@@ -68,9 +68,12 @@ export function createTypesenseSearchEngine(client, schema, options = {}) {
68
68
  const labelSources = new Map([...schema.values()].map((searchType) => [
69
69
  searchType.class,
70
70
  new Map(referenceFields(searchType)
71
- .filter((field) => labelSourceNameOf(field) !== undefined)
71
+ // An INLINE reference names its label target one level in, through
72
+ // its identity companion: the companion holds that field's ids, so
73
+ // that field's target is what can label them.
74
+ .filter((field) => labelTargetNameOf(field, schema) !== undefined)
72
75
  .map((field) => {
73
- const source = typesByName.get(labelSourceNameOf(field));
76
+ const source = typesByName.get(labelTargetNameOf(field, schema));
74
77
  const labelField = labelFieldOf(source);
75
78
  return [
76
79
  field.name,
@@ -114,7 +117,18 @@ export function createTypesenseSearchEngine(client, schema, options = {}) {
114
117
  return [
115
118
  searchType.class,
116
119
  referenceFields(searchType)
117
- .filter((field) => field.output === true && sources?.has(field.name))
120
+ .filter((field) => field.output === true &&
121
+ sources?.has(field.name) === true &&
122
+ // A field storing NESTED DOCUMENTS is excluded, and must be: this
123
+ // list drives the per-hit label lookup, which reads each stored
124
+ // value as an id. An inline reference and a `local` lookup store
125
+ // objects, so every entry would contribute the string
126
+ // `"[object Object]"` to the batched `id:[…]` filter. Neither
127
+ // needs entry labels anyway – the referent's own fields are in
128
+ // the entry – and their FACET buckets are labelled by a separate
129
+ // path that reads the companion.
130
+ nestedReferenceType(schema, field) === undefined &&
131
+ localLookupTypeOf(field, schema) === undefined)
118
132
  .map((field) => ({
119
133
  name: field.name,
120
134
  source: sources.get(field.name),
@@ -538,7 +552,7 @@ export function parseSearchResponse(response, searchType, labels, schema, refere
538
552
  // reference facet stays id-only even when a cached full-collection map holds
539
553
  // its IRIs.
540
554
  const referenceFacets = new Set(referenceFields(searchType)
541
- .filter((field) => labelSourceNameOf(field) !== undefined)
555
+ .filter((field) => labelTargetNameOf(field, schema) !== undefined)
542
556
  .map((field) => field.name));
543
557
  const facets = {};
544
558
  for (const facet of response.facet_counts ?? []) {
@@ -587,15 +601,25 @@ function logicalValue(flat, field, labels, schema, referents) {
587
601
  // reconstructs as a nested Search Document rather than as an IRI plus a
588
602
  // label. Nesting is rebuilt HERE, below every surface, so a second
589
603
  // surface inherits it instead of reimplementing it (ADR 11).
604
+ const resolved = referents?.get(field.name);
590
605
  const nested = nestedReferenceType(schema, field);
591
606
  if (nested !== undefined) {
592
- return nestedValue(flat[field.name], field, nested, labels, schema);
607
+ // A nested level resolves nothing itself; what it carries are the
608
+ // levels below it, which the entries are reconstructed against.
609
+ return nestedValue(flat[field.name], field, nested, labels, schema, resolved?.children);
610
+ }
611
+ // Checked BEFORE the plain lookup below, because a `local` lookup
612
+ // resolves through the same kind of level while storing a different
613
+ // shape: its stored value is the endpoint's own document, not an id, so
614
+ // reading it as one would stringify the object into a bogus `id`.
615
+ const localType = localLookupTypeOf(field, schema);
616
+ if (localType !== undefined) {
617
+ return localLookupValue(flat[field.name], field, localType, resolved, labels, schema);
593
618
  }
594
619
  // A resolved lookup carries the referent's own fields, fetched from the
595
620
  // target's collection. It reconstructs through the SAME path a hit does,
596
621
  // so a consumer cannot tell a projected referent from an inline one.
597
- const resolved = referents?.get(field.name);
598
- if (resolved !== undefined) {
622
+ if (resolved !== undefined && resolved.via === 'lookup') {
599
623
  return lookupValue(flat[field.name], field, resolved, labels, schema);
600
624
  }
601
625
  // A surfaced inline reference stores nested documents, not IRIs, so a
@@ -655,10 +679,10 @@ function localizedValue(flat, field) {
655
679
  * a nested document is read, not addressed. A nested reference type may itself
656
680
  * surface an inline reference, which recurses through {@link reconstructDocument}.
657
681
  */
658
- function nestedValue(raw, field, referenceType, labels, schema) {
682
+ function nestedValue(raw, field, referenceType, labels, schema, children) {
659
683
  const referents = (Array.isArray(raw) ? raw : [raw]).filter((referent) => typeof referent === 'object' && referent !== null);
660
684
  const documents = referents.map((referent) => {
661
- const document = reconstructDocument(referent, referenceType, labels, schema);
685
+ const document = reconstructDocument(referent, referenceType, labels, schema, children);
662
686
  return typeof referent.id === 'string'
663
687
  ? { id: referent.id, ...document }
664
688
  : document;
@@ -668,6 +692,34 @@ function nestedValue(raw, field, referenceType, labels, schema) {
668
692
  }
669
693
  return field.array === true ? documents : documents[0];
670
694
  }
695
+ /**
696
+ * Rebuild a {@link ReferenceStrategy.local local} lookup: the endpoint’s own
697
+ * document as this document stated it, with the **resolved** document overlaid
698
+ * where the lookup found one.
699
+ *
700
+ * Both sides are the target type’s flat physical shape, so the merge is a plain
701
+ * override and the authoritative value wins field by field. Three cases fall
702
+ * out of one expression, which is the point of storing local fields
703
+ * unconditionally:
704
+ *
705
+ * - identified and indexed → the target’s own record, over what was stated here;
706
+ * - identified but not indexed → what was stated here, plus the `id`, rather
707
+ * than a bare IRI;
708
+ * - not identified → what was stated here, with no `id` at all.
709
+ */
710
+ function localLookupValue(raw, field, target, resolved, labels, schema) {
711
+ const fetched = resolved?.via === 'lookup' ? resolved.documents : new Map();
712
+ const merged = (Array.isArray(raw) ? raw : [raw])
713
+ .filter((entry) => typeof entry === 'object' && entry !== null)
714
+ .map((entry) => {
715
+ const id = typeof entry.id === 'string' ? entry.id : undefined;
716
+ const authoritative = id === undefined ? undefined : fetched.get(id);
717
+ return authoritative === undefined
718
+ ? entry
719
+ : { ...entry, ...authoritative };
720
+ });
721
+ return nestedValue(merged, field, target, labels, schema, resolved?.children);
722
+ }
671
723
  /**
672
724
  * Rebuild a resolved `lookup` reference: each stored IRI becomes the referent’s
673
725
  * own document, read through the **target’s** declaration and carrying whatever
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lde/search-typesense",
3
- "version": "0.27.4",
3
+ "version": "0.29.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,17 +25,17 @@
25
25
  "!**/*.tsbuildinfo"
26
26
  ],
27
27
  "dependencies": {
28
- "@lde/search": "^0.23.2",
28
+ "@lde/search": "^0.24.0",
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
+ "n3": "^2.4.0",
35
35
  "testcontainers": "^12.1.0"
36
36
  },
37
37
  "peerDependencies": {
38
38
  "@lde/dataset": "^0.7.9",
39
- "@lde/pipeline": "^0.36.3"
39
+ "@lde/pipeline": "^0.36.4"
40
40
  }
41
41
  }
package/dist/import.d.ts DELETED
@@ -1,31 +0,0 @@
1
- import type { Client } from 'typesense';
2
- /** Documents imported per Typesense request unless a writer is told otherwise. */
3
- export declare const DEFAULT_BATCH_SIZE = 1000;
4
- /**
5
- * Accumulates documents across writes and upserts them into a collection in
6
- * fixed-size batches, so many small writes (one dataset at a time) still land
7
- * as few Typesense requests. Call {@link flush} to import the remainder.
8
- */
9
- export declare class BatchImporter<TDocument extends {
10
- id: string;
11
- }> {
12
- private readonly client;
13
- private readonly collection;
14
- private readonly batchSize;
15
- private buffer;
16
- constructor(client: Client, collection: string, batchSize: number);
17
- /** Add a stream of documents, importing a batch whenever one fills up. */
18
- add(documents: AsyncIterable<TDocument>): Promise<void>;
19
- /** Import any remaining documents. */
20
- flush(): Promise<void>;
21
- private importBuffer;
22
- }
23
- /**
24
- * Create-or-replace one batch of whole documents, keyed on `id`, throwing if any
25
- * individual document fails (Typesense’s bulk import otherwise reports
26
- * per-document failures without rejecting).
27
- */
28
- export declare function importBatch(client: Client, collection: string, batch: readonly {
29
- id: string;
30
- }[]): Promise<void>;
31
- //# sourceMappingURL=import.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"import.d.ts","sourceRoot":"","sources":["../src/import.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAkB,MAAM,WAAW,CAAC;AAExD,kFAAkF;AAClF,eAAO,MAAM,kBAAkB,OAAO,CAAC;AAEvC;;;;GAIG;AACH,qBAAa,aAAa,CAAC,SAAS,SAAS;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE;IAIvD,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,UAAU;IAC3B,OAAO,CAAC,QAAQ,CAAC,SAAS;IAL5B,OAAO,CAAC,MAAM,CAAmB;gBAGd,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM;IAGpC,0EAA0E;IACpE,GAAG,CAAC,SAAS,EAAE,aAAa,CAAC,SAAS,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC;IAS7D,sCAAsC;IAChC,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;YAMd,YAAY;CAK3B;AAED;;;;GAIG;AACH,wBAAsB,WAAW,CAC/B,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,MAAM,EAClB,KAAK,EAAE,SAAS;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE,EAAE,GAC/B,OAAO,CAAC,IAAI,CAAC,CAkBf"}