@lde/search-typesense 0.28.0 → 0.29.1

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,36 +1,25 @@
1
1
  import { facetableFields, physicalFields } from '@lde/search/adapter';
2
2
  import { buildCollectionDefinition } from './collection-definition.js';
3
- import { BatchImporter } from './import.js';
4
3
  import { ensureCollectionExists, openLockedRun, releaseLock } from './lock.js';
5
- import { LAST_SEEN_FIELD, SOURCE_FIELD, departedSources, membershipSweepFilters, provenanceField, staleDocumentsFilter, thisRunDocumentsFilter, } from './sweep.js';
6
- import { assertNoReservedFields, assertSweepableProvenanceField, deleteByFilter, resolveRebuildOptions, stampDocuments, } from './rebuild-support.js';
7
- /**
8
- * Default ceiling on the distinct sources the membership sweep enumerates via
9
- * a single `source` facet. The sweep needs the complete source set to spot
10
- * departed ones, so a truncated facet would silently miss deletions;
11
- * {@link InPlaceRebuild} throws instead. Raise it (up to the engine’s
12
- * `max_facet_values` limit) with {@link InPlaceRebuildOptions.maxSweepableSources}
13
- * before an index approaches it, so the ceiling is a tunable guard rather than
14
- * a hard wall.
15
- */
16
- const DEFAULT_MAX_SWEEPABLE_SOURCES = 10_000;
4
+ import { assertBookkeepingPresent, assertWritableType, openDocuments, withBookkeeping, } from './documents.js';
5
+ import { resolveRebuildOptions, } from './rebuild-support.js';
17
6
  /**
18
7
  * In-place Rebuild (update the live index directly – upsert changed sources,
19
8
  * sweep the rest – rather than swap in a fresh one) as a transactional
20
9
  * `Writer`: documents are upserted into one long-lived collection with
21
- * per-source atomicity – no swap, no staging. Every document is stamped with
22
- * its `source` (the dataset IRI) and `last_seen` (the run id); deletion is a
23
- * sweep, never special-cased:
10
+ * per-dataset atomicity – no swap, no staging. Deletion is a sweep, never
11
+ * special-cased, and {@link openDocuments} decides what a deletion physically
12
+ * is – for a canonically keyed type, dropping one dataset’s membership of a
13
+ * document several datasets share, and the document itself only with the last
14
+ * of them:
24
15
  *
25
- * - a **successful dataset flush** deletes the source’s documents the run did
26
- * not rewrite (`source = dataset && last_seen != runId`). A failed dataset
27
- * is not swept – its output is incomplete, and the next successful run
28
- * reconciles;
29
- * - **reset** (the pipeline’s dump-fallback discard) deletes only *this run’s*
30
- * writes for the dataset (`source = dataset && last_seen = runId`), so the
31
- * dump re-run rebuilds it cleanly while the source’s prior-run documents are
32
- * left for the success sweep to reconcile;
33
- * - **commit** deletes every document whose source left the run’s selection
16
+ * - a **successful dataset flush** drops what the dataset had and this run did
17
+ * not rewrite. A failed dataset is not swept – its output is incomplete, and
18
+ * the next successful run reconciles;
19
+ * - **reset** (the pipeline’s dump-fallback discard) drops only *this run’s*
20
+ * writes for the dataset, so the dump re-run rebuilds it cleanly while the
21
+ * dataset’s prior-run documents are left for the success sweep to reconcile;
22
+ * - **commit** drops what belongs to datasets that left the run’s selection
34
23
  * (registry-membership sweep over {@link RunContext.selectedSources}, which
35
24
  * includes datasets skipped as unchanged) and releases the lock;
36
25
  * - **abort** only releases the lock: upserts are idempotent, so whatever
@@ -38,15 +27,17 @@ const DEFAULT_MAX_SWEEPABLE_SOURCES = 10_000;
38
27
  *
39
28
  * `openRun` takes the single-flight cross-pod lock (throwing
40
29
  * `RebuildAlreadyRunning` when another rebuild holds it) and creates
41
- * the collection on demand from the {@link SearchType} plus the two
42
- * bookkeeping fields. A collection that already exists is used as it is, with
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}).
30
+ * the collection on demand from the {@link SearchType} plus the bookkeeping
31
+ * fields. A collection that already exists is used as it is, with two
32
+ * exceptions: it must carry every reference field the declaration asks for and
33
+ * every facet companion its facets read
34
+ * ({@link assertReferencesAndFacetCompanionsPresent}), and the membership field
35
+ * a keyed type records in ({@link assertBookkeepingPresent}) – each failing
36
+ * with the rebuild that fixes it.
47
37
  *
48
- * Document ids must be unique per (source, entity) – the caller keys them –
49
- * or documents from different sources overwrite each other.
38
+ * Unless the type is canonically keyed, document ids must be unique per
39
+ * (dataset, entity) – the caller keys them – or documents from different
40
+ * datasets overwrite each other.
50
41
  *
51
42
  * The name is the NDE Stack’s pattern
52
43
  * ({@link https://docs.nde.nl/stack/patterns | Stack patterns}).
@@ -63,21 +54,16 @@ export class InPlaceRebuild {
63
54
  collectionName;
64
55
  maxSweepableSources;
65
56
  resolved;
66
- /** The column this collection carries its documents’ dataset IRI in: the
67
- * type’s declared dataset field, or the private `source` when it declares
68
- * none. Everything below – the stamp, every sweep filter, the source
69
- * enumeration – reads this one name. */
70
- sourceField;
71
57
  constructor(client, searchType, options = {}) {
72
58
  this.client = client;
73
59
  this.searchType = searchType;
74
- assertNoReservedFields(searchType, [SOURCE_FIELD, LAST_SEEN_FIELD]);
75
- assertSweepableProvenanceField(searchType, {
60
+ // The membership sweep enumerates the indexed datasets by faceting the
61
+ // column they are recorded in, so a declared one has to be facetable.
62
+ assertWritableType(searchType, {
76
63
  requireFacetable: true,
77
64
  schema: options.schema,
78
65
  });
79
- this.sourceField = provenanceField(searchType);
80
- const { maxSweepableSources = DEFAULT_MAX_SWEEPABLE_SOURCES, ...rebuildOptions } = options;
66
+ const { maxSweepableSources, ...rebuildOptions } = options;
81
67
  this.maxSweepableSources = maxSweepableSources;
82
68
  this.resolved = resolveRebuildOptions(searchType, rebuildOptions);
83
69
  this.collectionName =
@@ -86,78 +72,47 @@ export class InPlaceRebuild {
86
72
  async openRun(context) {
87
73
  const { batchSize, lockTtlMs, definitionOptions } = this.resolved;
88
74
  const name = this.collectionName;
89
- const sourceField = this.sourceField;
90
75
  return openLockedRun(this.client, name, lockTtlMs, async () => {
91
- // Create the collection on demand: SearchType schema + the bookkeeping
92
- // fields. The private `source` – faceted so the membership sweep can
93
- // enumerate the distinct sources – only when the type declares no dataset
94
- // field of its own; when it does, that declared field already carries the
95
- // IRI (and its own `facet: true`), and a second column would be the same
96
- // value twice, free to drift.
76
+ // Create the collection on demand: the SearchType’s schema plus whatever
77
+ // bookkeeping its membership regime keeps.
97
78
  const definition = buildCollectionDefinition(this.searchType, definitionOptions);
98
79
  const declaredFields = definition.fields ?? [];
99
- const created = await ensureCollectionExists(this.client, name, () => {
100
- const bookkeeping = [
101
- ...(sourceField === SOURCE_FIELD
102
- ? [{ name: SOURCE_FIELD, type: 'string', facet: true }]
103
- : []),
104
- { name: LAST_SEEN_FIELD, type: 'string' },
105
- ];
106
- return {
107
- ...definition,
108
- fields: [...declaredFields, ...bookkeeping],
109
- };
110
- });
80
+ const created = await ensureCollectionExists(this.client, name, () => withBookkeeping(definition, this.searchType));
111
81
  // A collection this run did NOT create may predate a `joinable` being
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.
82
+ // declared, a facet policy on a type this one references, or the type
83
+ // being keyed – and none of the three can be added to it here.
114
84
  if (!created) {
115
- await assertReferencesAndFacetCompanionsPresent(this.client, name, declaredFields, facetCompanionsOf(this.searchType, definitionOptions.schema));
85
+ const existing = (await this.client.collections(name).retrieve())
86
+ .fields;
87
+ assertReferencesAndFacetCompanionsPresent(name, declaredFields, facetCompanionsOf(this.searchType, definitionOptions.schema), existing);
88
+ assertBookkeepingPresent(this.searchType, existing, name);
116
89
  }
117
- const importer = new BatchImporter(this.client, name, batchSize);
90
+ const documents = openDocuments(this.client, name, {
91
+ searchType: this.searchType,
92
+ runId: context.runId,
93
+ startedAt: context.startedAt,
94
+ batchSize,
95
+ maxSweepableSources: this.maxSweepableSources,
96
+ });
118
97
  return {
119
- // Stamping the provenance field – rather than trusting the projection
120
- // to have filled a declared one – is what keeps the sweep total: a
121
- // caller projecting without a dataset context, or a document reaching
122
- // the writer any other way, still lands in a swept collection. Where
123
- // the projection did fill it, the value is the same dataset IRI, so the
124
- // stamp only ever reasserts it.
125
- //
126
- // It reasserts the sweep’s column only, never the physical fanout a
127
- // declared field also produces (the folded `${name}_search` companion
128
- // of a `searchable` one). Folding is the projection’s convention, and
129
- // restating it here would put it in two places – the split this change
130
- // exists to close. So a document that reached the writer unprojected is
131
- // sweepable but not full-text findable by dataset: absent, never wrong.
132
- write: async (dataset, documents) => importer.add(stampDocuments(documents, {
133
- [sourceField]: dataset.iri.toString(),
134
- [LAST_SEEN_FIELD]: context.runId,
135
- })),
98
+ write: async (dataset, items) => documents.add(dataset, items),
136
99
  flush: async (dataset, outcome) => {
137
- // Land the buffered documents first, so the sweep below never
138
- // deletes what this run just rewrote.
139
- await importer.flush();
140
100
  if (outcome !== 'success') {
141
101
  // A failed dataset’s output is incomplete: sweeping against it
142
102
  // would delete documents the run never got to rewrite. Leave the
143
- // stale ones for the next successful run to reconcile.
103
+ // stale ones for the next successful run to reconcile. The buffered
104
+ // documents stay buffered; the next flush lands them.
144
105
  return;
145
106
  }
146
- await deleteByFilter(this.client, name, staleDocumentsFilter(sourceField, dataset.iri.toString(), context.runId));
147
- },
148
- reset: async (dataset) => {
149
- // Discard only this run’s partial writes for the source (the failed
150
- // endpoint attempt) so the dump re-run rebuilds it cleanly; the
151
- // source’s prior-run documents stay for the success sweep.
152
- await importer.flush();
153
- await deleteByFilter(this.client, name, thisRunDocumentsFilter(sourceField, dataset.iri.toString(), context.runId));
107
+ await documents.dropUnwritten(dataset);
154
108
  },
109
+ // Discard only this run’s partial writes for the dataset (the failed
110
+ // endpoint attempt) so the dump re-run rebuilds it cleanly; the
111
+ // dataset’s prior-run documents stay for the success sweep.
112
+ reset: async (dataset) => documents.dropWrittenThisRun(dataset),
155
113
  commit: async () => {
156
- await importer.flush();
157
- const departed = departedSources(await this.indexedSources(name, this.maxSweepableSources), context.selectedSources());
158
- for (const filter of membershipSweepFilters(sourceField, departed)) {
159
- await deleteByFilter(this.client, name, filter);
160
- }
114
+ await documents.flush();
115
+ await documents.dropUnselected(context.selectedSources());
161
116
  await releaseLock(this.client, name);
162
117
  },
163
118
  abort: async () => {
@@ -166,30 +121,6 @@ export class InPlaceRebuild {
166
121
  };
167
122
  });
168
123
  }
169
- /**
170
- * The distinct sources present in the collection, via a single facet over its
171
- * {@link provenanceField}. Requests one bucket beyond `maxSources` so genuine truncation is
172
- * distinguishable from an exactly-full result: `maxSources` buckets are
173
- * returned intact, `maxSources + 1` proves more exist and the sweep would
174
- * miss some, so it throws rather than delete blind.
175
- */
176
- async indexedSources(name, maxSources) {
177
- const response = await this.client
178
- .collections(name)
179
- .documents()
180
- .search({
181
- q: '*',
182
- query_by: this.sourceField,
183
- per_page: 0,
184
- facet_by: this.sourceField,
185
- max_facet_values: maxSources + 1,
186
- });
187
- const counts = response.facet_counts?.[0]?.counts ?? [];
188
- if (counts.length > maxSources) {
189
- throw new Error(`Membership sweep cannot see beyond ${maxSources} distinct sources in “${name}”; raise maxSweepableSources or departed sources might be missed`);
190
- }
191
- return counts.map((count) => count.value);
192
- }
193
124
  }
194
125
  /**
195
126
  * Fail loudly when an EXISTING collection does not carry every reference field
@@ -218,15 +149,12 @@ export class InPlaceRebuild {
218
149
  * self-correcting (a new plain field simply starts being written) or
219
150
  * harmless, and general drift detection is a feature of its own.
220
151
  */
221
- async function assertReferencesAndFacetCompanionsPresent(client, name, fields, facetCompanions) {
152
+ function assertReferencesAndFacetCompanionsPresent(name, fields, facetCompanions, existingFields) {
222
153
  const references = fields.filter((field) => field.reference !== undefined);
223
154
  if (references.length === 0 && facetCompanions.length === 0) {
224
155
  return;
225
156
  }
226
- const existing = new Map((await client.collections(name).retrieve()).fields.map((field) => [
227
- field.name,
228
- field,
229
- ]));
157
+ const existing = new Map(existingFields.map((field) => [field.name, field]));
230
158
  const missingReferences = references.filter((field) => existing.get(field.name)?.reference !== field.reference);
231
159
  if (missingReferences.length > 0) {
232
160
  throw new Error(`Typesense collection “${name}” exists without the reference field(s) ${missingReferences
package/dist/index.d.ts CHANGED
@@ -3,7 +3,6 @@ export type { BlueGreenRebuildOptions } from './blue-green-rebuild.js';
3
3
  export { InPlaceRebuild } from './in-place-rebuild.js';
4
4
  export type { InPlaceRebuildOptions } from './in-place-rebuild.js';
5
5
  export { RebuildAlreadyRunning } from './lock.js';
6
- export { departedSources, membershipSweepFilters, sourceDocumentsFilter, staleDocumentsFilter, } from './sweep.js';
7
6
  export { buildCollectionDefinition } from './collection-definition.js';
8
7
  export type { CollectionDefinitionOptions } from './collection-definition.js';
9
8
  export { deriveCollectionName } from './collection-name.js';
@@ -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,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"}
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,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/index.js CHANGED
@@ -1,7 +1,6 @@
1
1
  export { BlueGreenRebuild } from './blue-green-rebuild.js';
2
2
  export { InPlaceRebuild } from './in-place-rebuild.js';
3
3
  export { RebuildAlreadyRunning } from './lock.js';
4
- export { departedSources, membershipSweepFilters, sourceDocumentsFilter, staleDocumentsFilter, } from './sweep.js';
5
4
  export { buildCollectionDefinition } from './collection-definition.js';
6
5
  export { deriveCollectionName } from './collection-name.js';
7
6
  export { buildSearchParams } from './query-compiler.js';
@@ -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,YAAY,EACjB,KAAK,UAAU,EAIhB,MAAM,aAAa,CAAC;AAmBrB;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,CAkCtB;AAwfD;;;;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,EAIhB,MAAM,aAAa,CAAC;AAmBrB;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,CAkCtB;AA4fD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAEvD"}
@@ -99,12 +99,11 @@ function queryFields(searchType, locale, schema) {
99
99
  * contribute its own companions. A set shared across the walk would let the
100
100
  * first field claim the type and leave the second's companions indexed but
101
101
  * unqueried. What it does guard is a type reached from ITSELF, which a
102
- * {@link ReferenceStrategy.local local} lookup can do.
102
+ * {@link ReferenceStrategy.local local} lookup can do – cut at the same
103
+ * boundary the collection's own walk cuts at, so no field is declared indexed
104
+ * that this walk does not ask for.
103
105
  */
104
106
  function collectSearchable(searchType, locale, schema, prefix, onPath, names, weights) {
105
- if (onPath.has(searchType.name)) {
106
- return;
107
- }
108
107
  const walked = new Set(onPath).add(searchType.name);
109
108
  for (const field of searchableFields(searchType)) {
110
109
  const search = physicalFields(field).search;
@@ -139,7 +138,12 @@ function collectSearchable(searchType, locale, schema, prefix, onPath, names, we
139
138
  continue;
140
139
  }
141
140
  const nested = nestedReferenceType(schema, field) ?? localLookupTypeOf(field, schema);
142
- if (nested !== undefined) {
141
+ // Cut on the CHILD, not on entry, and against the set this level was
142
+ // reached with – the same boundary the collection's own walk cuts at
143
+ // (`nestedFields`). Returning on entry instead skipped the companions of
144
+ // the level the collection HAD declared, leaving them indexed and absent
145
+ // from `query_by` wherever a type reached itself.
146
+ if (nested !== undefined && !onPath.has(nested.name)) {
143
147
  collectSearchable(nested, locale, schema, qualify(prefix, field.name), walked, names, weights);
144
148
  }
145
149
  }
@@ -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
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lde/search-typesense",
3
- "version": "0.28.0",
3
+ "version": "0.29.1",
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.24.0",
28
+ "@lde/search": "^0.24.1",
29
29
  "@lde/text-normalization": "^0.1.2",
30
30
  "tslib": "^2.3.0",
31
31
  "typesense": "^3.0.6"
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"}
package/dist/import.js DELETED
@@ -1,60 +0,0 @@
1
- /** Documents imported per Typesense request unless a writer is told otherwise. */
2
- export const DEFAULT_BATCH_SIZE = 1000;
3
- /**
4
- * Accumulates documents across writes and upserts them into a collection in
5
- * fixed-size batches, so many small writes (one dataset at a time) still land
6
- * as few Typesense requests. Call {@link flush} to import the remainder.
7
- */
8
- export class BatchImporter {
9
- client;
10
- collection;
11
- batchSize;
12
- buffer = [];
13
- constructor(client, collection, batchSize) {
14
- this.client = client;
15
- this.collection = collection;
16
- this.batchSize = batchSize;
17
- }
18
- /** Add a stream of documents, importing a batch whenever one fills up. */
19
- async add(documents) {
20
- for await (const document of documents) {
21
- this.buffer.push(document);
22
- if (this.buffer.length >= this.batchSize) {
23
- await this.importBuffer();
24
- }
25
- }
26
- }
27
- /** Import any remaining documents. */
28
- async flush() {
29
- if (this.buffer.length > 0) {
30
- await this.importBuffer();
31
- }
32
- }
33
- async importBuffer() {
34
- const batch = this.buffer;
35
- this.buffer = [];
36
- await importBatch(this.client, this.collection, batch);
37
- }
38
- }
39
- /**
40
- * Create-or-replace one batch of whole documents, keyed on `id`, throwing if any
41
- * individual document fails (Typesense’s bulk import otherwise reports
42
- * per-document failures without rejecting).
43
- */
44
- export async function importBatch(client, collection, batch) {
45
- const results = (await client
46
- .collections(collection)
47
- .documents()
48
- .import(batch, {
49
- action: 'upsert',
50
- // Collect per-document outcomes instead of throwing the client’s opaque
51
- // ImportError, so we can report which documents failed and why.
52
- throwOnFail: false,
53
- }));
54
- const failures = results.filter((result) => !result.success);
55
- if (failures.length > 0) {
56
- throw new Error(`Typesense upsert into “${collection}” failed for ${failures.length}/${results.length} documents: ${failures
57
- .map((failure) => failure.error)
58
- .join('; ')}`);
59
- }
60
- }