@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.
@@ -5,27 +5,33 @@ import { type RebuildOptions } from './rebuild-support.js';
5
5
  /** {@link InPlaceRebuild} options: the shared rebuild options plus the
6
6
  * membership-sweep ceiling. */
7
7
  export interface InPlaceRebuildOptions extends RebuildOptions {
8
- /** Most distinct sources the membership sweep may enumerate before it throws
9
- * rather than risk missing departed sources (default 10 000). */
8
+ /**
9
+ * Most distinct datasets the membership sweep may enumerate before it throws
10
+ * rather than risk missing departed ones (default 10 000). The sweep needs
11
+ * the complete set to spot which datasets left, so a truncated facet would
12
+ * silently miss deletions. Raise it (up to the engine’s `max_facet_values`
13
+ * limit) before an index approaches it, so the ceiling is a tunable guard
14
+ * rather than a hard wall.
15
+ */
10
16
  readonly maxSweepableSources?: number;
11
17
  }
12
18
  /**
13
19
  * In-place Rebuild (update the live index directly – upsert changed sources,
14
20
  * sweep the rest – rather than swap in a fresh one) as a transactional
15
21
  * `Writer`: documents are upserted into one long-lived collection with
16
- * per-source atomicity – no swap, no staging. Every document is stamped with
17
- * its `source` (the dataset IRI) and `last_seen` (the run id); deletion is a
18
- * sweep, never special-cased:
22
+ * per-dataset atomicity – no swap, no staging. Deletion is a sweep, never
23
+ * special-cased, and {@link openDocuments} decides what a deletion physically
24
+ * is – for a canonically keyed type, dropping one dataset’s membership of a
25
+ * document several datasets share, and the document itself only with the last
26
+ * of them:
19
27
  *
20
- * - a **successful dataset flush** deletes the source’s documents the run did
21
- * not rewrite (`source = dataset && last_seen != runId`). A failed dataset
22
- * is not swept – its output is incomplete, and the next successful run
23
- * reconciles;
24
- * - **reset** (the pipeline’s dump-fallback discard) deletes only *this run’s*
25
- * writes for the dataset (`source = dataset && last_seen = runId`), so the
26
- * dump re-run rebuilds it cleanly while the source’s prior-run documents are
27
- * left for the success sweep to reconcile;
28
- * - **commit** deletes every document whose source left the run’s selection
28
+ * - a **successful dataset flush** drops what the dataset had and this run did
29
+ * not rewrite. A failed dataset is not swept – its output is incomplete, and
30
+ * the next successful run reconciles;
31
+ * - **reset** (the pipeline’s dump-fallback discard) drops only *this run’s*
32
+ * writes for the dataset, so the dump re-run rebuilds it cleanly while the
33
+ * dataset’s prior-run documents are left for the success sweep to reconcile;
34
+ * - **commit** drops what belongs to datasets that left the run’s selection
29
35
  * (registry-membership sweep over {@link RunContext.selectedSources}, which
30
36
  * includes datasets skipped as unchanged) and releases the lock;
31
37
  * - **abort** only releases the lock: upserts are idempotent, so whatever
@@ -33,15 +39,17 @@ export interface InPlaceRebuildOptions extends RebuildOptions {
33
39
  *
34
40
  * `openRun` takes the single-flight cross-pod lock (throwing
35
41
  * `RebuildAlreadyRunning` when another rebuild holds it) and creates
36
- * the collection on demand from the {@link SearchType} plus the two
37
- * bookkeeping fields. A collection that already exists is used as it is, with
38
- * one exception: it must carry every reference field the declaration asks for
39
- * and every facet companion its facets read, or the run fails with the
40
- * drop-and-rebuild that fixes it
41
- * ({@link assertReferencesAndFacetCompanionsPresent}).
42
+ * the collection on demand from the {@link SearchType} plus the bookkeeping
43
+ * fields. A collection that already exists is used as it is, with two
44
+ * exceptions: it must carry every reference field the declaration asks for and
45
+ * every facet companion its facets read
46
+ * ({@link assertReferencesAndFacetCompanionsPresent}), and the membership field
47
+ * a keyed type records in ({@link assertBookkeepingPresent}) – each failing
48
+ * with the rebuild that fixes it.
42
49
  *
43
- * Document ids must be unique per (source, entity) – the caller keys them –
44
- * or documents from different sources overwrite each other.
50
+ * Unless the type is canonically keyed, document ids must be unique per
51
+ * (dataset, entity) – the caller keys them – or documents from different
52
+ * datasets overwrite each other.
45
53
  *
46
54
  * The name is the NDE Stack’s pattern
47
55
  * ({@link https://docs.nde.nl/stack/patterns | Stack patterns}).
@@ -60,20 +68,7 @@ export declare class InPlaceRebuild<TDocument extends {
60
68
  readonly collectionName: string;
61
69
  private readonly maxSweepableSources;
62
70
  private readonly resolved;
63
- /** The column this collection carries its documents’ dataset IRI in: the
64
- * type’s declared dataset field, or the private `source` when it declares
65
- * none. Everything below – the stamp, every sweep filter, the source
66
- * enumeration – reads this one name. */
67
- private readonly sourceField;
68
71
  constructor(client: Client, searchType: SearchType, options?: InPlaceRebuildOptions);
