@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.
@@ -0,0 +1,147 @@
1
+ import type { Client, CollectionCreateSchema } from 'typesense';
2
+ import type { CollectionFieldSchema } from 'typesense/lib/Typesense/Collection.js';
3
+ import type { Dataset } from '@lde/dataset';
4
+ import type { SearchSchema, SearchType } from '@lde/search';
5
+ /**
6
+ * Default ceiling on the distinct datasets a collection may hold before the
7
+ * membership sweep refuses to guess. The sweep needs the complete set to spot
8
+ * the ones that left, so a truncated facet would silently miss deletions.
9
+ */
10
+ export declare const DEFAULT_MAX_SWEEPABLE_SOURCES = 10000;
11
+ /** What {@link openDocuments} needs to know about the run it serves. */
12
+ export interface RunDocumentsOptions {
13
+ /** The type whose collection this is; its `key` chooses the regime. */
14
+ readonly searchType: SearchType;
15
+ /** The run’s id, stamped on documents of a single-dataset collection. */
16
+ readonly runId: string;
17
+ /** The run’s ISO 8601 start, the ordered stamp a keyed collection records –
18
+ * see {@link openDocuments}. */
19
+ readonly startedAt: string;
20
+ /** Documents per Typesense import request. */
21
+ readonly batchSize: number;
22
+ /** Most distinct datasets {@link RunDocuments.dropUnselected} may enumerate
23
+ * before it throws rather than risk missing one that left. */
24
+ readonly maxSweepableSources?: number;
25
+ }
26
+ /**
27
+ * One run’s writes into one collection, plus the four questions a writer can
28
+ * ask about what should leave it. Every one is phrased about *datasets*, never
29
+ * about filters or fields: which column carries membership, whether a document
30
+ * is owned or shared, and what a deletion physically is, are this module’s.
31
+ *
32
+ * The one ordering rule a caller must hold: {@link add} buffers, so anything
33
+ * that drops documents needs the buffer landed first. Each `drop` does that
34
+ * itself; a caller only needs {@link flush} before it finishes the run.
35
+ */
36
+ export interface RunDocuments<TDocument extends {
37
+ id: string;
38
+ }> {
39
+ /** Write a dataset’s documents, batched. */
40
+ add(dataset: Dataset, documents: AsyncIterable<TDocument>): Promise<void>;
41
+ /** Land whatever is still buffered. */
42
+ flush(): Promise<void>;
43
+ /**
44
+ * Drop what this dataset had and this run did not rewrite – the sweep after
45
+ * a dataset succeeds. A document another dataset still references survives,
46
+ * having lost only this one’s membership.
47
+ */
48
+ dropUnwritten(dataset: Dataset): Promise<void>;
49
+ /**
50
+ * Drop only what this run wrote for the dataset, keeping its earlier
51
+ * documents – the pipeline’s dump-fallback discard.
52
+ */
53
+ dropWrittenThisRun(dataset: Dataset): Promise<void>;
54
+ /** Drop everything this dataset contributed, whenever it was written. */
55
+ dropAll(dataset: Dataset): Promise<void>;
56
+ /**
57
+ * Drop what belongs to datasets outside the run’s selection: the registry
58
+ * membership sweep. Enumerating what the collection holds is this module’s
59
+ * business, and so is refusing to sweep when it cannot see all of it.
60
+ *
61
+ * @param selectedDatasets Every dataset IRI the run’s selector produced,
62
+ * including datasets skipped as unchanged – selection is membership.
63
+ */
64
+ dropUnselected(selectedDatasets: Iterable<string>): Promise<void>;
65
+ }
66
+ /**
67
+ * Open a run’s documents on a collection.
68
+ *
69
+ * Two regimes, chosen once from the type and never surfaced again:
70
+ *
71
+ * - a type with no {@link RootType.key} keys every document on its own node
72
+ * IRI, so a document belongs to exactly one dataset. It is stamped with that
73
+ * dataset and the run id, and a drop is a delete.
74
+ * - a **canonically keyed** type keys documents on an identifier several
75
+ * datasets can share – two datasets referencing the same GeoNames place
76
+ * produce one document. Membership is therefore a set: a write **adds** the
77
+ * dataset to `referenced_by`, a drop **removes** it, and the document is
78
+ * deleted when the last referring dataset goes. That is what stops one
79
+ * dataset’s sweep from deleting what another still points at, without any
80
+ * special case for a dataset skipped as unchanged – its membership simply
81
+ * stays, untouched by anyone else’s sweep.
82
+ *
83
+ * The run is recorded on a keyed collection as `Date.parse(startedAt)` rather
84
+ * than as the run id, because *this dataset did not write it this run* has to
85
+ * be asked of one element of a nested array, and Typesense correlates a
86
+ * comparison within an element but **not** a negation – `run:<thisRun`
87
+ * correlates, `run:!=thisRun` silently answers at document level and sweeps
88
+ * nothing. See `test/membership-filters.integration.test.ts`, where both are
89
+ * pinned against the engine.
90
+ */
91
+ export declare function openDocuments<TDocument extends {
92
+ id: string;
93
+ }>(client: Client, collection: string, options: RunDocumentsOptions): RunDocuments<TDocument>;
94
+ /**
95
+ * The collection definition plus the bookkeeping a writer keeps on it: the
96
+ * private membership columns, and the nesting a keyed collection’s
97
+ * `referenced_by` needs enabled.
98
+ *
99
+ * A single-dataset collection carries `source` only when the type declares no
100
+ * dataset field of its own; when it does, that field already holds the same
101
+ * IRI ({@link provenanceFieldOf}) and a second column would be the same value
102
+ * twice, free to drift.
103
+ */
104
+ export declare function withBookkeeping(definition: CollectionCreateSchema, searchType: SearchType): CollectionCreateSchema;
105
+ /**
106
+ * Reject a {@link SearchType} whose declaration cannot carry this module’s
107
+ * bookkeeping, at writer construction – so it fails before a run touches the
108
+ * index rather than at the sweep, after the writes.
109
+ *
110
+ * A **canonically keyed** type may not declare a field over the dataset. Such a
111
+ * field asks *which dataset is this document’s*, and a document several
112
+ * datasets share has no answer: it would hold whichever wrote last, and a user
113
+ * filtering by one of the others would silently miss it.
114
+ *
115
+ * A single-dataset type declaring one makes it the collection’s provenance
116
+ * column, so it has to answer that question the way the private `source` did:
117
+ * not `array` (Typesense reads `field:=[a,b]` over a `string[]` as *contains
118
+ * any*, so a departed dataset would take every document merely mentioning it);
119
+ * no `transform` (the sweep matches the stored value against the run’s raw
120
+ * dataset IRIs); and `facetable` on its own name where the writer enumerates
121
+ * the indexed datasets by faceting it – a reference inheriting a facet policy
122
+ * facets a companion holding only the admitted keys, so enumerating through it
123
+ * would miss every dataset the policy excludes.
124
+ *
125
+ * @param requireFacetable Whether this writer enumerates the indexed datasets
126
+ * ({@link RunDocuments.dropUnselected}); a writer that only ever filters by a
127
+ * known IRI does not need the column facetable.
128
+ */
129
+ export declare function assertWritableType(searchType: SearchType, options: {
130
+ readonly requireFacetable: boolean;
131
+ readonly schema?: SearchSchema;
132
+ }): void;
133
+ /**
134
+ * Fail an EXISTING collection that predates its type being canonically keyed,
135
+ * naming the two steps that fix it.
136
+ *
137
+ * The field cannot be added: nesting is a create-time collection setting, and
138
+ * even where it could be it would be **empty** on every document already
139
+ * indexed. Membership is only written by a dataset that reprocesses, and
140
+ * skip-unchanged means most do not – so those documents would match no
141
+ * dataset, never be retracted and never be collected, while looking perfectly
142
+ * healthy. Dropping the collection alone has the same hole: a rebuilt
143
+ * collection is repopulated only by datasets that changed, so the pipeline
144
+ * version has to rotate with it to force every dataset through.
145
+ */
146
+ export declare function assertBookkeepingPresent(searchType: SearchType, existing: readonly CollectionFieldSchema[], collection: string): void;
147
+ //# sourceMappingURL=documents.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"documents.d.ts","sourceRoot":"","sources":["../src/documents.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,MAAM,EAAE,sBAAsB,EAAE,MAAM,WAAW,CAAC;AAChE,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,uCAAuC,CAAC;AACnF,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAC5C,OAAO,KAAK,EAAY,YAAY,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAmCtE;;;;GAIG;AACH,eAAO,MAAM,6BAA6B,QAAS,CAAC;AAEpD,wEAAwE;AACxE,MAAM,WAAW,mBAAmB;IAClC,uEAAuE;IACvE,QAAQ,CAAC,UAAU,EAAE,UAAU,CAAC;IAChC,yEAAyE;IACzE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB;qCACiC;IACjC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,8CAA8C;IAC9C,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B;mEAC+D;IAC/D,QAAQ,CAAC,mBAAmB,CAAC,EAAE,MAAM,CAAC;CACvC;AAED;;;;;;;;;GASG;AACH,MAAM,WAAW,YAAY,CAAC,SAAS,SAAS;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE;IAC5D,4CAA4C;IAC5C,GAAG,CAAC,OAAO,EAAE,OAAO,EAAE,SAAS,EAAE,aAAa,CAAC,SAAS,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1E,uCAAuC;IACvC,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACvB;;;;OAIG;IACH,aAAa,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/C;;;OAGG;IACH,kBAAkB,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpD,yEAAyE;IACzE,OAAO,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACzC;;;;;;;OAOG;IACH,cAAc,CAAC,gBAAgB,EAAE,QAAQ,CAAC,MAAM,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACnE;AAED;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,aAAa,CAAC,SAAS,SAAS;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE,EAC5D,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE,mBAAmB,GAC3B,YAAY,CAAC,SAAS,CAAC,CAIzB;AAED;;;;;;;;;GASG;AACH,wBAAgB,eAAe,CAC7B,UAAU,EAAE,sBAAsB,EAClC,UAAU,EAAE,UAAU,GACrB,sBAAsB,CA4BxB;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,kBAAkB,CAChC,UAAU,EAAE,UAAU,EACtB,OAAO,EAAE;IACP,QAAQ,CAAC,gBAAgB,EAAE,OAAO,CAAC;IACnC,QAAQ,CAAC,MAAM,CAAC,EAAE,YAAY,CAAC;CAChC,GACA,IAAI,CAoDN;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,wBAAwB,CACtC,UAAU,EAAE,UAAU,EACtB,QAAQ,EAAE,SAAS,qBAAqB,EAAE,EAC1C,UAAU,EAAE,MAAM,GACjB,IAAI,CAWN"}
@@ -0,0 +1,529 @@
1
+ // The documents a run writes into one collection, and every decision about
2
+ // which of them leave it. Owns the private bookkeeping columns, the batching,
3
+ // and the two regimes a collection can be in – a document belongs to one
4
+ // dataset, or several reference it – so a writer states *what* should go and
5
+ // never *how* it is stored.
6
+ import { datasetField, isInternalField, physicalFields, } from '@lde/search/adapter';
7
+ import { escapeFilterValue } from './query-compiler.js';
8
+ /** The private field carrying the dataset IRI a document came from, in a
9
+ * collection whose documents each belong to one dataset. */
10
+ const SOURCE_FIELD = 'source';
11
+ /** The private field carrying the id of the run that last wrote a document,
12
+ * beside {@link SOURCE_FIELD}. */
13
+ const LAST_SEEN_FIELD = 'last_seen';
14
+ /** The private field carrying the datasets that reference a document, in a
15
+ * collection keyed on a canonical identifier: one `{ dataset, run }` element
16
+ * per referring dataset. */
17
+ const REFERENCED_BY_FIELD = 'referenced_by';
18
+ /** Documents read back per round-trip while retracting a dataset – the unit of
19
+ * work the sweep’s memory is bounded by, and Typesense’s `per_page` ceiling. */
20
+ const RETRACTION_PAGE = 250;
21
+ /** Ids per `multi_search` entry when reading membership back before a write. */
22
+ const LOOKUP_BATCH = 200;
23
+ /** Entries per `multi_search` request: the engine’s own default ceiling
24
+ * (`max-searches-in-multi-search`), which a large `batchSize` would exceed. */
25
+ const MAX_SEARCHES_PER_REQUEST = 50;
26
+ /** Stay well under Typesense’s ~4000-char URL query-string limit per delete. */
27
+ const MAX_FILTER_VALUES_LENGTH = 3000;
28
+ /**
29
+ * Default ceiling on the distinct datasets a collection may hold before the
30
+ * membership sweep refuses to guess. The sweep needs the complete set to spot
31
+ * the ones that left, so a truncated facet would silently miss deletions.
32
+ */
33
+ export const DEFAULT_MAX_SWEEPABLE_SOURCES = 10_000;
34
+ /**
35
+ * Open a run’s documents on a collection.
36
+ *
37
+ * Two regimes, chosen once from the type and never surfaced again:
38
+ *
39
+ * - a type with no {@link RootType.key} keys every document on its own node
40
+ * IRI, so a document belongs to exactly one dataset. It is stamped with that
41
+ * dataset and the run id, and a drop is a delete.
42
+ * - a **canonically keyed** type keys documents on an identifier several
43
+ * datasets can share – two datasets referencing the same GeoNames place
44
+ * produce one document. Membership is therefore a set: a write **adds** the
45
+ * dataset to `referenced_by`, a drop **removes** it, and the document is
46
+ * deleted when the last referring dataset goes. That is what stops one
47
+ * dataset’s sweep from deleting what another still points at, without any
48
+ * special case for a dataset skipped as unchanged – its membership simply
49
+ * stays, untouched by anyone else’s sweep.
50
+ *
51
+ * The run is recorded on a keyed collection as `Date.parse(startedAt)` rather
52
+ * than as the run id, because *this dataset did not write it this run* has to
53
+ * be asked of one element of a nested array, and Typesense correlates a
54
+ * comparison within an element but **not** a negation – `run:<thisRun`
55
+ * correlates, `run:!=thisRun` silently answers at document level and sweeps
56
+ * nothing. See `test/membership-filters.integration.test.ts`, where both are
57
+ * pinned against the engine.
58
+ */
59
+ export function openDocuments(client, collection, options) {
60
+ return keyOf(options.searchType) === undefined
61
+ ? ownedDocuments(client, collection, options)
62
+ : sharedDocuments(client, collection, options);
63
+ }
64
+ /**
65
+ * The collection definition plus the bookkeeping a writer keeps on it: the
66
+ * private membership columns, and the nesting a keyed collection’s
67
+ * `referenced_by` needs enabled.
68
+ *
69
+ * A single-dataset collection carries `source` only when the type declares no
70
+ * dataset field of its own; when it does, that field already holds the same
71
+ * IRI ({@link provenanceFieldOf}) and a second column would be the same value
72
+ * twice, free to drift.
73
+ */
74
+ export function withBookkeeping(definition, searchType) {
75
+ const fields = [...(definition.fields ?? [])];
76
+ if (keyOf(searchType) !== undefined) {
77
+ return {
78
+ ...definition,
79
+ enable_nested_fields: true,
80
+ fields: [
81
+ ...fields,
82
+ { name: REFERENCED_BY_FIELD, type: 'object[]' },
83
+ {
84
+ name: `${REFERENCED_BY_FIELD}.dataset`,
85
+ type: 'string[]',
86
+ facet: true,
87
+ },
88
+ { name: `${REFERENCED_BY_FIELD}.run`, type: 'int64[]' },
89
+ ],
90
+ };
91
+ }
92
+ return {
93
+ ...definition,
94
+ fields: [
95
+ ...fields,
96
+ ...(provenanceFieldOf(searchType) === SOURCE_FIELD
97
+ ? [{ name: SOURCE_FIELD, type: 'string', facet: true }]
98
+ : []),
99
+ { name: LAST_SEEN_FIELD, type: 'string' },
100
+ ],
101
+ };
102
+ }
103
+ /**
104
+ * Reject a {@link SearchType} whose declaration cannot carry this module’s
105
+ * bookkeeping, at writer construction – so it fails before a run touches the
106
+ * index rather than at the sweep, after the writes.
107
+ *
108
+ * A **canonically keyed** type may not declare a field over the dataset. Such a
109
+ * field asks *which dataset is this document’s*, and a document several
110
+ * datasets share has no answer: it would hold whichever wrote last, and a user
111
+ * filtering by one of the others would silently miss it.
112
+ *
113
+ * A single-dataset type declaring one makes it the collection’s provenance
114
+ * column, so it has to answer that question the way the private `source` did:
115
+ * not `array` (Typesense reads `field:=[a,b]` over a `string[]` as *contains
116
+ * any*, so a departed dataset would take every document merely mentioning it);
117
+ * no `transform` (the sweep matches the stored value against the run’s raw
118
+ * dataset IRIs); and `facetable` on its own name where the writer enumerates
119
+ * the indexed datasets by faceting it – a reference inheriting a facet policy
120
+ * facets a companion holding only the admitted keys, so enumerating through it
121
+ * would miss every dataset the policy excludes.
122
+ *
123
+ * @param requireFacetable Whether this writer enumerates the indexed datasets
124
+ * ({@link RunDocuments.dropUnselected}); a writer that only ever filters by a
125
+ * known IRI does not need the column facetable.
126
+ */
127
+ export function assertWritableType(searchType, options) {
128
+ const keyed = keyOf(searchType) !== undefined;
129
+ const reserved = keyed
130
+ ? [REFERENCED_BY_FIELD]
131
+ : [SOURCE_FIELD, LAST_SEEN_FIELD];
132
+ const clashing = searchType.fields.filter((field) => reserved.includes(field.name));
133
+ if (clashing.length > 0) {
134
+ throw new Error(`SearchType “${searchType.name}” declares reserved bookkeeping field(s) ${clashing
135
+ .map((field) => `“${field.name}”`)
136
+ .join(', ')}`);
137
+ }
138
+ const declared = datasetField(searchType);
139
+ if (declared === undefined || isInternalField(declared)) {
140
+ return;
141
+ }
142
+ if (keyed) {
143
+ throw new Error(`SearchType “${searchType.name}” is keyed on “${searchType.key?.field}”, so several datasets can share one document, and “${declared.name}” over the indexed dataset would hold whichever of them wrote it last – a filter on any of the others would then miss the document. Drop the field: a keyed collection records every dataset that references a document.`);
144
+ }
145
+ const problems = [];
146
+ if (declared.array === true) {
147
+ problems.push('it is an array, and a membership sweep over one would delete every document merely mentioning a departed dataset');
148
+ }
149
+ if (declared.transform !== undefined) {
150
+ problems.push('it declares a transform, and the sweep matches the stored value against the run’s raw dataset IRIs');
151
+ }
152
+ if (options.requireFacetable && declared.facetable !== true) {
153
+ problems.push('it is not facetable, and the membership sweep enumerates the indexed datasets by faceting it');
154
+ }
155
+ else if (options.requireFacetable &&
156
+ physicalFields(declared, options.schema).facet !== declared.name) {
157
+ 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');
158
+ }
159
+ if (problems.length > 0) {
160
+ throw new Error(`SearchType “${searchType.name}” declares “${declared.name}” over the indexed dataset, which this writer keeps its provenance bookkeeping on, but ${problems.join('; and ')}.`);
161
+ }
162
+ }
163
+ /**
164
+ * Fail an EXISTING collection that predates its type being canonically keyed,
165
+ * naming the two steps that fix it.
166
+ *
167
+ * The field cannot be added: nesting is a create-time collection setting, and
168
+ * even where it could be it would be **empty** on every document already
169
+ * indexed. Membership is only written by a dataset that reprocesses, and
170
+ * skip-unchanged means most do not – so those documents would match no
171
+ * dataset, never be retracted and never be collected, while looking perfectly
172
+ * healthy. Dropping the collection alone has the same hole: a rebuilt
173
+ * collection is repopulated only by datasets that changed, so the pipeline
174
+ * version has to rotate with it to force every dataset through.
175
+ */
176
+ export function assertBookkeepingPresent(searchType, existing, collection) {
177
+ if (keyOf(searchType) === undefined) {
178
+ return;
179
+ }
180
+ const names = new Set(existing.map((field) => field.name));
181
+ if (names.has(`${REFERENCED_BY_FIELD}.dataset`)) {
182
+ return;
183
+ }
184
+ throw new Error(`Typesense collection “${collection}” exists without the “${REFERENCED_BY_FIELD}” field its keyed SearchType “${searchType.name}” records membership in, so nothing in it can be swept. It cannot be added to a live collection, and would be empty on every document already indexed: drop “${collection}” (and the collections that join to it) and rotate the pipeline version, so the next run reprocesses every dataset and rebuilds them.`);
185
+ }
186
+ /** The dataset column a single-dataset collection carries: the type’s declared
187
+ * dataset field, or the private `source` when it declares none. */
188
+ function provenanceFieldOf(searchType) {
189
+ const declared = datasetField(searchType);
190
+ return declared === undefined || isInternalField(declared)
191
+ ? SOURCE_FIELD
192
+ : declared.name;
193
+ }
194
+ /** A type’s {@link RootType.key}, for the types that can have one. */
195
+ function keyOf(searchType) {
196
+ return searchType.key;
197
+ }
198
+ /**
199
+ * A collection whose documents each belong to one dataset: stamped with it and
200
+ * the run, dropped by deleting them. What every collection did before keying
201
+ * existed, and still the regime for every type that declares no `key`.
202
+ */
203
+ function ownedDocuments(client, collection, options) {
204
+ const { searchType, runId, batchSize } = options;
205
+ const field = provenanceFieldOf(searchType);
206
+ const buffer = new Batch(batchSize);
207
+ const run = escapeFilterValue(runId);
208
+ const of = (dataset) => `${field}:=${escapeFilterValue(dataset.iri.toString())}`;
209
+ const land = async () => {
210
+ const batch = buffer.take();
211
+ if (batch.length > 0) {
212
+ await importDocuments(client, collection, batch, 'upsert');
213
+ }
214
+ };
215
+ const drop = async (filter) => {
216
+ await land();
217
+ await deleteByFilter(client, collection, filter);
218
+ };
219
+ return {
220
+ // Stamping the dataset column here – rather than trusting the projection to
221
+ // have filled a declared one – is what keeps the sweep total: a caller
222
+ // projecting without a dataset context, or a document reaching the writer
223
+ // any other way, still lands sweepable. Where the projection did fill it,
224
+ // the value is the same IRI, so the stamp only reasserts it.
225
+ //
226
+ // It reasserts the sweep’s column only, never the physical fanout a
227
+ // declared field also produces (the folded `${name}_search` companion of a
228
+ // searchable one). Folding is the projection’s convention, and restating it
229
+ // here would put it in two places. So a document that reached the writer
230
+ // unprojected is sweepable but not full-text findable by dataset: absent,
231
+ // never wrong.
232
+ add: async (dataset, documents) => {
233
+ const stamp = {
234
+ [field]: dataset.iri.toString(),
235
+ [LAST_SEEN_FIELD]: runId,
236
+ };
237
+ for await (const document of documents) {
238
+ buffer.push({ ...document, ...stamp });
239
+ if (buffer.full()) {
240
+ await land();
241
+ }
242
+ }
243
+ },
244
+ flush: land,
245
+ dropUnwritten: async (dataset) => drop(`${of(dataset)} && ${LAST_SEEN_FIELD}:!=${run}`),
246
+ dropWrittenThisRun: async (dataset) => drop(`${of(dataset)} && ${LAST_SEEN_FIELD}:=${run}`),
247
+ dropAll: async (dataset) => drop(of(dataset)),
248
+ dropUnselected: async (selectedDatasets) => {
249
+ await land();
250
+ const departed = await departedDatasets(client, collection, field, selectedDatasets, options.maxSweepableSources ?? DEFAULT_MAX_SWEEPABLE_SOURCES);
251
+ for (const chunk of chunked(departed.map(escapeFilterValue))) {
252
+ await deleteByFilter(client, collection, `${field}:=[${chunk.join(',')}]`);
253
+ }
254
+ },
255
+ };
256
+ }
257
+ /**
258
+ * A collection keyed on a canonical identifier, where one document can be
259
+ * reached from several datasets. Membership is the set of datasets referencing
260
+ * it; a drop retracts one dataset’s entry and deletes only what nobody is left
261
+ * referencing.
262
+ */
263
+ function sharedDocuments(client, collection, options) {
264
+ const { batchSize } = options;
265
+ const run = Date.parse(options.startedAt);
266
+ const facet = `${REFERENCED_BY_FIELD}.dataset`;
267
+ const buffer = new Batch(batchSize);
268
+ const referencing = (datasets) => `${facet}:=[${datasets.map(escapeFilterValue).join(',')}]`;
269
+ /**
270
+ * Land a batch, adding each document’s dataset to the membership already
271
+ * stored. Typesense replaces a whole document on upsert and has no way to
272
+ * append to an array, so the set has to be read back first – one
273
+ * `multi_search` per batch, against the ids the batch carries.
274
+ */
275
+ const land = async () => {
276
+ const batch = buffer.take();
277
+ if (batch.length === 0) {
278
+ return;
279
+ }
280
+ // Two nodes of one dataset, or two datasets, can key onto one document
281
+ // within a single batch. Later content wins, as a merged document always
282
+ // does – but membership is the union, or the second would erase the first.
283
+ const merged = new Map();
284
+ for (const { document, dataset } of batch) {
285
+ const seen = merged.get(document.id);
286
+ merged.set(document.id, {
287
+ document,
288
+ datasets: seen === undefined
289
+ ? [dataset]
290
+ : seen.datasets.includes(dataset)
291
+ ? seen.datasets
292
+ : [...seen.datasets, dataset],
293
+ });
294
+ }
295
+ const stored = await storedMembership(client, collection, [
296
+ ...merged.keys(),
297
+ ]);
298
+ await importDocuments(client, collection, [...merged.values()].map(({ document, datasets }) => ({
299
+ ...document,
300
+ [REFERENCED_BY_FIELD]: [
301
+ ...(stored.get(document.id) ?? []).filter((referrer) => !datasets.includes(referrer.dataset)),
302
+ ...datasets.map((dataset) => ({ dataset, run })),
303
+ ],
304
+ })), 'upsert');
305
+ };
306
+ /**
307
+ * Take the datasets out of every document the filter selects, deleting the
308
+ * documents left with no referrer.
309
+ *
310
+ * The filter is re-asked from the first page each round rather than
311
+ * paginated: a document this pass touches stops matching it, so the loop
312
+ * drains, its memory is one page whatever the collection holds, and deep
313
+ * pagination never comes into it. A round that changes nothing would spin,
314
+ * so it throws instead.
315
+ */
316
+ const retract = async (filter, datasets) => {
317
+ await land();
318
+ for (;;) {
319
+ const page = await search(client, collection, {
320
+ filter_by: filter,
321
+ include_fields: `id,${REFERENCED_BY_FIELD}`,
322
+ per_page: RETRACTION_PAGE,
323
+ });
324
+ const hits = (page.hits ?? []).map((hit) => hit.document);
325
+ if (hits.length === 0) {
326
+ return;
327
+ }
328
+ const keeping = [];
329
+ const emptied = [];
330
+ for (const document of hits) {
331
+ const referrers = referrersOf(document);
332
+ const remaining = referrers.filter((referrer) => !datasets.includes(referrer.dataset));
333
+ if (remaining.length === referrers.length) {
334
+ // Selected, yet carries none of the datasets being retracted: writing
335
+ // it back would change nothing and it would match again next round.
336
+ continue;
337
+ }
338
+ if (remaining.length === 0) {
339
+ emptied.push(String(document.id));
340
+ }
341
+ else {
342
+ keeping.push({
343
+ id: document.id,
344
+ [REFERENCED_BY_FIELD]: remaining,
345
+ });
346
+ }
347
+ }
348
+ if (keeping.length === 0 && emptied.length === 0) {
349
+ throw new Error(`Retracting ${datasets.join(', ')} from “${collection}” selected ${hits.length} document(s) carrying none of them, so the sweep cannot make progress.`);
350
+ }
351
+ if (keeping.length > 0) {
352
+ await importDocuments(client, collection, keeping, 'update');
353
+ }
354
+ for (const chunk of chunked(emptied.map(escapeFilterValue))) {
355
+ await deleteByFilter(client, collection, `id:=[${chunk.join(',')}]`);
356
+ }
357
+ }
358
+ };
359
+ return {
360
+ add: async (dataset, documents) => {
361
+ const iri = dataset.iri.toString();
362
+ for await (const document of documents) {
363
+ buffer.push({ document, dataset: iri });
364
+ if (buffer.full()) {
365
+ await land();
366
+ }
367
+ }
368
+ },
369
+ flush: land,
370
+ dropUnwritten: async (dataset) => {
371
+ const iri = dataset.iri.toString();
372
+ await retract(`${REFERENCED_BY_FIELD}.{dataset:=${escapeFilterValue(iri)} && run:<${run}}`, [iri]);
373
+ },
374
+ dropWrittenThisRun: async (dataset) => {
375
+ const iri = dataset.iri.toString();
376
+ await retract(`${REFERENCED_BY_FIELD}.{dataset:=${escapeFilterValue(iri)} && run:=${run}}`, [iri]);
377
+ },
378
+ dropAll: async (dataset) => {
379
+ const iri = dataset.iri.toString();
380
+ await retract(referencing([iri]), [iri]);
381
+ },
382
+ dropUnselected: async (selectedDatasets) => {
383
+ await land();
384
+ const departed = await departedDatasets(client, collection, facet, selectedDatasets, options.maxSweepableSources ?? DEFAULT_MAX_SWEEPABLE_SOURCES);
385
+ // Every departed dataset at once: one document can be referenced by
386
+ // several of them, and retracting them together spares it a second pass.
387
+ for (const chunk of chunked(departed.map(escapeFilterValue))) {
388
+ await retract(`${facet}:=[${chunk.join(',')}]`, departed);
389
+ }
390
+ },
391
+ };
392
+ }
393
+ /** The membership stored for these ids, read back before a write adds to it. */
394
+ async function storedMembership(client, collection, ids) {
395
+ const searches = [];
396
+ for (let start = 0; start < ids.length; start += LOOKUP_BATCH) {
397
+ const batch = ids.slice(start, start + LOOKUP_BATCH);
398
+ searches.push({
399
+ collection,
400
+ q: '*',
401
+ filter_by: `id:[${batch.map(escapeFilterValue).join(',')}]`,
402
+ include_fields: `id,${REFERENCED_BY_FIELD}`,
403
+ per_page: batch.length,
404
+ });
405
+ }
406
+ const membership = new Map();
407
+ // A batch wide enough to need more entries than the engine accepts per
408
+ // request travels as several requests: `batchSize` is the caller's knob, and
409
+ // it must not decide whether a write succeeds.
410
+ for (let start = 0; start < searches.length; start += MAX_SEARCHES_PER_REQUEST) {
411
+ const { results } = (await client.multiSearch.perform({
412
+ searches: searches.slice(start, start + MAX_SEARCHES_PER_REQUEST),
413
+ }));
414
+ for (const result of results) {
415
+ // multi_search reports a failed entry inline instead of rejecting. A lost
416
+ // entry would silently drop the membership of everything it covered, so
417
+ // it is raised rather than skipped.
418
+ if (result.error !== undefined) {
419
+ throw new Error(`Reading membership from “${collection}” failed: ${result.error}`);
420
+ }
421
+ for (const hit of result.hits ?? []) {
422
+ membership.set(String(hit.document.id), referrersOf(hit.document));
423
+ }
424
+ }
425
+ }
426
+ return membership;
427
+ }
428
+ /** The referrers stored on a document, ignoring anything malformed. */
429
+ function referrersOf(document) {
430
+ const stored = document[REFERENCED_BY_FIELD];
431
+ return (Array.isArray(stored) ? stored : []).filter((referrer) => typeof referrer === 'object' &&
432
+ referrer !== null &&
433
+ typeof referrer.dataset === 'string');
434
+ }
435
+ /**
436
+ * The datasets a collection holds that the run’s selection does not, via a
437
+ * single facet over its membership column.
438
+ *
439
+ * Asks for one bucket beyond the ceiling so genuine truncation is
440
+ * distinguishable from an exactly-full result: `maxDatasets` buckets are
441
+ * returned intact, one more proves the sweep would miss some, so it throws
442
+ * rather than delete blind.
443
+ */
444
+ async function departedDatasets(client, collection, facetField, selectedDatasets, maxDatasets) {
445
+ const response = await search(client, collection, {
446
+ per_page: 0,
447
+ facet_by: facetField,
448
+ max_facet_values: maxDatasets + 1,
449
+ });
450
+ const counts = response.facet_counts?.[0]?.counts ?? [];
451
+ if (counts.length > maxDatasets) {
452
+ throw new Error(`Membership sweep cannot see beyond ${maxDatasets} distinct datasets in “${collection}”; raise maxSweepableSources or departed datasets might be missed`);
453
+ }
454
+ const selected = new Set(selectedDatasets);
455
+ return counts
456
+ .map((count) => count.value)
457
+ .filter((dataset) => !selected.has(dataset));
458
+ }
459
+ /** Escaped values grouped into filters that fit a URL query string. */
460
+ function* chunked(values) {
461
+ let chunk = [];
462
+ let length = 0;
463
+ for (const value of values) {
464
+ if (length + value.length > MAX_FILTER_VALUES_LENGTH && chunk.length > 0) {
465
+ yield chunk;
466
+ chunk = [];
467
+ length = 0;
468
+ }
469
+ chunk.push(value);
470
+ length += value.length + 1;
471
+ }
472
+ if (chunk.length > 0) {
473
+ yield chunk;
474
+ }
475
+ }
476
+ /** Accumulates items until a batch is full. */
477
+ class Batch {
478
+ size;
479
+ items = [];
480
+ constructor(size) {
481
+ this.size = size;
482
+ }
483
+ push(item) {
484
+ this.items.push(item);
485
+ }
486
+ full() {
487
+ return this.items.length >= this.size;
488
+ }
489
+ /** The accumulated items, emptying the batch. */
490
+ take() {
491
+ const items = this.items;
492
+ this.items = [];
493
+ return items;
494
+ }
495
+ }
496
+ /**
497
+ * Import one batch, throwing if any individual document fails – Typesense’s
498
+ * bulk import otherwise reports per-document failures without rejecting, which
499
+ * would turn a rejected write into silently missing data.
500
+ */
501
+ async function importDocuments(client, collection, batch, action) {
502
+ const results = (await client
503
+ .collections(collection)
504
+ .documents()
505
+ .import(batch, {
506
+ action,
507
+ throwOnFail: false,
508
+ }));
509
+ const failures = results.filter((result) => !result.success);
510
+ if (failures.length > 0) {
511
+ throw new Error(`Typesense ${action} into “${collection}” failed for ${failures.length}/${results.length} documents: ${failures
512
+ .map((failure) => failure.error)
513
+ .join('; ')}`);
514
+ }
515
+ }
516
+ /** A whole-collection search; `q: '*'` needs no field to query by. */
517
+ async function search(client, collection, parameters) {
518
+ return (await client
519
+ .collections(collection)
520
+ .documents()
521
+ .search({ q: '*', ...parameters }));
522
+ }
523
+ /** Delete a collection’s documents matching a Typesense filter. */
524
+ async function deleteByFilter(client, collection, filterBy) {
525
+ await client
526
+ .collections(collection)
527
+ .documents()
528
+ .delete({ filter_by: filterBy });
529
+ }