@lde/search-typesense 0.3.0 → 0.4.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.
package/README.md CHANGED
@@ -6,7 +6,8 @@ domain-agnostic** – you supply a `SearchType`; this package never names your
6
6
  domain. It is the Typesense implementation of the `SearchEngine` port: it derives
7
7
  a collection schema from the field model, compiles the neutral `SearchQuery` into
8
8
  Typesense search params, runs it, reconstructs the engine-neutral `SearchResult`,
9
- and manages the search index lifecycle (blue/green rebuild).
9
+ and manages the search index lifecycle as transactional `@lde/pipeline`
10
+ writers (Blue/green Rebuild and In-place Rebuild).
10
11
 
11
12
  ## Collection schema and engine
12
13
 
@@ -39,56 +40,100 @@ direct use and testing.
39
40
 
40
41
  ## Indexing
41
42
 
42
- `rebuild` blue/green-rebuilds a search index in one call, straight from the
43
- declaration: it derives the collection schema from your `SearchType` (via
44
- `buildCollectionSchema`), creates a fresh versioned collection
45
- (`${name}_<timestamp>`), streams the documents into it in batches, atomically
46
- repoints the `name` alias to it, then drops the collection it superseded. The
47
- caller passes the `SearchType`, the logical index `name` and a stream of
48
- documents; the versioned collection and the alias are managed for them.
43
+ Indexing runs through two transactional writers, one per update mode – the
44
+ [NDE Stack](https://docs.nde.nl/stack/patterns) patterns of the same names:
45
+
46
+ - [**Blue/green Rebuild**](#bluegreen-rebuild: build a fresh index, then swap to it atomically;
47
+ - [**In-place Rebuild**](#in-place-rebuild): update the live index directly by
48
+ upserting changed sources and sweeping the rest.
49
+
50
+ Both implement `@lde/pipeline`’s `Writer` – each run is
51
+ `openRun(context)` → `write` per dataset → `commit()` or `abort(error)` – so
52
+ an `@lde/pipeline` `Pipeline` drives them without branching on the mode. Both
53
+ derive the collection schema from your `SearchType` (via
54
+ `buildCollectionSchema`), and their options accept everything
55
+ `buildCollectionSchema` does (`defaultLocale`, `defaultSortingField`,
56
+ `synonymSets`) plus the tuning knobs (`batchSize`, `lockTtlMs`).
57
+
58
+ ### Blue/green Rebuild
59
+
60
+ `BlueGreenRebuild` rebuilds the index from zero and goes live atomically:
61
+ `openRun` creates a fresh versioned collection (`${name}_<timestamp>`),
62
+ `write` streams documents into it in batches (each stamped with its `source`
63
+ dataset IRI), and `commit` atomically repoints the `name` alias and drops the
64
+ collection it superseded. Until commit, the live alias never points at a
65
+ partial build; `abort` drops the half-built collection. Deletion is implicit –
66
+ whatever a run does not write does not exist in the new collection. A dataset
67
+ that fails (or is reset before a dump re-run) is rolled back out of the
68
+ not-yet-live collection by `source`, so the swap never ships a half-processed
69
+ dataset. Right-sized for small collections (e.g. one document per dataset
70
+ description).
49
71
 
50
72
  ```ts
51
73
  import { Client } from 'typesense';
52
- import { rebuild } from '@lde/search-typesense';
74
+ import { BlueGreenRebuild } from '@lde/search-typesense';
53
75
 
54
76
  const client = new Client({
55
77
  nodes: [{ host, port, protocol: 'https' }],
56
78
  apiKey,
57
79
  });
58
80
 
59
- // `documents` is an async iterable (e.g. a streaming projection); only one
60
- // batch is held in memory at a time. `rebuild` returns the live collection name
61
- // and the imported count (or `null` if another rebuild was already running).
62
- const result = await rebuild(client, documents, DATASET, { name: 'datasets' });
81
+ const writer = new BlueGreenRebuild(client, DATASET, { name: 'datasets' });
82
+ // Standalone use; under @lde/pipeline the Pipeline drives this lifecycle.
83
+ const run = await writer.openRun(context);
84
+ await run.write(dataset, documents);
85
+ await run.commit();
63
86
  ```
64
87
 
65
- The options accept everything `buildCollectionSchema` does (`defaultLocale`,
66
- `defaultSortingField`, `synonymSets`) plus the rebuild knobs (`batchSize`,
67
- `lockTtlMs`).
88
+ ### In-place Rebuild
68
89
 
69
- `rebuild` takes a `Client` the caller owns (and reuses for queries), so this
90
+ `InPlaceRebuild` maintains one long-lived collection with per-source
91
+ atomicity – no swap, no staging – for large, mostly-static corpora (e.g.
92
+ millions of objects across many datasets, where a daily run touches only the
93
+ changed ones). Every document is stamped with its `source` (the dataset IRI)
94
+ and `last_seen` (the run id); deletion is a sweep, never special-cased:
95
+
96
+ - a successful dataset flush deletes the source’s documents the run did not
97
+ rewrite (`source = dataset && last_seen != runId`); a failed dataset is not
98
+ swept – its output is incomplete – and the next successful run reconciles;
99
+ - `commit` deletes every document whose source left the run’s selection (the
100
+ registry-membership sweep over `RunContext.selectedSources()`, which
101
+ includes datasets skipped as unchanged);
102
+ - `abort` only releases the lock: upserts are idempotent, so whatever landed
103
+ stays until the next run reconciles.
104
+
105
+ Document ids must be unique per (source, entity) – the caller keys them.
106
+
107
+ Both writers take a `Client` the caller owns (and reuses for queries), so this
70
108
  package adds no connection or document type of its own – any object with an `id`
71
109
  is a valid document, including the `SearchDocument`s `@lde/search` produces.
72
110
 
73
111
  ## Concurrency
74
112
 
75
- `rebuild` is **single-flight per index**: it first takes a lock (a marker
113
+ Rebuilds are **single-flight per index**: `openRun` takes a lock (a marker
76
114
  document in a `rebuild_locks` collection, created on demand) via Typesense’s
77
- atomic create, so concurrent callers across pods never rebuild the same index at
78
- once. A call made while another rebuild for that index is in flight returns
79
- `null` instead of a count. This keeps blue/green safe under replication: without
80
- it, two same-millisecond rebuilds would collide on the versioned collection name
81
- and one would delete the other’s in-flight build.
115
+ atomic create, so concurrent runs across pods never rebuild the same index at
116
+ once – a run opened while another holds the lock throws
117
+ `RebuildAlreadyRunning` (catch it to treat a concurrent rebuild as a graceful
118
+ skip). This keeps blue/green safe under replication: without it, two
119
+ same-millisecond rebuilds would collide on the versioned collection name and
120
+ one would delete the other’s in-flight build.
82
121
 
83
122
  Limitations to design around:
84
123
 
85
124
  - **Advisory, not a strict mutex.** The lock is built on Typesense, not a
86
125
  consensus store. Under a TTL-reclaim race two rebuilds can briefly run at
87
126
  once; this is safe because blue/green is idempotent (worst case: redundant
88
- work and a transient orphaned collection).
89
- - **Single-flight, not coalescing.** A call skipped with `null` is _not_ queued.
90
- If you must capture state that changed mid-build, re-trigger after the running
91
- rebuild finishes.
127
+ work and a transient orphaned collection) and in-place upserts are
128
+ idempotent.
129
+ - **Single-flight, not coalescing.** A run refused with `RebuildAlreadyRunning`
130
+ is _not_ queued. If you must capture state that changed mid-build,
131
+ re-trigger after the running rebuild finishes.
92
132
  - **Lock TTL.** A rebuild running longer than `lockTtlMs` (default 10 minutes)
93
133
  can be reclaimed by another caller and run concurrently; size the TTL above
94
134
  your longest rebuild.
135
+ - **Membership-sweep cap.** The in-place membership sweep enumerates distinct
136
+ sources via a single facet, capped at `maxSweepableSources` (default
137
+ 10 000); beyond that the commit throws rather than sweeping blind. Raise the
138
+ option (up to the engine’s `max_facet_values` limit) before an index nears
139
+ the cap so it stays a tunable guard rather than a hard wall.
@@ -0,0 +1,46 @@
1
+ import type { Client } from 'typesense';
2
+ import type { SearchType } from '@lde/search';
3
+ import type { RunContext, RunWriter, Writer } from '@lde/pipeline';
4
+ import { type RebuildOptions } from './rebuild-support.js';
5
+ /** {@link BlueGreenRebuild} options: the collection-schema options (`name` is
6
+ * the logical index name the alias is kept on) plus the rebuild tuning knobs. */
7
+ export type BlueGreenRebuildOptions = RebuildOptions;
8
+ /**
9
+ * Blue/green Rebuild (build a fresh index alongside the live one, then swap to
10
+ * it atomically) as a transactional `Writer`: each run builds the search
11
+ * index `options.name` from zero in a fresh versioned collection and goes
12
+ * live atomically on commit. Deletion is implicit – whatever the run does not
13
+ * write does not exist in the new collection. The name is the NDE Stack’s
14
+ * pattern ({@link https://docs.nde.nl/stack/patterns | Stack patterns}).
15
+ *
16
+ * - `openRun` takes the single-flight cross-pod lock ({@link openLockedRun},
17
+ * throwing `RebuildAlreadyRunning` when another rebuild holds it) and
18
+ * creates the versioned collection (`${name}_<timestamp>`) with the schema
19
+ * derived from the {@link SearchType}.
20
+ * - `write` streams documents into the fresh collection, batched across write
21
+ * calls, each stamped with its `source` (the dataset IRI).
22
+ * - `flush` on a **failed** dataset, and `reset` (the pipeline’s dump-fallback
23
+ * discard), roll that dataset’s documents back out of the not-yet-live
24
+ * collection by `source`, so a swap never ships a half-processed dataset;
25
+ * a successful flush leaves the streamed documents in place.
26
+ * - `commit` imports the remainder, atomically repoints the `name` alias to
27
+ * the new collection, drops the collection it superseded, and releases the
28
+ * lock. Until commit, the live alias never points at a partial build.
29
+ * - `abort` drops the half-built collection and releases the lock; the live
30
+ * index is untouched.
31
+ *
32
+ * The caller passes only the logical index `name`; the versioned collection
33
+ * name and the alias are managed here.
34
+ */
35
+ export declare class BlueGreenRebuild<TDocument extends {
36
+ id: string;
37
+ }> implements Writer<TDocument> {
38
+ private readonly client;
39
+ private readonly searchType;
40
+ private readonly options;
41
+ constructor(client: Client, searchType: SearchType, options: BlueGreenRebuildOptions);
42
+ openRun(context: RunContext): Promise<RunWriter<TDocument>>;
43
+ /** The collection an alias currently points at, or `undefined` if unset. */
44
+ private aliasTarget;
45
+ }
46
+ //# sourceMappingURL=blue-green-rebuild.d.ts.map
@@ -0,0 +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,EACpB,MAAM,sBAAsB,CAAC;AAE9B;kFACkF;AAClF,MAAM,MAAM,uBAAuB,GAAG,cAAc,CAAC;AAErD;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,qBAAa,gBAAgB,CAC3B,SAAS,SAAS;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE,CAChC,YAAW,MAAM,CAAC,SAAS,CAAC;IAE1B,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,UAAU;IAC3B,OAAO,CAAC,QAAQ,CAAC,OAAO;gBAFP,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,UAAU,EACtB,OAAO,EAAE,uBAAuB;IAM7C,OAAO,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,SAAS,CAAC,SAAS,CAAC,CAAC;IAoFjE,4EAA4E;YAC9D,WAAW;CAW1B"}
@@ -0,0 +1,119 @@
1
+ import { buildCollectionSchema } from './collection-schema.js';
2
+ import { BatchImporter } from './import.js';
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';
6
+ /**
7
+ * Blue/green Rebuild (build a fresh index alongside the live one, then swap to
8
+ * it atomically) as a transactional `Writer`: each run builds the search
9
+ * index `options.name` from zero in a fresh versioned collection and goes
10
+ * live atomically on commit. Deletion is implicit – whatever the run does not
11
+ * write does not exist in the new collection. The name is the NDE Stack’s
12
+ * pattern ({@link https://docs.nde.nl/stack/patterns | Stack patterns}).
13
+ *
14
+ * - `openRun` takes the single-flight cross-pod lock ({@link openLockedRun},
15
+ * throwing `RebuildAlreadyRunning` when another rebuild holds it) and
16
+ * creates the versioned collection (`${name}_<timestamp>`) with the schema
17
+ * derived from the {@link SearchType}.
18
+ * - `write` streams documents into the fresh collection, batched across write
19
+ * calls, each stamped with its `source` (the dataset IRI).
20
+ * - `flush` on a **failed** dataset, and `reset` (the pipeline’s dump-fallback
21
+ * discard), roll that dataset’s documents back out of the not-yet-live
22
+ * collection by `source`, so a swap never ships a half-processed dataset;
23
+ * a successful flush leaves the streamed documents in place.
24
+ * - `commit` imports the remainder, atomically repoints the `name` alias to
25
+ * the new collection, drops the collection it superseded, and releases the
26
+ * lock. Until commit, the live alias never points at a partial build.
27
+ * - `abort` drops the half-built collection and releases the lock; the live
28
+ * index is untouched.
29
+ *
30
+ * The caller passes only the logical index `name`; the versioned collection
31
+ * name and the alias are managed here.
32
+ */
33
+ export class BlueGreenRebuild {
34
+ client;
35
+ searchType;
36
+ options;
37
+ constructor(client, searchType, options) {
38
+ this.client = client;
39
+ this.searchType = searchType;
40
+ this.options = options;
41
+ // `source` is stamped on every document for per-dataset rollback.
42
+ assertNoReservedFields(searchType, [SOURCE_FIELD]);
43
+ }
44
+ async openRun(context) {
45
+ const { name, batchSize, lockTtlMs, schemaOptions } = resolveRebuildOptions(this.options);
46
+ return openLockedRun(this.client, name, lockTtlMs, async () => {
47
+ // Create the fresh (blue) collection up front, so a failure surfaces
48
+ // before any dataset is processed. startedAt orders the versioned names;
49
+ // concurrent same-name runs are excluded by the lock.
50
+ const collection = `${name}_${Date.parse(context.startedAt)}`;
51
+ const previous = await this.aliasTarget(name);
52
+ const schema = buildCollectionSchema(this.searchType, schemaOptions);
53
+ await this.client.collections().create({
54
+ ...schema,
55
+ name: collection,
56
+ fields: [
57
+ ...(schema.fields ?? []),
58
+ { name: SOURCE_FIELD, type: 'string' },
59
+ ],
60
+ });
61
+ const importer = new BatchImporter(this.client, collection, batchSize);
62
+ // Drop a dataset’s streamed documents back out of the not-yet-live
63
+ // collection (a failed dataset, or a reset before the dump re-run). Any
64
+ // still buffered must land first, so the delete filter sees them.
65
+ const rollback = async (dataset) => {
66
+ await importer.flush();
67
+ await deleteByFilter(this.client, collection, sourceDocumentsFilter(dataset.iri.toString()));
68
+ };
69
+ return {
70
+ write: async (dataset, documents) => importer.add(stampDocuments(documents, {
71
+ [SOURCE_FIELD]: dataset.iri.toString(),
72
+ })),
73
+ flush: async (dataset, outcome) => {
74
+ // A successful dataset keeps its streamed documents (they go live at
75
+ // the swap); a failed one is rolled back so the swap never ships it.
76
+ if (outcome !== 'success') {
77
+ await rollback(dataset);
78
+ }
79
+ },
80
+ reset: async (dataset) => rollback(dataset),
81
+ commit: async () => {
82
+ await importer.flush();
83
+ await this.client
84
+ .aliases()
85
+ .upsert(name, { collection_name: collection });
86
+ if (previous !== undefined && previous !== collection) {
87
+ await this.client
88
+ .collections(previous)
89
+ .delete()
90
+ .catch(() => undefined);
91
+ }
92
+ await releaseLock(this.client, name);
93
+ },
94
+ abort: async () => {
95
+ // The live alias is untouched; just drop the orphaned half-built
96
+ // collection rather than let it accumulate.
97
+ await this.client
98
+ .collections(collection)
99
+ .delete()
100
+ .catch(() => undefined);
101
+ await releaseLock(this.client, name);
102
+ },
103
+ };
104
+ });
105
+ }
106
+ /** The collection an alias currently points at, or `undefined` if unset. */
107
+ async aliasTarget(alias) {
108
+ try {
109
+ const { collection_name } = await this.client.aliases(alias).retrieve();
110
+ return collection_name;
111
+ }
112
+ catch (error) {
113
+ if (httpStatus(error) === 404) {
114
+ return undefined;
115
+ }
116
+ throw error;
117
+ }
118
+ }
119
+ }
@@ -0,0 +1,31 @@
1
+ import type { Client } from 'typesense';
2
+ /** Documents imported per Typesense request unless a writer is told otherwise. */
3
+ export declare const DEFAULT_BATCH_SIZE = 1000;
4
+ /**
5
+ * Accumulates documents across writes and upserts them into a collection in
6
+ * fixed-size batches, so many small writes (one dataset at a time) still land
7
+ * as few Typesense requests. Call {@link flush} to import the remainder.
8
+ */
9
+ export declare class BatchImporter<TDocument extends {
10
+ id: string;
11
+ }> {
12
+ private readonly client;
13
+ private readonly collection;
14
+ private readonly batchSize;
15
+ private buffer;
16
+ constructor(client: Client, collection: string, batchSize: number);
17
+ /** Add a stream of documents, importing a batch whenever one fills up. */
18
+ add(documents: AsyncIterable<TDocument>): Promise<void>;
19
+ /** Import any remaining documents. */
20
+ flush(): Promise<void>;
21
+ private importBuffer;
22
+ }
23
+ /**
24
+ * Create-or-replace one batch of whole documents, keyed on `id`, throwing if any
25
+ * individual document fails (Typesense’s bulk import otherwise reports
26
+ * per-document failures without rejecting).
27
+ */
28
+ export declare function importBatch(client: Client, collection: string, batch: readonly {
29
+ id: string;
30
+ }[]): Promise<void>;
31
+ //# sourceMappingURL=import.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"import.d.ts","sourceRoot":"","sources":["../src/import.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAkB,MAAM,WAAW,CAAC;AAExD,kFAAkF;AAClF,eAAO,MAAM,kBAAkB,OAAO,CAAC;AAEvC;;;;GAIG;AACH,qBAAa,aAAa,CAAC,SAAS,SAAS;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE;IAIvD,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,UAAU;IAC3B,OAAO,CAAC,QAAQ,CAAC,SAAS;IAL5B,OAAO,CAAC,MAAM,CAAmB;gBAGd,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM;IAGpC,0EAA0E;IACpE,GAAG,CAAC,SAAS,EAAE,aAAa,CAAC,SAAS,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC;IAS7D,sCAAsC;IAChC,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;YAMd,YAAY;CAK3B;AAED;;;;GAIG;AACH,wBAAsB,WAAW,CAC/B,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,MAAM,EAClB,KAAK,EAAE,SAAS;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE,EAAE,GAC/B,OAAO,CAAC,IAAI,CAAC,CAkBf"}
package/dist/import.js ADDED
@@ -0,0 +1,60 @@
1
+ /** Documents imported per Typesense request unless a writer is told otherwise. */
2
+ export const DEFAULT_BATCH_SIZE = 1000;
3
+ /**
4
+ * Accumulates documents across writes and upserts them into a collection in
5
+ * fixed-size batches, so many small writes (one dataset at a time) still land
6
+ * as few Typesense requests. Call {@link flush} to import the remainder.
7
+ */
8
+ export class BatchImporter {
9
+ client;
10
+ collection;
11
+ batchSize;
12
+ buffer = [];
13
+ constructor(client, collection, batchSize) {
14
+ this.client = client;
15
+ this.collection = collection;
16
+ this.batchSize = batchSize;
17
+ }
18
+ /** Add a stream of documents, importing a batch whenever one fills up. */
19
+ async add(documents) {
20
+ for await (const document of documents) {
21
+ this.buffer.push(document);
22
+ if (this.buffer.length >= this.batchSize) {
23
+ await this.importBuffer();
24
+ }
25
+ }
26
+ }
27
+ /** Import any remaining documents. */
28
+ async flush() {
29
+ if (this.buffer.length > 0) {
30
+ await this.importBuffer();
31
+ }
32
+ }
33
+ async importBuffer() {
34
+ const batch = this.buffer;
35
+ this.buffer = [];
36
+ await importBatch(this.client, this.collection, batch);
37
+ }
38
+ }
39
+ /**
40
+ * Create-or-replace one batch of whole documents, keyed on `id`, throwing if any
41
+ * individual document fails (Typesense’s bulk import otherwise reports
42
+ * per-document failures without rejecting).
43
+ */
44
+ export async function importBatch(client, collection, batch) {
45
+ const results = (await client
46
+ .collections(collection)
47
+ .documents()
48
+ .import(batch, {
49
+ action: 'upsert',
50
+ // Collect per-document outcomes instead of throwing the client’s opaque
51
+ // ImportError, so we can report which documents failed and why.
52
+ throwOnFail: false,
53
+ }));
54
+ const failures = results.filter((result) => !result.success);
55
+ if (failures.length > 0) {
56
+ throw new Error(`Typesense upsert into “${collection}” failed for ${failures.length}/${results.length} documents: ${failures
57
+ .map((failure) => failure.error)
58
+ .join('; ')}`);
59
+ }
60
+ }
@@ -0,0 +1,62 @@
1
+ import type { Client } from 'typesense';
2
+ import type { SearchType } from '@lde/search';
3
+ import type { RunContext, RunWriter, Writer } from '@lde/pipeline';
4
+ import { type RebuildOptions } from './rebuild-support.js';
5
+ /** {@link InPlaceRebuild} options: the shared rebuild options plus the
6
+ * membership-sweep ceiling. */
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). */
10
+ readonly maxSweepableSources?: number;
11
+ }
12
+ /**
13
+ * In-place Rebuild (update the live index directly – upsert changed sources,
14
+ * sweep the rest – rather than swap in a fresh one) as a transactional
15
+ * `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:
19
+ *
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
29
+ * (registry-membership sweep over {@link RunContext.selectedSources}, which
30
+ * includes datasets skipped as unchanged) and releases the lock;
31
+ * - **abort** only releases the lock: upserts are idempotent, so whatever
32
+ * landed stays until the next run reconciles.
33
+ *
34
+ * `openRun` takes the single-flight cross-pod lock (throwing
35
+ * `RebuildAlreadyRunning` when another rebuild holds it) and creates
36
+ * the collection on demand from the {@link SearchType} plus the two
37
+ * bookkeeping fields.
38
+ *
39
+ * Document ids must be unique per (source, entity) – the caller keys them –
40
+ * or documents from different sources overwrite each other.
41
+ *
42
+ * The name is the NDE Stack’s pattern
43
+ * ({@link https://docs.nde.nl/stack/patterns | Stack patterns}).
44
+ */
45
+ export declare class InPlaceRebuild<TDocument extends {
46
+ id: string;
47
+ }> implements Writer<TDocument> {
48
+ private readonly client;
49
+ private readonly searchType;
50
+ private readonly options;
51
+ constructor(client: Client, searchType: SearchType, options: InPlaceRebuildOptions);
52
+ openRun(context: RunContext): Promise<RunWriter<TDocument>>;
53
+ /**
54
+ * The distinct sources present in the collection, via a single `source`
55
+ * facet. Requests one bucket beyond `maxSources` so genuine truncation is
56
+ * distinguishable from an exactly-full result: `maxSources` buckets are
57
+ * returned intact, `maxSources + 1` proves more exist and the sweep would
58
+ * miss some, so it throws rather than delete blind.
59
+ */
60
+ private indexedSources;
61
+ }
62
+ //# sourceMappingURL=in-place-rebuild.d.ts.map
@@ -0,0 +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,EACpB,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;IAE1B,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,UAAU;IAC3B,OAAO,CAAC,QAAQ,CAAC,OAAO;gBAFP,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,UAAU,EACtB,OAAO,EAAE,qBAAqB;IAK3C,OAAO,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,SAAS,CAAC,SAAS,CAAC,CAAC;IAuFjE;;;;;;OAMG;YACW,cAAc;CAsB7B"}
@@ -0,0 +1,140 @@
1
+ import { buildCollectionSchema } from './collection-schema.js';
2
+ import { BatchImporter } from './import.js';
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';
6
+ /**
7
+ * Default ceiling on the distinct sources the membership sweep enumerates via
8
+ * a single `source` facet. The sweep needs the complete source set to spot
9
+ * departed ones, so a truncated facet would silently miss deletions;
10
+ * {@link InPlaceRebuild} throws instead. Raise it (up to the engine’s
11
+ * `max_facet_values` limit) with {@link InPlaceRebuildOptions.maxSweepableSources}
12
+ * before an index approaches it, so the ceiling is a tunable guard rather than
13
+ * a hard wall.
14
+ */
15
+ const DEFAULT_MAX_SWEEPABLE_SOURCES = 10_000;
16
+ /**
17
+ * In-place Rebuild (update the live index directly – upsert changed sources,
18
+ * sweep the rest – rather than swap in a fresh one) as a transactional
19
+ * `Writer`: documents are upserted into one long-lived collection with
20
+ * per-source atomicity – no swap, no staging. Every document is stamped with
21
+ * its `source` (the dataset IRI) and `last_seen` (the run id); deletion is a
22
+ * sweep, never special-cased:
23
+ *
24
+ * - a **successful dataset flush** deletes the source’s documents the run did
25
+ * not rewrite (`source = dataset && last_seen != runId`). A failed dataset
26
+ * is not swept – its output is incomplete, and the next successful run
27
+ * reconciles;
28
+ * - **reset** (the pipeline’s dump-fallback discard) deletes only *this run’s*
29
+ * writes for the dataset (`source = dataset && last_seen = runId`), so the
30
+ * dump re-run rebuilds it cleanly while the source’s prior-run documents are
31
+ * left for the success sweep to reconcile;
32
+ * - **commit** deletes every document whose source left the run’s selection
33
+ * (registry-membership sweep over {@link RunContext.selectedSources}, which
34
+ * includes datasets skipped as unchanged) and releases the lock;
35
+ * - **abort** only releases the lock: upserts are idempotent, so whatever
36
+ * landed stays until the next run reconciles.
37
+ *
38
+ * `openRun` takes the single-flight cross-pod lock (throwing
39
+ * `RebuildAlreadyRunning` when another rebuild holds it) and creates
40
+ * the collection on demand from the {@link SearchType} plus the two
41
+ * bookkeeping fields.
42
+ *
43
+ * Document ids must be unique per (source, entity) – the caller keys them –
44
+ * or documents from different sources overwrite each other.
45
+ *
46
+ * The name is the NDE Stack’s pattern
47
+ * ({@link https://docs.nde.nl/stack/patterns | Stack patterns}).
48
+ */
49
+ export class InPlaceRebuild {
50
+ client;
51
+ searchType;
52
+ options;
53
+ constructor(client, searchType, options) {
54
+ this.client = client;
55
+ this.searchType = searchType;
56
+ this.options = options;
57
+ assertNoReservedFields(searchType, [SOURCE_FIELD, LAST_SEEN_FIELD]);
58
+ }
59
+ async openRun(context) {
60
+ const { maxSweepableSources = DEFAULT_MAX_SWEEPABLE_SOURCES, ...rebuildOptions } = this.options;
61
+ const { name, batchSize, lockTtlMs, schemaOptions } = resolveRebuildOptions(rebuildOptions);
62
+ return openLockedRun(this.client, name, lockTtlMs, async () => {
63
+ // Create the collection on demand: SearchType schema + the bookkeeping
64
+ // fields, `source` faceted so the membership sweep can enumerate the
65
+ // distinct sources.
66
+ await ensureCollectionExists(this.client, name, () => {
67
+ const schema = buildCollectionSchema(this.searchType, schemaOptions);
68
+ const bookkeeping = [
69
+ { name: SOURCE_FIELD, type: 'string', facet: true },
70
+ { name: LAST_SEEN_FIELD, type: 'string' },
71
+ ];
72
+ return {
73
+ ...schema,
74
+ fields: [...(schema.fields ?? []), ...bookkeeping],
75
+ };
76
+ });
77
+ const importer = new BatchImporter(this.client, name, batchSize);
78
+ return {
79
+ write: async (dataset, documents) => importer.add(stampDocuments(documents, {
80
+ [SOURCE_FIELD]: dataset.iri.toString(),
81
+ [LAST_SEEN_FIELD]: context.runId,
82
+ })),
83
+ flush: async (dataset, outcome) => {
84
+ // Land the buffered documents first, so the sweep below never
85
+ // deletes what this run just rewrote.
86
+ await importer.flush();
87
+ if (outcome !== 'success') {
88
+ // A failed dataset’s output is incomplete: sweeping against it
89
+ // would delete documents the run never got to rewrite. Leave the
90
+ // stale ones for the next successful run to reconcile.
91
+ return;
92
+ }
93
+ await deleteByFilter(this.client, name, staleDocumentsFilter(dataset.iri.toString(), context.runId));
94
+ },
95
+ reset: async (dataset) => {
96
+ // Discard only this run’s partial writes for the source (the failed
97
+ // endpoint attempt) so the dump re-run rebuilds it cleanly; the
98
+ // source’s prior-run documents stay for the success sweep.
99
+ await importer.flush();
100
+ await deleteByFilter(this.client, name, thisRunDocumentsFilter(dataset.iri.toString(), context.runId));
101
+ },
102
+ commit: async () => {
103
+ await importer.flush();
104
+ const departed = departedSources(await this.indexedSources(name, maxSweepableSources), context.selectedSources());
105
+ for (const filter of membershipSweepFilters(departed)) {
106
+ await deleteByFilter(this.client, name, filter);
107
+ }
108
+ await releaseLock(this.client, name);
109
+ },
110
+ abort: async () => {
111
+ await releaseLock(this.client, name);
112
+ },
113
+ };
114
+ });
115
+ }
116
+ /**
117
+ * The distinct sources present in the collection, via a single `source`
118
+ * facet. Requests one bucket beyond `maxSources` so genuine truncation is
119
+ * distinguishable from an exactly-full result: `maxSources` buckets are
120
+ * returned intact, `maxSources + 1` proves more exist and the sweep would
121
+ * miss some, so it throws rather than delete blind.
122
+ */
123
+ async indexedSources(name, maxSources) {
124
+ const response = await this.client
125
+ .collections(name)
126
+ .documents()
127
+ .search({
128
+ q: '*',
129
+ query_by: SOURCE_FIELD,
130
+ per_page: 0,
131
+ facet_by: SOURCE_FIELD,
132
+ max_facet_values: maxSources + 1,
133
+ });
134
+ const counts = response.facet_counts?.[0]?.counts ?? [];
135
+ if (counts.length > maxSources) {
136
+ throw new Error(`Membership sweep cannot see beyond ${maxSources} distinct sources in “${name}”; raise maxSweepableSources or departed sources might be missed`);
137
+ }
138
+ return counts.map((count) => count.value);
139
+ }
140
+ }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,9 @@
1
- export { rebuild } from './adapter.js';
2
- export type { RebuildOptions } from './adapter.js';
1
+ export { BlueGreenRebuild } from './blue-green-rebuild.js';
2
+ export type { BlueGreenRebuildOptions } from './blue-green-rebuild.js';
3
+ export { InPlaceRebuild } from './in-place-rebuild.js';
4
+ export type { InPlaceRebuildOptions } from './in-place-rebuild.js';
5
+ export { RebuildAlreadyRunning } from './lock.js';
6
+ export { departedSources, membershipSweepFilters, sourceDocumentsFilter, staleDocumentsFilter, } from './sweep.js';
3
7
  export { buildCollectionSchema } from './collection-schema.js';
4
8
  export type { CollectionSchemaOptions } from './collection-schema.js';
5
9
  export { buildSearchParams } from './query-compiler.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AACvC,YAAY,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AACnD,OAAO,EAAE,qBAAqB,EAAE,MAAM,wBAAwB,CAAC;AAC/D,YAAY,EAAE,uBAAuB,EAAE,MAAM,wBAAwB,CAAC;AACtE,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AACxD,YAAY,EAAE,wBAAwB,EAAE,MAAM,qBAAqB,CAAC;AACpE,OAAO,EAAE,2BAA2B,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AAC/E,YAAY,EACV,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,EACL,eAAe,EACf,sBAAsB,EACtB,qBAAqB,EACrB,oBAAoB,GACrB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,qBAAqB,EAAE,MAAM,wBAAwB,CAAC;AAC/D,YAAY,EAAE,uBAAuB,EAAE,MAAM,wBAAwB,CAAC;AACtE,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AACxD,YAAY,EAAE,wBAAwB,EAAE,MAAM,qBAAqB,CAAC;AACpE,OAAO,EAAE,2BAA2B,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AAC/E,YAAY,EACV,4BAA4B,EAC5B,uBAAuB,GACxB,MAAM,aAAa,CAAC"}
package/dist/index.js CHANGED
@@ -1,4 +1,7 @@
1
- export { rebuild } from './adapter.js';
1
+ export { BlueGreenRebuild } from './blue-green-rebuild.js';
2
+ export { InPlaceRebuild } from './in-place-rebuild.js';
3
+ export { RebuildAlreadyRunning } from './lock.js';
4
+ export { departedSources, membershipSweepFilters, sourceDocumentsFilter, staleDocumentsFilter, } from './sweep.js';
2
5
  export { buildCollectionSchema } from './collection-schema.js';
3
6
  export { buildSearchParams } from './query-compiler.js';
4
7
  export { createTypesenseSearchEngine, parseSearchResponse } from './search.js';