69
72
  openRun(context: RunContext): Promise<RunWriter<TDocument>>;
70
- /**
71
- * The distinct sources present in the collection, via a single facet over its
72
- * {@link provenanceField}. Requests one bucket beyond `maxSources` so genuine truncation is
73
- * distinguishable from an exactly-full result: `maxSources` buckets are
74
- * returned intact, `maxSources + 1` proves more exist and the sweep would
75
- * miss some, so it throws rather than delete blind.
76
- */
77
- private indexedSources;
78
73
  }
79
74
  //# sourceMappingURL=in-place-rebuild.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"in-place-rebuild.d.ts","sourceRoot":"","sources":["../src/in-place-rebuild.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AAExC,OAAO,KAAK,EAAgB,UAAU,EAAE,MAAM,aAAa,CAAC;AAG5D,OAAO,KAAK,EAEV,UAAU,EACV,SAAS,EACT,MAAM,EACP,MAAM,eAAe,CAAC;AAavB,OAAO,EAML,KAAK,cAAc,EAEpB,MAAM,sBAAsB,CAAC;AAa9B;gCACgC;AAChC,MAAM,WAAW,qBAAsB,SAAQ,cAAc;IAC3D;sEACkE;IAClE,QAAQ,CAAC,mBAAmB,CAAC,EAAE,MAAM,CAAC;CACvC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AACH,qBAAa,cAAc,CACzB,SAAS,SAAS;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE,CAChC,YAAW,MAAM,CAAC,SAAS,CAAC;IAiB1B,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,UAAU;IAjB7B;;;;;OAKG;IACH,SAAgB,cAAc,EAAE,MAAM,CAAC;IACvC,OAAO,CAAC,QAAQ,CAAC,mBAAmB,CAAS;IAC7C,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAyB;IAClD;;;6CAGyC;IACzC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAS;gBAGlB,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,UAAU,EACvC,OAAO,GAAE,qBAA0B;IAkB/B,OAAO,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,SAAS,CAAC,SAAS,CAAC,CAAC;IA6HjE;;;;;;OAMG;YACW,cAAc;CAsB7B"}
