@lde/search-typesense 0.18.1 → 0.19.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.
@@ -50,6 +50,10 @@ export declare class BlueGreenRebuild<TDocument extends {
50
50
  */
51
51
  readonly collectionName: string;
52
52
  private readonly resolved;
53
+ /** The column this collection carries its documents’ dataset IRI in: the
54
+ * type’s declared dataset field, or the private `source` when it declares
55
+ * none. Both the stamp and the per-dataset rollback filter read it. */
56
+ private readonly sourceField;
53
57
  constructor(client: Client, searchType: SearchType, options?: BlueGreenRebuildOptions);
54
58
  openRun(context: RunContext): Promise<RunWriter<TDocument>>;
55
59
  /** The collection an alias currently points at, or `undefined` if unset. */
@@ -1 +1 @@
1
- {"version":3,"file":"blue-green-rebuild.d.ts","sourceRoot":"","sources":["../src/blue-green-rebuild.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AACxC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAC9C,OAAO,KAAK,EAEV,UAAU,EACV,SAAS,EACT,MAAM,EACP,MAAM,eAAe,CAAC;AAMvB,OAAO,EAKL,KAAK,cAAc,EAEpB,MAAM,sBAAsB,CAAC;AAE9B;;yDAEyD;AACzD,MAAM,MAAM,uBAAuB,GAAG,cAAc,CAAC;AAErD;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,qBAAa,gBAAgB,CAC3B,SAAS,SAAS;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE,CAChC,YAAW,MAAM,CAAC,SAAS,CAAC;IAa1B,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,UAAU;IAb7B;;;;;;;OAOG;IACH,SAAgB,cAAc,EAAE,MAAM,CAAC;IACvC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAyB;gBAG/B,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,UAAU,EACvC,OAAO,GAAE,uBAA4B;IAQjC,OAAO,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,SAAS,CAAC,SAAS,CAAC,CAAC;IA6FjE,4EAA4E;YAC9D,WAAW;CAW1B"}
1
+ {"version":3,"file":"blue-green-rebuild.d.ts","sourceRoot":"","sources":["../src/blue-green-rebuild.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AACxC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAC9C,OAAO,KAAK,EAEV,UAAU,EACV,SAAS,EACT,MAAM,EACP,MAAM,eAAe,CAAC;AAUvB,OAAO,EAML,KAAK,cAAc,EAEpB,MAAM,sBAAsB,CAAC;AAE9B;;yDAEyD;AACzD,MAAM,MAAM,uBAAuB,GAAG,cAAc,CAAC;AAErD;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,qBAAa,gBAAgB,CAC3B,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;;;;;;;OAOG;IACH,SAAgB,cAAc,EAAE,MAAM,CAAC;IACvC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAyB;IAClD;;4EAEwE;IACxE,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAS;gBAGlB,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,UAAU,EACvC,OAAO,GAAE,uBAA4B;IAYjC,OAAO,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,SAAS,CAAC,SAAS,CAAC,CAAC;IAmGjE,4EAA4E;YAC9D,WAAW;CAW1B"}
@@ -1,8 +1,8 @@
1
1
  import { buildCollectionDefinition } from './collection-definition.js';
2
2
  import { BatchImporter } from './import.js';
3
3
  import { httpStatus, openLockedRun, releaseLock } from './lock.js';
4
- import { SOURCE_FIELD, sourceDocumentsFilter } from './sweep.js';
5
- import { assertNoReservedFields, deleteByFilter, resolveRebuildOptions, stampDocuments, } from './rebuild-support.js';
4
+ import { SOURCE_FIELD, provenanceField, sourceDocumentsFilter, } from './sweep.js';
5
+ import { assertNoReservedFields, assertSweepableProvenanceField, deleteByFilter, resolveRebuildOptions, stampDocuments, } from './rebuild-support.js';
6
6
  /**
7
7
  * Blue/green Rebuild (build a fresh index alongside the live one, then swap to
8
8
  * it atomically) as a transactional `Writer`: each run builds the search
@@ -45,17 +45,26 @@ export class BlueGreenRebuild {
45
45
  */
46
46
  collectionName;
47
47
  resolved;
48
+ /** The column this collection carries its documents’ dataset IRI in: the
49
+ * type’s declared dataset field, or the private `source` when it declares
50
+ * none. Both the stamp and the per-dataset rollback filter read it. */
51
+ sourceField;
48
52
  constructor(client, searchType, options = {}) {
49
53
  this.client = client;
50
54
  this.searchType = searchType;
51
55
  // `source` is stamped on every document for per-dataset rollback.
52
56
  assertNoReservedFields(searchType, [SOURCE_FIELD]);
57
+ // Rollback filters by a known dataset IRI rather than enumerating the
58
+ // indexed ones, so a declared field need not be facetable here.
59
+ assertSweepableProvenanceField(searchType, { requireFacetable: false });
60
+ this.sourceField = provenanceField(searchType);
53
61
  this.resolved = resolveRebuildOptions(searchType, options);
54
62
  this.collectionName = this.resolved.definitionOptions.name;
55
63
  }
56
64
  async openRun(context) {
57
65
  const { batchSize, lockTtlMs, definitionOptions } = this.resolved;
58
66
  const name = this.collectionName;
67
+ const sourceField = this.sourceField;
59
68
  return openLockedRun(this.client, name, lockTtlMs, async () => {
60
69
  // Create the fresh (blue) collection up front, so a failure surfaces
61
70
  // before any dataset is processed. startedAt orders the versioned names;
@@ -66,9 +75,14 @@ export class BlueGreenRebuild {
66
75
  await this.client.collections().create({
67
76
  ...definition,
68
77
  name: collection,
78
+ // The private `source` only when the type declares no dataset field of
79
+ // its own; a declared one is already in the definition and carries the
80
+ // same IRI.
69
81
  fields: [
70
82
  ...(definition.fields ?? []),
71
- { name: SOURCE_FIELD, type: 'string' },
83
+ ...(sourceField === SOURCE_FIELD
84
+ ? [{ name: SOURCE_FIELD, type: 'string' }]
85
+ : []),
72
86
  ],
73
87
  });
74
88
  const importer = new BatchImporter(this.client, collection, batchSize);
@@ -77,11 +91,11 @@ export class BlueGreenRebuild {
77
91
  // still buffered must land first, so the delete filter sees them.
78
92
  const rollback = async (dataset) => {
79
93
  await importer.flush();
80
- await deleteByFilter(this.client, collection, sourceDocumentsFilter(dataset.iri.toString()));
94
+ await deleteByFilter(this.client, collection, sourceDocumentsFilter(sourceField, dataset.iri.toString()));
81
95
  };
82
96
  return {
83
97
  write: async (dataset, documents) => importer.add(stampDocuments(documents, {
84
- [SOURCE_FIELD]: dataset.iri.toString(),
98
+ [sourceField]: dataset.iri.toString(),
85
99
  })),
86
100
  flush: async (dataset, outcome) => {
87
101
  // A successful dataset keeps its streamed documents (they go live at
@@ -56,11 +56,16 @@ export declare class InPlaceRebuild<TDocument extends {
56
56
  readonly collectionName: string;
57
57
  private readonly maxSweepableSources;
58
58
  private readonly resolved;
59
+ /** The column this collection carries its documents’ dataset IRI in: the
60
+ * type’s declared dataset field, or the private `source` when it declares
61
+ * none. Everything below – the stamp, every sweep filter, the source
62
+ * enumeration – reads this one name. */
63
+ private readonly sourceField;
59
64
  constructor(client: Client, searchType: SearchType, options?: InPlaceRebuildOptions);
60
65
  openRun(context: RunContext): Promise<RunWriter<TDocument>>;
61
66
  /**
62
- * The distinct sources present in the collection, via a single `source`
63
- * facet. Requests one bucket beyond `maxSources` so genuine truncation is
67
+ * The distinct sources present in the collection, via a single facet over its
68
+ * {@link provenanceField}. Requests one bucket beyond `maxSources` so genuine truncation is
64
69
  * distinguishable from an exactly-full result: `maxSources` buckets are
65
70
  * returned intact, `maxSources + 1` proves more exist and the sweep would
66
71
  * miss some, so it throws rather than delete blind.
@@ -1 +1 @@
1
- {"version":3,"file":"in-place-rebuild.d.ts","sourceRoot":"","sources":["../src/in-place-rebuild.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AAExC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAE9C,OAAO,KAAK,EAEV,UAAU,EACV,SAAS,EACT,MAAM,EACP,MAAM,eAAe,CAAC;AAYvB,OAAO,EAKL,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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;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,CAAS;IAC7C,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAyB;gBAG/B,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,UAAU,EACvC,OAAO,GAAE,qBAA0B;IAY/B,OAAO,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,SAAS,CAAC,SAAS,CAAC,CAAC;IAsFjE;;;;;;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,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAE9C,OAAO,KAAK,EAEV,UAAU,EACV,SAAS,EACT,MAAM,EACP,MAAM,eAAe,CAAC;AAavB,OAAO,EAML,KAAK,cAAc,EAEpB,MAAM,sBAAsB,CAAC;AAa9B;gCACgC;AAChC,MAAM,WAAW,qBAAsB,SAAQ,cAAc;IAC3D;sEACkE;IAClE,QAAQ,CAAC,mBAAmB,CAAC,EAAE,MAAM,CAAC;CACvC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;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;IAc/B,OAAO,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,SAAS,CAAC,SAAS,CAAC,CAAC;IAiHjE;;;;;;OAMG;YACW,cAAc;CAsB7B"}
@@ -1,8 +1,8 @@
1
1
  import { buildCollectionDefinition } from './collection-definition.js';
2
2
  import { BatchImporter } from './import.js';
3
3
  import { ensureCollectionExists, openLockedRun, releaseLock } from './lock.js';
4
- import { LAST_SEEN_FIELD, SOURCE_FIELD, departedSources, membershipSweepFilters, staleDocumentsFilter, thisRunDocumentsFilter, } from './sweep.js';
5
- import { assertNoReservedFields, deleteByFilter, resolveRebuildOptions, stampDocuments, } from './rebuild-support.js';
4
+ import { LAST_SEEN_FIELD, SOURCE_FIELD, departedSources, membershipSweepFilters, provenanceField, staleDocumentsFilter, thisRunDocumentsFilter, } from './sweep.js';
5
+ import { assertNoReservedFields, assertSweepableProvenanceField, deleteByFilter, resolveRebuildOptions, stampDocuments, } from './rebuild-support.js';
6
6
  /**
7
7
  * Default ceiling on the distinct sources the membership sweep enumerates via
8
8
  * a single `source` facet. The sweep needs the complete source set to spot
@@ -58,10 +58,17 @@ export class InPlaceRebuild {
58
58
  collectionName;
59
59
  maxSweepableSources;
60
60
  resolved;
61
+ /** The column this collection carries its documents’ dataset IRI in: the
62
+ * type’s declared dataset field, or the private `source` when it declares
63
+ * none. Everything below – the stamp, every sweep filter, the source
64
+ * enumeration – reads this one name. */
65
+ sourceField;
61
66
  constructor(client, searchType, options = {}) {
62
67
  this.client = client;
63
68
  this.searchType = searchType;
64
69
  assertNoReservedFields(searchType, [SOURCE_FIELD, LAST_SEEN_FIELD]);
70
+ assertSweepableProvenanceField(searchType, { requireFacetable: true });
71
+ this.sourceField = provenanceField(searchType);
65
72
  const { maxSweepableSources = DEFAULT_MAX_SWEEPABLE_SOURCES, ...rebuildOptions } = options;
66
73
  this.maxSweepableSources = maxSweepableSources;
67
74
  this.resolved = resolveRebuildOptions(searchType, rebuildOptions);
@@ -70,14 +77,20 @@ export class InPlaceRebuild {
70
77
  async openRun(context) {
71
78
  const { batchSize, lockTtlMs, definitionOptions } = this.resolved;
72
79
  const name = this.collectionName;
80
+ const sourceField = this.sourceField;
73
81
  return openLockedRun(this.client, name, lockTtlMs, async () => {
74
82
  // Create the collection on demand: SearchType schema + the bookkeeping
75
- // fields, `source` faceted so the membership sweep can enumerate the
76
- // distinct sources.
83
+ // fields. The private `source` – faceted so the membership sweep can
84
+ // enumerate the distinct sources – only when the type declares no dataset
85
+ // field of its own; when it does, that declared field already carries the
86
+ // IRI (and its own `facet: true`), and a second column would be the same
87
+ // value twice, free to drift.
77
88
  await ensureCollectionExists(this.client, name, () => {
78
89
  const definition = buildCollectionDefinition(this.searchType, definitionOptions);
79
90
  const bookkeeping = [
80
- { name: SOURCE_FIELD, type: 'string', facet: true },
91
+ ...(sourceField === SOURCE_FIELD
92
+ ? [{ name: SOURCE_FIELD, type: 'string', facet: true }]
93
+ : []),
81
94
  { name: LAST_SEEN_FIELD, type: 'string' },
82
95
  ];
83
96
  return {
@@ -87,8 +100,21 @@ export class InPlaceRebuild {
87
100
  });
88
101
  const importer = new BatchImporter(this.client, name, batchSize);
89
102
  return {
103
+ // Stamping the provenance field – rather than trusting the projection
104
+ // to have filled a declared one – is what keeps the sweep total: a
105
+ // caller projecting without a dataset context, or a document reaching
106
+ // the writer any other way, still lands in a swept collection. Where
107
+ // the projection did fill it, the value is the same dataset IRI, so the
108
+ // stamp only ever reasserts it.
109
+ //
110
+ // It reasserts the sweep’s column only, never the physical fanout a
111
+ // declared field also produces (the folded `${name}_search` companion
112
+ // of a `searchable` one). Folding is the projection’s convention, and
113
+ // restating it here would put it in two places – the split this change
114
+ // exists to close. So a document that reached the writer unprojected is
115
+ // sweepable but not full-text findable by dataset: absent, never wrong.
90
116
  write: async (dataset, documents) => importer.add(stampDocuments(documents, {
91
- [SOURCE_FIELD]: dataset.iri.toString(),
117
+ [sourceField]: dataset.iri.toString(),
92
118
  [LAST_SEEN_FIELD]: context.runId,
93
119
  })),
94
120
  flush: async (dataset, outcome) => {
@@ -101,19 +127,19 @@ export class InPlaceRebuild {
101
127
  // stale ones for the next successful run to reconcile.
102
128
  return;
103
129
  }
104
- await deleteByFilter(this.client, name, staleDocumentsFilter(dataset.iri.toString(), context.runId));
130
+ await deleteByFilter(this.client, name, staleDocumentsFilter(sourceField, dataset.iri.toString(), context.runId));
105
131
  },
106
132
  reset: async (dataset) => {
107
133
  // Discard only this run’s partial writes for the source (the failed
108
134
  // endpoint attempt) so the dump re-run rebuilds it cleanly; the
109
135
  // source’s prior-run documents stay for the success sweep.
110
136
  await importer.flush();
111
- await deleteByFilter(this.client, name, thisRunDocumentsFilter(dataset.iri.toString(), context.runId));
137
+ await deleteByFilter(this.client, name, thisRunDocumentsFilter(sourceField, dataset.iri.toString(), context.runId));
112
138
  },
113
139
  commit: async () => {
114
140
  await importer.flush();
115
141
  const departed = departedSources(await this.indexedSources(name, this.maxSweepableSources), context.selectedSources());
116
- for (const filter of membershipSweepFilters(departed)) {
142
+ for (const filter of membershipSweepFilters(sourceField, departed)) {
117
143
  await deleteByFilter(this.client, name, filter);
118
144
  }
119
145
  await releaseLock(this.client, name);
@@ -125,8 +151,8 @@ export class InPlaceRebuild {
125
151
  });
126
152
  }
127
153
  /**
128
- * The distinct sources present in the collection, via a single `source`
129
- * facet. Requests one bucket beyond `maxSources` so genuine truncation is
154
+ * The distinct sources present in the collection, via a single facet over its
155
+ * {@link provenanceField}. Requests one bucket beyond `maxSources` so genuine truncation is
130
156
  * distinguishable from an exactly-full result: `maxSources` buckets are
131
157
  * returned intact, `maxSources + 1` proves more exist and the sweep would
132
158
  * miss some, so it throws rather than delete blind.
@@ -137,9 +163,9 @@ export class InPlaceRebuild {
137
163
  .documents()
138
164
  .search({
139
165
  q: '*',
140
- query_by: SOURCE_FIELD,
166
+ query_by: this.sourceField,
141
167
  per_page: 0,
142
- facet_by: SOURCE_FIELD,
168
+ facet_by: this.sourceField,
143
169
  max_facet_values: maxSources + 1,
144
170
  });
145
171
  const counts = response.facet_counts?.[0]?.counts ?? [];
@@ -43,6 +43,29 @@ export declare function resolveRebuildOptions(searchType: SearchType, options: R
43
43
  * `last_seen`.
44
44
  */
45
45
  export declare function assertNoReservedFields(searchType: SearchType, reserved: readonly string[]): void;
46
+ /**
47
+ * Reject a declared dataset field the provenance bookkeeping cannot be kept on.
48
+ * A type declaring one makes it the collection’s provenance field – the column
49
+ * the membership sweep enumerates and deletes by – so the declaration has to answer
50
+ * *which single dataset is this document’s* the way the private `source` field
51
+ * did:
52
+ *
53
+ * - **not `array`**: Typesense reads `field:=[a,b]` over a `string[]` as
54
+ * *contains any*, so a departed source would take every document that merely
55
+ * mentions it – including entities another selected dataset still
56
+ * contributes;
57
+ * - **no `transform`**: the sweep compares the stored value against the run’s
58
+ * selection, which carries raw dataset IRIs, so a transformed value would
59
+ * match nothing and the sweep would silently stop deleting;
60
+ * - **`facetable`** where the writer enumerates the indexed sources by faceting
61
+ * it (the In-place writer does; Blue/green only ever filters by a known IRI).
62
+ *
63
+ * Thrown at writer construction, so a declaration that cannot be swept fails
64
+ * before a run touches the index rather than at the sweep, after the writes.
65
+ */
66
+ export declare function assertSweepableProvenanceField(searchType: SearchType, options: {
67
+ readonly requireFacetable: boolean;
68
+ }): void;
46
69
  /** Stamp each document with fixed bookkeeping fields as it streams past. */
47
70
  export declare function stampDocuments<TDocument>(documents: AsyncIterable<TDocument>, stamp: Readonly<Record<string, string>>): AsyncIterable<TDocument & Record<string, string>>;
48
71
  /** Delete a collection’s documents matching a Typesense filter. */
@@ -1 +1 @@
1
- {"version":3,"file":"rebuild-support.d.ts","sourceRoot":"","sources":["../src/rebuild-support.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AACxC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAC9C,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;;6DAEyD;IACzD,QAAQ,CAAC,iBAAiB,EAAE,2BAA2B,GAAG;QACxD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;KACvB,CAAC;CACH;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,qBAAqB,CACnC,UAAU,EAAE,UAAU,EACtB,OAAO,EAAE,cAAc,GACtB,sBAAsB,CAgBxB;AAED;;;;GAIG;AACH,wBAAgB,sBAAsB,CACpC,UAAU,EAAE,UAAU,EACtB,QAAQ,EAAE,SAAS,MAAM,EAAE,GAC1B,IAAI,CAWN;AAED,4EAA4E;AAC5E,wBAAuB,cAAc,CAAC,SAAS,EAC7C,SAAS,EAAE,aAAa,CAAC,SAAS,CAAC,EACnC,KAAK,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,GACtC,aAAa,CAAC,SAAS,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAInD;AAED,mEAAmE;AACnE,wBAAsB,cAAc,CAClC,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,IAAI,CAAC,CAKf"}
1
+ {"version":3,"file":"rebuild-support.d.ts","sourceRoot":"","sources":["../src/rebuild-support.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AACxC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAE9C,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;;6DAEyD;IACzD,QAAQ,CAAC,iBAAiB,EAAE,2BAA2B,GAAG;QACxD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;KACvB,CAAC;CACH;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,qBAAqB,CACnC,UAAU,EAAE,UAAU,EACtB,OAAO,EAAE,cAAc,GACtB,sBAAsB,CAgBxB;AAED;;;;GAIG;AACH,wBAAgB,sBAAsB,CACpC,UAAU,EAAE,UAAU,EACtB,QAAQ,EAAE,SAAS,MAAM,EAAE,GAC1B,IAAI,CAWN;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,8BAA8B,CAC5C,UAAU,EAAE,UAAU,EACtB,OAAO,EAAE;IAAE,QAAQ,CAAC,gBAAgB,EAAE,OAAO,CAAA;CAAE,GAC9C,IAAI,CA0BN;AAED,4EAA4E;AAC5E,wBAAuB,cAAc,CAAC,SAAS,EAC7C,SAAS,EAAE,aAAa,CAAC,SAAS,CAAC,EACnC,KAAK,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,GACtC,aAAa,CAAC,SAAS,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAInD;AAED,mEAAmE;AACnE,wBAAsB,cAAc,CAClC,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,IAAI,CAAC,CAKf"}
@@ -1,3 +1,4 @@
1
+ import { datasetField, isInternalField } from '@lde/search/adapter';
1
2
  import { buildCollectionDefinition, } from './collection-definition.js';
2
3
  import { deriveCollectionName } from './collection-name.js';
3
4
  import { DEFAULT_LOCK_TTL_MS } from './lock.js';
@@ -41,6 +42,45 @@ export function assertNoReservedFields(searchType, reserved) {
41
42
  .join(', ')}`);
42
43
  }
43
44
  }
45
+ /**
46
+ * Reject a declared dataset field the provenance bookkeeping cannot be kept on.
47
+ * A type declaring one makes it the collection’s provenance field – the column
48
+ * the membership sweep enumerates and deletes by – so the declaration has to answer
49
+ * *which single dataset is this document’s* the way the private `source` field
50
+ * did:
51
+ *
52
+ * - **not `array`**: Typesense reads `field:=[a,b]` over a `string[]` as
53
+ * *contains any*, so a departed source would take every document that merely
54
+ * mentions it – including entities another selected dataset still
55
+ * contributes;
56
+ * - **no `transform`**: the sweep compares the stored value against the run’s
57
+ * selection, which carries raw dataset IRIs, so a transformed value would
58
+ * match nothing and the sweep would silently stop deleting;
59
+ * - **`facetable`** where the writer enumerates the indexed sources by faceting
60
+ * it (the In-place writer does; Blue/green only ever filters by a known IRI).
61
+ *
62
+ * Thrown at writer construction, so a declaration that cannot be swept fails
63
+ * before a run touches the index rather than at the sweep, after the writes.
64
+ */
65
+ export function assertSweepableProvenanceField(searchType, options) {
66
+ const field = datasetField(searchType);
67
+ if (field === undefined || isInternalField(field)) {
68
+ return;
69
+ }
70
+ const problems = [];
71
+ if (field.array === true) {
72
+ problems.push('it is an array, and a membership sweep over one would delete every document merely mentioning a departed dataset');
73
+ }
74
+ if (field.transform !== undefined) {
75
+ problems.push('it declares a transform, and the sweep matches the stored value against the run’s raw dataset IRIs');
76
+ }
77
+ if (options.requireFacetable && field.facetable !== true) {
78
+ problems.push('it is not facetable, and the membership sweep enumerates the indexed datasets by faceting it');
79
+ }
80
+ if (problems.length > 0) {
81
+ 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 ')}.`);
82
+ }
83
+ }
44
84
  /** Stamp each document with fixed bookkeeping fields as it streams past. */
45
85
  export async function* stampDocuments(documents, stamp) {
46
86
  for await (const document of documents) {
package/dist/sweep.d.ts CHANGED
@@ -1,7 +1,23 @@
1
- /** The document field carrying the dataset IRI a document came from. */
1
+ import type { SearchType } from '@lde/search';
2
+ /** The private field carrying the dataset IRI a document came from, for a type
3
+ * that declares none of its own – see {@link provenanceField}. */
2
4
  export declare const SOURCE_FIELD = "source";
3
5
  /** The document field carrying the id of the run that last wrote a document. */
4
6
  export declare const LAST_SEEN_FIELD = "last_seen";
7
+ /**
8
+ * The field a collection carries its documents’ dataset IRI in – the one the
9
+ * membership sweep enumerates, filters and deletes by.
10
+ *
11
+ * A type that declares a field over the dataset
12
+ * ({@link datasetField `from: 'dataset'`}) *is* its provenance: the writer keeps
13
+ * its bookkeeping on that field rather than stamping a private one beside it,
14
+ * so the facet, the label resolution, any `derive` and the sweep all read one
15
+ * column that cannot drift. A type declaring nothing – or declaring the dataset
16
+ * only as an *internal* field, which the projection prunes before the writer
17
+ * ever sees it – falls back to the private {@link SOURCE_FIELD}, and behaves
18
+ * exactly as it always has.
19
+ */
20
+ export declare function provenanceField(searchType: SearchType): string;
5
21
  /**
6
22
  * Sources whose documents must leave the index: indexed, but no longer part
7
23
  * of the run’s selection. Selection is membership, not processing – a dataset
@@ -15,18 +31,20 @@ export declare function departedSources(indexedSources: Iterable<string>, select
15
31
  * Typesense filter matching a source’s documents that this run did not touch:
16
32
  * everything the source no longer contains, ready for a per-source sweep.
17
33
  *
18
- * @param sourceIri The dataset IRI stamped on the documents as `source`
34
+ * @param sourceField The collection’s {@link provenanceField}
35
+ * @param sourceIri The dataset IRI the documents carry in that field
19
36
  * @param runId The current run; documents it wrote carry it as `last_seen`
20
37
  */
21
- export declare function staleDocumentsFilter(sourceIri: string, runId: string): string;
38
+ export declare function staleDocumentsFilter(sourceField: string, sourceIri: string, runId: string): string;
22
39
  /**
23
40
  * Typesense filter matching all of a source’s documents, ready to drop a whole
24
41
  * source (a departed source’s membership sweep, or a Blue/green writer rolling
25
42
  * a failed dataset out of its not-yet-live collection).
26
43
  *
27
- * @param sourceIri The dataset IRI stamped on the documents as `source`
44
+ * @param sourceField The collection’s {@link provenanceField}
45
+ * @param sourceIri The dataset IRI the documents carry in that field
28
46
  */
29
- export declare function sourceDocumentsFilter(sourceIri: string): string;
47
+ export declare function sourceDocumentsFilter(sourceField: string, sourceIri: string): string;
30
48
  /**
31
49
  * Typesense filter matching the documents **this run** wrote for a source: the
32
50
  * inverse of {@link staleDocumentsFilter}. An In-place writer deletes these to
@@ -34,17 +52,19 @@ export declare function sourceDocumentsFilter(sourceIri: string): string;
34
52
  * touching its prior-run documents, which the success sweep or a failed run
35
53
  * still owns.
36
54
  *
37
- * @param sourceIri The dataset IRI stamped on the documents as `source`
55
+ * @param sourceField The collection’s {@link provenanceField}
56
+ * @param sourceIri The dataset IRI the documents carry in that field
38
57
  * @param runId The current run, stamped on its documents as `last_seen`
39
58
  */
40
- export declare function thisRunDocumentsFilter(sourceIri: string, runId: string): string;
59
+ export declare function thisRunDocumentsFilter(sourceField: string, sourceIri: string, runId: string): string;
41
60
  /**
42
61
  * Typesense filters deleting every departed source’s documents, combined into
43
62
  * as few filters as fit: deletes travel in the URL query string, so each
44
63
  * filter stays under a conservative length budget rather than listing every
45
64
  * source in one string.
46
65
  *
66
+ * @param sourceField The collection’s {@link provenanceField}
47
67
  * @param departed The departed source IRIs ({@link departedSources})
48
68
  */
49
- export declare function membershipSweepFilters(departed: readonly string[]): string[];
69
+ export declare function membershipSweepFilters(sourceField: string, departed: readonly string[]): string[];
50
70
  //# sourceMappingURL=sweep.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"sweep.d.ts","sourceRoot":"","sources":["../src/sweep.ts"],"names":[],"mappings":"AAQA,wEAAwE;AACxE,eAAO,MAAM,YAAY,WAAW,CAAC;AAErC,gFAAgF;AAChF,eAAO,MAAM,eAAe,cAAc,CAAC;AAE3C;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAC7B,cAAc,EAAE,QAAQ,CAAC,MAAM,CAAC,EAChC,eAAe,EAAE,QAAQ,CAAC,MAAM,CAAC,GAChC,MAAM,EAAE,CAGV;AAED;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAE7E;AAED;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAE/D;AAED;;;;;;;;;GASG;AACH,wBAAgB,sBAAsB,CACpC,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,MAAM,GACZ,MAAM,CAER;AAED;;;;;;;GAOG;AACH,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,EAAE,CAqB5E"}
1
+ {"version":3,"file":"sweep.d.ts","sourceRoot":"","sources":["../src/sweep.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAG9C;mEACmE;AACnE,eAAO,MAAM,YAAY,WAAW,CAAC;AAErC,gFAAgF;AAChF,eAAO,MAAM,eAAe,cAAc,CAAC;AAE3C;;;;;;;;;;;;GAYG;AACH,wBAAgB,eAAe,CAAC,UAAU,EAAE,UAAU,GAAG,MAAM,CAK9D;AAED;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAC7B,cAAc,EAAE,QAAQ,CAAC,MAAM,CAAC,EAChC,eAAe,EAAE,QAAQ,CAAC,MAAM,CAAC,GAChC,MAAM,EAAE,CAGV;AAED;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAClC,WAAW,EAAE,MAAM,EACnB,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,MAAM,GACZ,MAAM,CAER;AAED;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CACnC,WAAW,EAAE,MAAM,EACnB,SAAS,EAAE,MAAM,GAChB,MAAM,CAER;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,sBAAsB,CACpC,WAAW,EAAE,MAAM,EACnB,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,MAAM,GACZ,MAAM,CAER;AAED;;;;;;;;GAQG;AACH,wBAAgB,sBAAsB,CACpC,WAAW,EAAE,MAAM,EACnB,QAAQ,EAAE,SAAS,MAAM,EAAE,GAC1B,MAAM,EAAE,CAqBV"}
package/dist/sweep.js CHANGED
@@ -1,13 +1,35 @@
1
1
  // Pure deletion planning shared by the rebuild writers: which documents leave
2
2
  // a collection, expressed as source sets and Typesense filter strings. Kept
3
3
  // free of the Typesense client so the logic is unit-testable. This module owns
4
- // the bookkeeping field names, so stamping and every filter that reads them
5
- // can never disagree.
4
+ // the private bookkeeping field names and resolves which field a collection
5
+ // carries its provenance in, so stamping and every filter that reads it can
6
+ // never disagree.
7
+ import { datasetField, isInternalField } from '@lde/search/adapter';
6
8
  import { escapeFilterValue } from './query-compiler.js';
7
- /** The document field carrying the dataset IRI a document came from. */
9
+ /** The private field carrying the dataset IRI a document came from, for a type
10
+ * that declares none of its own – see {@link provenanceField}. */
8
11
  export const SOURCE_FIELD = 'source';
9
12
  /** The document field carrying the id of the run that last wrote a document. */
10
13
  export const LAST_SEEN_FIELD = 'last_seen';
14
+ /**
15
+ * The field a collection carries its documents’ dataset IRI in – the one the
16
+ * membership sweep enumerates, filters and deletes by.
17
+ *
18
+ * A type that declares a field over the dataset
19
+ * ({@link datasetField `from: 'dataset'`}) *is* its provenance: the writer keeps
20
+ * its bookkeeping on that field rather than stamping a private one beside it,
21
+ * so the facet, the label resolution, any `derive` and the sweep all read one
22
+ * column that cannot drift. A type declaring nothing – or declaring the dataset
23
+ * only as an *internal* field, which the projection prunes before the writer
24
+ * ever sees it – falls back to the private {@link SOURCE_FIELD}, and behaves
25
+ * exactly as it always has.
26
+ */
27
+ export function provenanceField(searchType) {
28
+ const declared = datasetField(searchType);
29
+ return declared === undefined || isInternalField(declared)
30
+ ? SOURCE_FIELD
31
+ : declared.name;
32
+ }
11
33
  /**
12
34
  * Sources whose documents must leave the index: indexed, but no longer part
13
35
  * of the run’s selection. Selection is membership, not processing – a dataset
@@ -24,21 +46,23 @@ export function departedSources(indexedSources, selectedSources) {
24
46
  * Typesense filter matching a source’s documents that this run did not touch:
25
47
  * everything the source no longer contains, ready for a per-source sweep.
26
48
  *
27
- * @param sourceIri The dataset IRI stamped on the documents as `source`
49
+ * @param sourceField The collection’s {@link provenanceField}
50
+ * @param sourceIri The dataset IRI the documents carry in that field
28
51
  * @param runId The current run; documents it wrote carry it as `last_seen`
29
52
  */
30
- export function staleDocumentsFilter(sourceIri, runId) {
31
- return `${sourceDocumentsFilter(sourceIri)} && ${LAST_SEEN_FIELD}:!=${escapeFilterValue(runId)}`;
53
+ export function staleDocumentsFilter(sourceField, sourceIri, runId) {
54
+ return `${sourceDocumentsFilter(sourceField, sourceIri)} && ${LAST_SEEN_FIELD}:!=${escapeFilterValue(runId)}`;
32
55
  }
33
56
  /**
34
57
  * Typesense filter matching all of a source’s documents, ready to drop a whole
35
58
  * source (a departed source’s membership sweep, or a Blue/green writer rolling
36
59
  * a failed dataset out of its not-yet-live collection).
37
60
  *
38
- * @param sourceIri The dataset IRI stamped on the documents as `source`
61
+ * @param sourceField The collection’s {@link provenanceField}
62
+ * @param sourceIri The dataset IRI the documents carry in that field
39
63
  */
40
- export function sourceDocumentsFilter(sourceIri) {
41
- return `${SOURCE_FIELD}:=${escapeFilterValue(sourceIri)}`;
64
+ export function sourceDocumentsFilter(sourceField, sourceIri) {
65
+ return `${sourceField}:=${escapeFilterValue(sourceIri)}`;
42
66
  }
43
67
  /**
44
68
  * Typesense filter matching the documents **this run** wrote for a source: the
@@ -47,11 +71,12 @@ export function sourceDocumentsFilter(sourceIri) {
47
71
  * touching its prior-run documents, which the success sweep or a failed run
48
72
  * still owns.
49
73
  *
50
- * @param sourceIri The dataset IRI stamped on the documents as `source`
74
+ * @param sourceField The collection’s {@link provenanceField}
75
+ * @param sourceIri The dataset IRI the documents carry in that field
51
76
  * @param runId The current run, stamped on its documents as `last_seen`
52
77
  */
53
- export function thisRunDocumentsFilter(sourceIri, runId) {
54
- return `${sourceDocumentsFilter(sourceIri)} && ${LAST_SEEN_FIELD}:=${escapeFilterValue(runId)}`;
78
+ export function thisRunDocumentsFilter(sourceField, sourceIri, runId) {
79
+ return `${sourceDocumentsFilter(sourceField, sourceIri)} && ${LAST_SEEN_FIELD}:=${escapeFilterValue(runId)}`;
55
80
  }
56
81
  /**
57
82
  * Typesense filters deleting every departed source’s documents, combined into
@@ -59,15 +84,16 @@ export function thisRunDocumentsFilter(sourceIri, runId) {
59
84
  * filter stays under a conservative length budget rather than listing every
60
85
  * source in one string.
61
86
  *
87
+ * @param sourceField The collection’s {@link provenanceField}
62
88
  * @param departed The departed source IRIs ({@link departedSources})
63
89
  */
64
- export function membershipSweepFilters(departed) {
90
+ export function membershipSweepFilters(sourceField, departed) {
65
91
  const filters = [];
66
92
  let chunk = [];
67
93
  let chunkLength = 0;
68
94
  const flush = () => {
69
95
  if (chunk.length > 0) {
70
- filters.push(`${SOURCE_FIELD}:=[${chunk.join(',')}]`);
96
+ filters.push(`${sourceField}:=[${chunk.join(',')}]`);
71
97
  chunk = [];
72
98
  chunkLength = 0;
73
99
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lde/search-typesense",
3
- "version": "0.18.1",
3
+ "version": "0.19.0",
4
4
  "description": "Typesense implementation of the @lde/search SearchEngine port: collection-schema builder, query compiler, label-resolving result reconstruction, and blue/green index lifecycle. Engine-specific (Typesense) but domain-agnostic.",
5
5
  "repository": {
6
6
  "url": "git+https://github.com/ldelements/lde.git",
@@ -25,7 +25,7 @@
25
25
  "!**/*.tsbuildinfo"
26
26
  ],
27
27
  "dependencies": {
28
- "@lde/search": "^0.15.0",
28
+ "@lde/search": "^0.16.0",
29
29
  "@lde/text-normalization": "^0.1.2",
30
30
  "tslib": "^2.3.0",
31
31
  "typesense": "^3.0.6"
@@ -35,6 +35,6 @@
35
35
  },
36
36
  "peerDependencies": {
37
37
  "@lde/dataset": "^0.7.9",
38
- "@lde/pipeline": "^0.35.5"
38
+ "@lde/pipeline": "^0.35.6"
39
39
  }
40
40
  }