1
+ {"version":3,"file":"in-place-rebuild.d.ts","sourceRoot":"","sources":["../src/in-place-rebuild.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AAExC,OAAO,KAAK,EAAgB,UAAU,EAAE,MAAM,aAAa,CAAC;AAG5D,OAAO,KAAK,EAEV,UAAU,EACV,SAAS,EACT,MAAM,EACP,MAAM,eAAe,CAAC;AASvB,OAAO,EAEL,KAAK,cAAc,EAEpB,MAAM,sBAAsB,CAAC;AAE9B;gCACgC;AAChC,MAAM,WAAW,qBAAsB,SAAQ,cAAc;IAC3D;;;;;;;OAOG;IACH,QAAQ,CAAC,mBAAmB,CAAC,EAAE,MAAM,CAAC;CACvC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AACH,qBAAa,cAAc,CACzB,SAAS,SAAS;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE,CAChC,YAAW,MAAM,CAAC,SAAS,CAAC;IAY1B,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,UAAU;IAZ7B;;;;;OAKG;IACH,SAAgB,cAAc,EAAE,MAAM,CAAC;IACvC,OAAO,CAAC,QAAQ,CAAC,mBAAmB,CAAqB;IACzD,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAyB;gBAG/B,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,UAAU,EACvC,OAAO,GAAE,qBAA0B;IAe/B,OAAO,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,SAAS,CAAC,SAAS,CAAC,CAAC;CAuElE"}
@@ -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';
package/dist/lookup.d.ts CHANGED
@@ -1,22 +1,31 @@
1
1
  import type { Client } from 'typesense';
2
2
  import { type ReferenceProjection, type RootType, type SearchSchema, type SearchType } from '@lde/search';
3
3
  /**
4
- * One resolved level of a {@link ReferenceProjection}: the referents fetched
5
- * for it, keyed by IRI and still in the engine’s flat physical shape, plus the
6
- * levels resolved *from* them.
4
+ * One resolved level of a {@link ReferenceProjection}. A level is one of two
5
+ * things, discriminated by `via`, because the two cost different things:
6
+ *
7
+ * - **`lookup`** – referents fetched from the target’s own collection, keyed by
8
+ * id and still in the engine’s flat physical shape. One batched round trip.
9
+ * - **`nested`** – an inline reference descended into. Its entries are already
10
+ * in the hit, so nothing is fetched and there is no target; the level exists
11
+ * only to carry the levels resolved *below* it.
7
12
  *
8
13
  * Kept flat rather than reconstructed here so one reconstruction path serves
9
14
  * hits and referents alike – the surface never learns that a nested document
10
15
  * arrived by a second round-trip rather than from the hit itself.
11
16
  */
12
- export interface ResolvedReferents {
13
- /** The Root Type these referents are declared by – reconstruction reads them
14
- * through the target’s own declaration, never the referrer’s. */
17
+ export type ResolvedReferents = {
18
+ readonly via: 'lookup';
19
+ /** The Root Type these referents are declared by – reconstruction reads
20
+ * them through the target’s own declaration, never the referrer’s. */
15
21
  readonly target: RootType;
16
22
  readonly documents: ReadonlyMap<string, Record<string, unknown>>;
17
23
  /** Keyed by the reference field’s name on the type this level carries. */
18
24
  readonly children: ReadonlyMap<string, ResolvedReferents>;
19
- }
25
+ } | {
26
+ readonly via: 'nested';
27
+ readonly children: ReadonlyMap<string, ResolvedReferents>;
28
+ };
20
29
  /**
21
30
  * Resolve a projection against the documents that name its referents: one
22
31
  * batched `multi_search` **per level**, never per document, because the IRIs of
@@ -1 +1 @@
1
- {"version":3,"file":"lookup.d.ts","sourceRoot":"","sources":["../src/lookup.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AACxC,OAAO,EACL,KAAK,mBAAmB,EACxB,KAAK,QAAQ,EACb,KAAK,YAAY,EACjB,KAAK,UAAU,EAChB,MAAM,aAAa,CAAC;AASrB;;;;;;;;GAQG;AACH,MAAM,WAAW,iBAAiB;IAChC;sEACkE;IAClE,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC;IAC1B,QAAQ,CAAC,SAAS,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IACjE,0EAA0E;IAC1E,QAAQ,CAAC,QAAQ,EAAE,WAAW,CAAC,MAAM,EAAE,iBAAiB,CAAC,CAAC;CAC3D;AAKD;;;;;;;;GAQG;AACH,wBAAsB,iBAAiB,CACrC,MAAM,EAAE,IAAI,CAAC,MAAM,EAAE,aAAa,CAAC,EACnC,UAAU,EAAE,mBAAmB,GAAG,SAAS,EAC3C,UAAU,EAAE,UAAU,EACtB,MAAM,EAAE,YAAY,EACpB,WAAW,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,EACxC,OAAO,EAAE,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,EAC3C,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,GACjC,OAAO,CAAC,WAAW,CAAC,MAAM,EAAE,iBAAiB,CAAC,CAAC,CAkEjD"}
1
+ {"version":3,"file":"lookup.d.ts","sourceRoot":"","sources":["../src/lookup.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AACxC,OAAO,EACL,KAAK,mBAAmB,EACxB,KAAK,QAAQ,EACb,KAAK,YAAY,EACjB,KAAK,UAAU,EAChB,MAAM,aAAa,CAAC;AAUrB;;;;;;;;;;;;;GAaG;AACH,MAAM,MAAM,iBAAiB,GACzB;IACE,QAAQ,CAAC,GAAG,EAAE,QAAQ,CAAC;IACvB;2EACuE;IACvE,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC;IAC1B,QAAQ,CAAC,SAAS,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IACjE,0EAA0E;IAC1E,QAAQ,CAAC,QAAQ,EAAE,WAAW,CAAC,MAAM,EAAE,iBAAiB,CAAC,CAAC;CAC3D,GACD;IACE,QAAQ,CAAC,GAAG,EAAE,QAAQ,CAAC;IACvB,QAAQ,CAAC,QAAQ,EAAE,WAAW,CAAC,MAAM,EAAE,iBAAiB,CAAC,CAAC;CAC3D,CAAC;AAKN;;;;;;;;GAQG;AACH,wBAAsB,iBAAiB,CACrC,MAAM,EAAE,IAAI,CAAC,MAAM,EAAE,aAAa,CAAC,EACnC,UAAU,EAAE,mBAAmB,GAAG,SAAS,EAC3C,UAAU,EAAE,UAAU,EACtB,MAAM,EAAE,YAAY,EACpB,WAAW,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,EACxC,OAAO,EAAE,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,EAC3C,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,GACjC,OAAO,CAAC,WAAW,CAAC,MAAM,EAAE,iBAAiB,CAAC,CAAC,CAwFjD"}
package/dist/lookup.js CHANGED
@@ -1,4 +1,4 @@
1
- import { displayFieldName, fieldNamed, labelFieldOf, rootTypeNamed, } from '@lde/search/adapter';
1
+ import { displayFieldName, fieldNamed, labelFieldOf, nestedReferenceType, rootTypeNamed, } from '@lde/search/adapter';
2
2
  import { escapeFilterValue } from './query-compiler.js';
3
3
  /** Typesense caps a filter list; the same batch size the label lookup uses. */
4
4
  const BATCH_SIZE = 200;
@@ -21,9 +21,24 @@ export async function resolveProjection(client, projection, searchType, schema,
21
21
  // stated bound – one round-trip per level – one per field instead.
22
22
  const levels = await Promise.all(Object.entries(projection).map(async ([name, level]) => {
23
23
  const field = fieldNamed(searchType, name);
24
- if (field === undefined ||
25
- field.kind !== 'reference' ||
26
- field.ref?.strategy !== 'lookup') {
24
+ if (field === undefined) {
25
+ return undefined;
26
+ }
27
+ // An inline level costs no round trip: its entries are already in the
28
+ // parents. Flatten them out and hand them down, so the level BELOW reads
29
+ // its ids off the entries rather than off the document – which is the
30
+ // whole of what makes a lookup inside a qualified edge resolvable.
31
+ const nested = nestedReferenceType(schema, field);
32
+ if (nested !== undefined) {
33
+ return [
34
+ name,
35
+ {
36
+ via: 'nested',
37
+ children: await resolveProjection(client, level.resolve, nested, schema, collections, entriesOf(parents, name), onError),
38
+ },
39
+ ];
40
+ }
41
+ if (field.kind !== 'reference' || field.ref?.strategy !== 'lookup') {
27
42
  // `assertValidQuery` rejects this for every caller; skipping keeps a
28
43
  // hand-built query from throwing here rather than at the port’s guard.
29
44
  return undefined;
@@ -44,6 +59,7 @@ export async function resolveProjection(client, projection, searchType, schema,
44
59
  return [
45
60
  name,
46
61
  {
62
+ via: 'lookup',
47
63
  target,
48
64
  documents,
49
65
  // The level below reads the IRIs off the documents this one just
@@ -59,14 +75,40 @@ export async function resolveProjection(client, projection, searchType, schema,
59
75
  }
60
76
  return resolved;
61
77
  }
62
- /** Every distinct IRI a page of documents carries under one reference field. */
78
+ /**
79
+ * The nested entries a page of documents carries under one inline reference,
80
+ * flattened into one list – the “documents” the level below resolves against.
81
+ *
82
+ * Flattening rather than grouping is what keeps the round-trip count per
83
+ * *level* rather than per document: every entry on the page contributes its ids
84
+ * to one batch, and reconstruction re-associates them afterwards by id.
85
+ */
86
+ function entriesOf(documents, field) {
87
+ return documents.flatMap((document) => {
88
+ const raw = document[field];
89
+ return (Array.isArray(raw) ? raw : [raw]).filter((entry) => typeof entry === 'object' && entry !== null);
90
+ });
91
+ }
92
+ /**
93
+ * Every distinct id a page of documents carries under one reference field.
94
+ *
95
+ * A reference stores its ids one of two ways, and which one is about how much
96
+ * of the referent the deployment wants stored, not about identity: a plain
97
+ * reference stores the id itself, while a
98
+ * {@link ReferenceStrategy.local local} lookup stores the endpoint’s own
99
+ * document, whose `id` is the same value. Both are read, so a level resolves
100
+ * the same either way.
101
+ */
63
102
  function distinctIris(documents, field) {
64
103
  const iris = new Set();
65
104
  for (const document of documents) {
66
105
  const raw = document[field];
67
106
  for (const value of Array.isArray(raw) ? raw : [raw]) {
68
- if (typeof value === 'string' && value !== '') {
69
- iris.add(value);
107
+ const id = typeof value === 'object' && value !== null
108
+ ? value.id
109
+ : value;
110
+ if (typeof id === 'string' && id !== '') {
111
+ iris.add(id);
70
112
  }
71
113
  }
72
114
  }
@@ -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,EAEhB,MAAM,aAAa,CAAC;AAarB;mDACmD;AACnD,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,UAAU,EAAE,QAAQ,CAAC;IAC9B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;CAC7B;AAED;;;;GAIG;AACH,MAAM,WAAW,wBAAwB;IACvC;;;;;;;;;;;;;;;;OAgBG;IACH,QAAQ,CAAC,aAAa,CAAC,EAAE,CACvB,IAAI,EAAE,UAAU,EAChB,IAAI,EAAE,SAAS,MAAM,EAAE,KACpB,UAAU,GAAG,SAAS,CAAC;IAC5B;;;;;;OAMG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC;IACjC;;;;;;;;OAQG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,YAAY,CAAC;IAC/B;;;;;;;;;;;OAWG;IACH,QAAQ,CAAC,eAAe,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;CACrD;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,WAAW,EAClB,UAAU,EAAE,UAAU,EACtB,OAAO,GAAE,wBAA6B,GACrC,YAAY,CAAC,MAAM,CAAC,CA8BtB;AA6VD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAEvD"}
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"}