@lde/search-typesense 0.2.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 +78 -27
- package/dist/blue-green-rebuild.d.ts +46 -0
- package/dist/blue-green-rebuild.d.ts.map +1 -0
- package/dist/blue-green-rebuild.js +119 -0
- package/dist/import.d.ts +31 -0
- package/dist/import.d.ts.map +1 -0
- package/dist/import.js +60 -0
- package/dist/in-place-rebuild.d.ts +62 -0
- package/dist/in-place-rebuild.d.ts.map +1 -0
- package/dist/in-place-rebuild.js +140 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/lock.d.ts +42 -0
- package/dist/lock.d.ts.map +1 -0
- package/dist/lock.js +134 -0
- package/dist/rebuild-support.d.ts +34 -0
- package/dist/rebuild-support.d.ts.map +1 -0
- package/dist/rebuild-support.js +33 -0
- package/dist/search.d.ts +6 -2
- package/dist/search.d.ts.map +1 -1
- package/dist/search.js +79 -25
- package/dist/sweep.d.ts +50 -0
- package/dist/sweep.d.ts.map +1 -0
- package/dist/sweep.js +87 -0
- package/package.json +6 -2
- package/dist/adapter.d.ts +0 -56
- package/dist/adapter.d.ts.map +0 -1
- package/dist/adapter.js +0 -214
package/dist/index.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
export {
|
|
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';
|
package/dist/lock.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Client, CollectionCreateSchema } from 'typesense';
|
|
2
|
+
/** A held lock older than this (ms) is reclaimed. */
|
|
3
|
+
export declare const DEFAULT_LOCK_TTL_MS: number;
|
|
4
|
+
/**
|
|
5
|
+
* Another run already holds the index’s rebuild lock, so this run refuses to
|
|
6
|
+
* open. Rebuilds are single-flight per index across pods; catch this to treat
|
|
7
|
+
* a concurrent rebuild as a graceful skip rather than a failure.
|
|
8
|
+
*/
|
|
9
|
+
export declare class RebuildAlreadyRunning extends Error {
|
|
10
|
+
constructor(name: string);
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Open a run under the index’s single-flight lock: acquire it (throwing
|
|
14
|
+
* {@link RebuildAlreadyRunning} when another rebuild holds it), run the
|
|
15
|
+
* writer-specific `setup`, and release the lock again if the setup fails –
|
|
16
|
+
* so a failed opening never leaves the index locked. The lock choreography
|
|
17
|
+
* lives here once, shared by every rebuild writer.
|
|
18
|
+
*/
|
|
19
|
+
export declare function openLockedRun<Run>(client: Client, name: string, ttlMs: number, setup: () => Promise<Run>): Promise<Run>;
|
|
20
|
+
/**
|
|
21
|
+
* Take the per-index rebuild lock via an atomic create, reclaiming it if the
|
|
22
|
+
* current holder is older than `ttlMs`. Returns `false` if another caller
|
|
23
|
+
* holds a fresh lock. The lock collection is created on demand, so the happy
|
|
24
|
+
* path costs a single request.
|
|
25
|
+
*
|
|
26
|
+
* Advisory, not a strict mutex: the lock is built on Typesense, not a
|
|
27
|
+
* consensus store. Under a TTL-reclaim race two rebuilds can briefly run at
|
|
28
|
+
* once; Blue/green tolerates this because it is idempotent (worst case:
|
|
29
|
+
* redundant work and a transient orphaned collection), In-place because its
|
|
30
|
+
* upserts are idempotent.
|
|
31
|
+
*/
|
|
32
|
+
export declare function acquireLock(client: Client, name: string, ttlMs: number): Promise<boolean>;
|
|
33
|
+
/** Release the per-index lock; a no-op when it is not currently held. */
|
|
34
|
+
export declare function releaseLock(client: Client, name: string): Promise<void>;
|
|
35
|
+
/**
|
|
36
|
+
* Create a collection on demand: retrieve it, and only on a 404 create it
|
|
37
|
+
* from the lazily built `schema`, tolerating a concurrent creator (409).
|
|
38
|
+
*/
|
|
39
|
+
export declare function ensureCollectionExists(client: Client, name: string, schema: () => CollectionCreateSchema): Promise<void>;
|
|
40
|
+
/** The HTTP status a Typesense client error carries, if any. */
|
|
41
|
+
export declare function httpStatus(error: unknown): number | undefined;
|
|
42
|
+
//# sourceMappingURL=lock.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"lock.d.ts","sourceRoot":"","sources":["../src/lock.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,sBAAsB,EAAE,MAAM,WAAW,CAAC;AAIhE,qDAAqD;AACrD,eAAO,MAAM,mBAAmB,QAAiB,CAAC;AAElD;;;;GAIG;AACH,qBAAa,qBAAsB,SAAQ,KAAK;gBAClC,IAAI,EAAE,MAAM;CAIzB;AAED;;;;;;GAMG;AACH,wBAAsB,aAAa,CAAC,GAAG,EACrC,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,MAAM,OAAO,CAAC,GAAG,CAAC,GACxB,OAAO,CAAC,GAAG,CAAC,CAUd;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,WAAW,CAC/B,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,GACZ,OAAO,CAAC,OAAO,CAAC,CAwBlB;AAED,yEAAyE;AACzE,wBAAsB,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAQ7E;AAED;;;GAGG;AACH,wBAAsB,sBAAsB,CAC1C,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,MAAM,sBAAsB,GACnC,OAAO,CAAC,IAAI,CAAC,CAgBf;AA+BD,gEAAgE;AAChE,wBAAgB,UAAU,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAE7D"}
|
package/dist/lock.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
const LOCK_COLLECTION = 'rebuild_locks';
|
|
2
|
+
/** A held lock older than this (ms) is reclaimed. */
|
|
3
|
+
export const DEFAULT_LOCK_TTL_MS = 10 * 60 * 1000;
|
|
4
|
+
/**
|
|
5
|
+
* Another run already holds the index’s rebuild lock, so this run refuses to
|
|
6
|
+
* open. Rebuilds are single-flight per index across pods; catch this to treat
|
|
7
|
+
* a concurrent rebuild as a graceful skip rather than a failure.
|
|
8
|
+
*/
|
|
9
|
+
export class RebuildAlreadyRunning extends Error {
|
|
10
|
+
constructor(name) {
|
|
11
|
+
super(`Another rebuild of “${name}” is already running`);
|
|
12
|
+
this.name = 'RebuildAlreadyRunning';
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Open a run under the index’s single-flight lock: acquire it (throwing
|
|
17
|
+
* {@link RebuildAlreadyRunning} when another rebuild holds it), run the
|
|
18
|
+
* writer-specific `setup`, and release the lock again if the setup fails –
|
|
19
|
+
* so a failed opening never leaves the index locked. The lock choreography
|
|
20
|
+
* lives here once, shared by every rebuild writer.
|
|
21
|
+
*/
|
|
22
|
+
export async function openLockedRun(client, name, ttlMs, setup) {
|
|
23
|
+
if (!(await acquireLock(client, name, ttlMs))) {
|
|
24
|
+
throw new RebuildAlreadyRunning(name);
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
return await setup();
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
await releaseLock(client, name);
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Take the per-index rebuild lock via an atomic create, reclaiming it if the
|
|
36
|
+
* current holder is older than `ttlMs`. Returns `false` if another caller
|
|
37
|
+
* holds a fresh lock. The lock collection is created on demand, so the happy
|
|
38
|
+
* path costs a single request.
|
|
39
|
+
*
|
|
40
|
+
* Advisory, not a strict mutex: the lock is built on Typesense, not a
|
|
41
|
+
* consensus store. Under a TTL-reclaim race two rebuilds can briefly run at
|
|
42
|
+
* once; Blue/green tolerates this because it is idempotent (worst case:
|
|
43
|
+
* redundant work and a transient orphaned collection), In-place because its
|
|
44
|
+
* upserts are idempotent.
|
|
45
|
+
*/
|
|
46
|
+
export async function acquireLock(client, name, ttlMs) {
|
|
47
|
+
try {
|
|
48
|
+
await client
|
|
49
|
+
.collections(LOCK_COLLECTION)
|
|
50
|
+
.documents()
|
|
51
|
+
.create({ id: name, acquired_at: Date.now() });
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
const status = httpStatus(error);
|
|
56
|
+
if (status === 409) {
|
|
57
|
+
return reclaimIfStale(client, name, ttlMs);
|
|
58
|
+
}
|
|
59
|
+
if (status === 404) {
|
|
60
|
+
// First lock ever: the collection does not exist yet. Create it, then
|
|
61
|
+
// retake from the top – the atomic create still guards a concurrent
|
|
62
|
+
// taker, and the collection cannot 404 again.
|
|
63
|
+
await ensureCollectionExists(client, LOCK_COLLECTION, () => ({
|
|
64
|
+
name: LOCK_COLLECTION,
|
|
65
|
+
fields: [{ name: 'acquired_at', type: 'int64' }],
|
|
66
|
+
}));
|
|
67
|
+
return acquireLock(client, name, ttlMs);
|
|
68
|
+
}
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** Release the per-index lock; a no-op when it is not currently held. */
|
|
73
|
+
export async function releaseLock(client, name) {
|
|
74
|
+
try {
|
|
75
|
+
await client.collections(LOCK_COLLECTION).documents(name).delete();
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
if (httpStatus(error) !== 404) {
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Create a collection on demand: retrieve it, and only on a 404 create it
|
|
85
|
+
* from the lazily built `schema`, tolerating a concurrent creator (409).
|
|
86
|
+
*/
|
|
87
|
+
export async function ensureCollectionExists(client, name, schema) {
|
|
88
|
+
try {
|
|
89
|
+
await client.collections(name).retrieve();
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
if (httpStatus(error) !== 404) {
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
await client.collections().create(schema());
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
if (httpStatus(error) !== 409) {
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/** Take over the lock if its holder has not refreshed it within `ttlMs`. */
|
|
107
|
+
async function reclaimIfStale(client, name, ttlMs) {
|
|
108
|
+
let held;
|
|
109
|
+
try {
|
|
110
|
+
held = (await client
|
|
111
|
+
.collections(LOCK_COLLECTION)
|
|
112
|
+
.documents(name)
|
|
113
|
+
.retrieve());
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
// Released between our create and this read — leave it for the next try.
|
|
117
|
+
if (httpStatus(error) === 404) {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
if (Date.now() - held.acquired_at <= ttlMs) {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
await client
|
|
126
|
+
.collections(LOCK_COLLECTION)
|
|
127
|
+
.documents()
|
|
128
|
+
.upsert({ id: name, acquired_at: Date.now() });
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
/** The HTTP status a Typesense client error carries, if any. */
|
|
132
|
+
export function httpStatus(error) {
|
|
133
|
+
return error.httpStatus;
|
|
134
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Client } from 'typesense';
|
|
2
|
+
import type { SearchType } from '@lde/search';
|
|
3
|
+
import type { CollectionSchemaOptions } from './collection-schema.js';
|
|
4
|
+
/**
|
|
5
|
+
* The tuning knobs both rebuild writers share, on top of the collection-schema
|
|
6
|
+
* options. Each writer may add its own (In-place adds `maxSweepableSources`).
|
|
7
|
+
*/
|
|
8
|
+
export interface RebuildOptions extends CollectionSchemaOptions {
|
|
9
|
+
/** Documents imported per Typesense request (default 1000). */
|
|
10
|
+
readonly batchSize?: number;
|
|
11
|
+
/** A held lock older than this (ms) is reclaimed (default 10 minutes). */
|
|
12
|
+
readonly lockTtlMs?: number;
|
|
13
|
+
}
|
|
14
|
+
/** The shared options resolved to concrete values plus the residual
|
|
15
|
+
* collection-schema options a writer passes to {@link buildCollectionSchema}. */
|
|
16
|
+
export interface ResolvedRebuildOptions {
|
|
17
|
+
readonly name: string;
|
|
18
|
+
readonly batchSize: number;
|
|
19
|
+
readonly lockTtlMs: number;
|
|
20
|
+
readonly schemaOptions: CollectionSchemaOptions;
|
|
21
|
+
}
|
|
22
|
+
/** Apply the shared defaults, once, so neither writer restates them. */
|
|
23
|
+
export declare function resolveRebuildOptions(options: RebuildOptions): ResolvedRebuildOptions;
|
|
24
|
+
/**
|
|
25
|
+
* Reject a {@link SearchType} that declares any of the bookkeeping field names
|
|
26
|
+
* a writer stamps, so a domain field can never collide with `source` /
|
|
27
|
+
* `last_seen`.
|
|
28
|
+
*/
|
|
29
|
+
export declare function assertNoReservedFields(searchType: SearchType, reserved: readonly string[]): void;
|
|
30
|
+
/** Stamp each document with fixed bookkeeping fields as it streams past. */
|
|
31
|
+
export declare function stampDocuments<TDocument>(documents: AsyncIterable<TDocument>, stamp: Readonly<Record<string, string>>): AsyncIterable<TDocument & Record<string, string>>;
|
|
32
|
+
/** Delete a collection’s documents matching a Typesense filter. */
|
|
33
|
+
export declare function deleteByFilter(client: Client, collection: string, filterBy: string): Promise<void>;
|
|
34
|
+
//# sourceMappingURL=rebuild-support.d.ts.map
|
|
@@ -0,0 +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,KAAK,EAAE,uBAAuB,EAAE,MAAM,wBAAwB,CAAC;AAItE;;;GAGG;AACH,MAAM,WAAW,cAAe,SAAQ,uBAAuB;IAC7D,+DAA+D;IAC/D,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,0EAA0E;IAC1E,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;CAC7B;AAED;kFACkF;AAClF,MAAM,WAAW,sBAAsB;IACrC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,aAAa,EAAE,uBAAuB,CAAC;CACjD;AAED,wEAAwE;AACxE,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,cAAc,GACtB,sBAAsB,CAOxB;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"}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { DEFAULT_LOCK_TTL_MS } from './lock.js';
|
|
2
|
+
import { DEFAULT_BATCH_SIZE } from './import.js';
|
|
3
|
+
/** Apply the shared defaults, once, so neither writer restates them. */
|
|
4
|
+
export function resolveRebuildOptions(options) {
|
|
5
|
+
const { batchSize = DEFAULT_BATCH_SIZE, lockTtlMs = DEFAULT_LOCK_TTL_MS, ...schemaOptions } = options;
|
|
6
|
+
return { name: schemaOptions.name, batchSize, lockTtlMs, schemaOptions };
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Reject a {@link SearchType} that declares any of the bookkeeping field names
|
|
10
|
+
* a writer stamps, so a domain field can never collide with `source` /
|
|
11
|
+
* `last_seen`.
|
|
12
|
+
*/
|
|
13
|
+
export function assertNoReservedFields(searchType, reserved) {
|
|
14
|
+
const clashing = searchType.fields.filter((field) => reserved.includes(field.name));
|
|
15
|
+
if (clashing.length > 0) {
|
|
16
|
+
throw new Error(`SearchType “${searchType.name}” declares reserved bookkeeping field(s) ${clashing
|
|
17
|
+
.map((field) => `“${field.name}”`)
|
|
18
|
+
.join(', ')}`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** Stamp each document with fixed bookkeeping fields as it streams past. */
|
|
22
|
+
export async function* stampDocuments(documents, stamp) {
|
|
23
|
+
for await (const document of documents) {
|
|
24
|
+
yield { ...document, ...stamp };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** Delete a collection’s documents matching a Typesense filter. */
|
|
28
|
+
export async function deleteByFilter(client, collection, filterBy) {
|
|
29
|
+
await client
|
|
30
|
+
.collections(collection)
|
|
31
|
+
.documents()
|
|
32
|
+
.delete({ filter_by: filterBy });
|
|
33
|
+
}
|
package/dist/search.d.ts
CHANGED
|
@@ -35,8 +35,12 @@ export interface TypesenseSearchEngineOptions<TypeName extends string = string>
|
|
|
35
35
|
* ({@link buildSearchParams}), runs it against the type’s collection,
|
|
36
36
|
* resolves the reference labels for the page of hits from the sidecar
|
|
37
37
|
* `labels` collection in one lookup, and reconstructs the engine-neutral
|
|
38
|
-
* {@link SearchResult} ({@link parseSearchResponse}).
|
|
39
|
-
*
|
|
38
|
+
* {@link SearchResult} ({@link parseSearchResponse}). `searchFacets` answers
|
|
39
|
+
* a whole facet batch (e.g. a sidebar’s skip-own-filter query variants) as a
|
|
40
|
+
* single `multi_search` round-trip with one shared label lookup; a failed
|
|
41
|
+
* entry is reported in place as a per-query outcome, so it never discards
|
|
42
|
+
* its siblings’ facets. Every engine specific stays here; consumers see only
|
|
43
|
+
* logical documents.
|
|
40
44
|
*
|
|
41
45
|
* With a schema built by `searchSchema` over `defineSearchType` literals,
|
|
42
46
|
* `search()` accepts only the deployment’s own types and returns typo-safe
|
package/dist/search.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../src/search.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AACxC,OAAO,
|
|
1
|
+
{"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../src/search.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AACxC,OAAO,EAGL,KAAK,cAAc,EAMnB,KAAK,YAAY,EAEjB,KAAK,YAAY,EACjB,KAAK,YAAY,EACjB,KAAK,UAAU,EAGhB,MAAM,aAAa,CAAC;AAUrB,OAAO,EAGL,KAAK,wBAAwB,EAC9B,MAAM,qBAAqB,CAAC;AAE7B;;;;kCAIkC;AAClC,MAAM,WAAW,4BAA4B,CAC3C,QAAQ,SAAS,MAAM,GAAG,MAAM,CAChC,SAAQ,wBAAwB;IAChC;qEACiE;IACjE,QAAQ,CAAC,WAAW,EAAE,QAAQ,CAAC,MAAM,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC,CAAC;IACzD,kFAAkF;IAClF,QAAQ,CAAC,gBAAgB,CAAC,EAAE,MAAM,CAAC;IACnC;;;OAGG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;IACjD;;;;;;;OAOG;IACH,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,CAAC;CACnC;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,2BAA2B,CACzC,KAAK,CAAC,KAAK,SAAS,SAAS,UAAU,EAAE,EAEzC,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,YAAY,CAAC,KAAK,CAAC,EAC3B,OAAO,EAAE,4BAA4B,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,CAAC,GAC3D,YAAY,CAAC,KAAK,CAAC,CAwKrB;AA+DD;;;;;;;;;;;;GAYG;AACH,wBAAsB,WAAW,CAC/B,MAAM,EAAE,IAAI,CAAC,MAAM,EAAE,aAAa,CAAC,EACnC,UAAU,EAAE,MAAM,EAClB,IAAI,EAAE,SAAS,MAAM,EAAE,GACtB,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC,CAmCtC;AA6BD,oEAAoE;AACpE,MAAM,WAAW,uBAAuB;IACtC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,IAAI,CAAC,EAAE,SAAS;QAAE,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,EAAE,CAAC;IAC1E,QAAQ,CAAC,YAAY,CAAC,EAAE,SAAS;QAC/B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;QAC5B,QAAQ,CAAC,MAAM,EAAE,SAAS;YACxB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;YACvB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;SACxB,EAAE,CAAC;KACL,EAAE,CAAC;CACL;AAED;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CACjC,QAAQ,EAAE,uBAAuB,EACjC,UAAU,EAAE,UAAU,EACtB,MAAM,EAAE,WAAW,CAAC,MAAM,EAAE,cAAc,CAAC,GAC1C,YAAY,CAiCd"}
|
package/dist/search.js
CHANGED
|
@@ -8,8 +8,12 @@ import { buildSearchParams, escapeFilterValue, } from './query-compiler.js';
|
|
|
8
8
|
* ({@link buildSearchParams}), runs it against the type’s collection,
|
|
9
9
|
* resolves the reference labels for the page of hits from the sidecar
|
|
10
10
|
* `labels` collection in one lookup, and reconstructs the engine-neutral
|
|
11
|
-
* {@link SearchResult} ({@link parseSearchResponse}).
|
|
12
|
-
*
|
|
11
|
+
* {@link SearchResult} ({@link parseSearchResponse}). `searchFacets` answers
|
|
12
|
+
* a whole facet batch (e.g. a sidebar’s skip-own-filter query variants) as a
|
|
13
|
+
* single `multi_search` round-trip with one shared label lookup; a failed
|
|
14
|
+
* entry is reported in place as a per-query outcome, so it never discards
|
|
15
|
+
* its siblings’ facets. Every engine specific stays here; consumers see only
|
|
16
|
+
* logical documents.
|
|
13
17
|
*
|
|
14
18
|
* With a schema built by `searchSchema` over `defineSearchType` literals,
|
|
15
19
|
* `search()` accepts only the deployment’s own types and returns typo-safe
|
|
@@ -54,6 +58,37 @@ export function createTypesenseSearchEngine(client, schema, options) {
|
|
|
54
58
|
});
|
|
55
59
|
return inFlightLoad;
|
|
56
60
|
}
|
|
61
|
+
// Cached path: the once-loaded full collection serves labels by in-memory
|
|
62
|
+
// lookup (no per-search round-trip). The load does not depend on the
|
|
63
|
+
// response, so it is started BEFORE awaiting the search and runs alongside
|
|
64
|
+
// it; it never rejects (a failed load degrades to an empty map), so it
|
|
65
|
+
// cannot leave an unhandled rejection behind if the search itself fails.
|
|
66
|
+
// `undefined` when the cache (or the sidecar) is not configured.
|
|
67
|
+
function startCachedLabels() {
|
|
68
|
+
return options.labelsCollection !== undefined &&
|
|
69
|
+
options.labelCacheTtlMs !== undefined
|
|
70
|
+
? cachedAllLabels(options.labelsCollection, options.labelCacheTtlMs)
|
|
71
|
+
: undefined;
|
|
72
|
+
}
|
|
73
|
+
// Labels are supplementary: a failed lookup (e.g. the sidecar collection
|
|
74
|
+
// mid-rebuild) degrades to id-only references rather than failing the whole
|
|
75
|
+
// search, so the listing still renders with bare IRIs. `iris` is a thunk so
|
|
76
|
+
// the cached and no-sidecar paths never pay for collecting them.
|
|
77
|
+
async function resolveLabels(cachedLabelsPromise, iris) {
|
|
78
|
+
if (cachedLabelsPromise !== undefined) {
|
|
79
|
+
return cachedLabelsPromise;
|
|
80
|
+
}
|
|
81
|
+
if (options.labelsCollection === undefined) {
|
|
82
|
+
return new Map();
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
return await fetchLabels(client, options.labelsCollection, iris());
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
options.onLabelError?.(error);
|
|
89
|
+
return new Map();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
57
92
|
const engine = {
|
|
58
93
|
schema,
|
|
59
94
|
async search(searchType, query) {
|
|
@@ -63,35 +98,48 @@ export function createTypesenseSearchEngine(client, schema, options) {
|
|
|
63
98
|
assertTypeInSchema(schema, searchType);
|
|
64
99
|
assertValidQuery(query, searchType);
|
|
65
100
|
const params = buildSearchParams(query, searchType, options);
|
|
66
|
-
|
|
67
|
-
// lookup (no per-search round-trip). The load does not depend on the
|
|
68
|
-
// response, so it runs alongside the search; it never rejects (a failed
|
|
69
|
-
// load degrades to an empty map), so it cannot leave an unhandled
|
|
70
|
-
// rejection behind if the search itself fails.
|
|
71
|
-
const cachedLabelsPromise = options.labelsCollection !== undefined &&
|
|
72
|
-
options.labelCacheTtlMs !== undefined
|
|
73
|
-
? cachedAllLabels(options.labelsCollection, options.labelCacheTtlMs)
|
|
74
|
-
: undefined;
|
|
101
|
+
const cachedLabelsPromise = startCachedLabels();
|
|
75
102
|
const response = (await client
|
|
76
103
|
.collections(collections.get(searchType.type))
|
|
77
104
|
.documents()
|
|
78
105
|
.search(params));
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
106
|
+
const labels = await resolveLabels(cachedLabelsPromise, () => referenceIris(response, searchType));
|
|
107
|
+
return parseSearchResponse(response, searchType, labels);
|
|
108
|
+
},
|
|
109
|
+
async searchFacets(searchType, queries) {
|
|
110
|
+
assertTypeInSchema(schema, searchType);
|
|
111
|
+
for (const query of queries) {
|
|
112
|
+
assertValidQuery(query, searchType);
|
|
85
113
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
labels = await fetchLabels(client, options.labelsCollection, referenceIris(response, searchType));
|
|
89
|
-
}
|
|
90
|
-
catch (error) {
|
|
91
|
-
options.onLabelError?.(error);
|
|
92
|
-
}
|
|
114
|
+
if (queries.length === 0) {
|
|
115
|
+
return [];
|
|
93
116
|
}
|
|
94
|
-
|
|
117
|
+
const collection = collections.get(searchType.type);
|
|
118
|
+
const cachedLabelsPromise = startCachedLabels();
|
|
119
|
+
// The whole batch travels as ONE multi_search round-trip. Each query
|
|
120
|
+
// compiles as facet-only regardless of what it carries: no hits
|
|
121
|
+
// (per_page 0) and no ordering – nothing is transferred or sorted that
|
|
122
|
+
// this method cannot return.
|
|
123
|
+
const { results } = (await client.multiSearch.perform({
|
|
124
|
+
searches: queries.map((query) => ({
|
|
125
|
+
collection,
|
|
126
|
+
...buildSearchParams({ ...query, orderBy: [], limit: 0, offset: 0 }, searchType, options),
|
|
127
|
+
})),
|
|
128
|
+
}));
|
|
129
|
+
// One label lookup serves every successful facet result in the batch.
|
|
130
|
+
const responses = results.filter((result) => !('error' in result));
|
|
131
|
+
const labels = await resolveLabels(cachedLabelsPromise, () => [
|
|
132
|
+
...new Set(responses.flatMap((response) => referenceIris(response, searchType))),
|
|
133
|
+
]);
|
|
134
|
+
// multi_search reports a failed entry inline instead of rejecting the
|
|
135
|
+
// call; pass that through as a per-query outcome – naming the facets,
|
|
136
|
+
// not the position, since the batch order is the caller's internal –
|
|
137
|
+
// so one failed query never discards its siblings' facets.
|
|
138
|
+
return results.map((result, index) => 'error' in result
|
|
139
|
+
? {
|
|
140
|
+
error: new Error(`Typesense facet search for “${queries[index].facets.join('”, “')}” failed${result.code !== undefined ? ` (${result.code})` : ''}: ${result.error}`),
|
|
141
|
+
}
|
|
142
|
+
: { facets: parseSearchResponse(result, searchType, labels).facets });
|
|
95
143
|
},
|
|
96
144
|
};
|
|
97
145
|
// The runtime object is string-keyed; the literal-schema typing narrows it.
|
|
@@ -180,6 +228,12 @@ export async function fetchLabels(client, collection, iris) {
|
|
|
180
228
|
}
|
|
181
229
|
const { results } = (await client.multiSearch.perform({ searches }));
|
|
182
230
|
for (const result of results) {
|
|
231
|
+
// multi_search reports a failed entry inline instead of rejecting; throw
|
|
232
|
+
// so the caller's degradation path (onLabelError, id-only references)
|
|
233
|
+
// engages rather than mistaking the failure for absent labels.
|
|
234
|
+
if ('error' in result) {
|
|
235
|
+
throw new Error(`Typesense label lookup failed${result.code !== undefined ? ` (${result.code})` : ''}: ${result.error}`);
|
|
236
|
+
}
|
|
183
237
|
for (const hit of result.hits ?? []) {
|
|
184
238
|
labels.set(String(hit.document.id), labelToLocalizedValue(hit.document));
|
|
185
239
|
}
|
package/dist/sweep.d.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** The document field carrying the dataset IRI a document came from. */
|
|
2
|
+
export declare const SOURCE_FIELD = "source";
|
|
3
|
+
/** The document field carrying the id of the run that last wrote a document. */
|
|
4
|
+
export declare const LAST_SEEN_FIELD = "last_seen";
|
|
5
|
+
/**
|
|
6
|
+
* Sources whose documents must leave the index: indexed, but no longer part
|
|
7
|
+
* of the run’s selection. Selection is membership, not processing – a dataset
|
|
8
|
+
* skipped as unchanged is still selected, so its documents survive.
|
|
9
|
+
*
|
|
10
|
+
* @param indexedSources Source IRIs present in the collection
|
|
11
|
+
* @param selectedSources Source IRIs the run’s selector produced
|
|
12
|
+
*/
|
|
13
|
+
export declare function departedSources(indexedSources: Iterable<string>, selectedSources: Iterable<string>): string[];
|
|
14
|
+
/**
|
|
15
|
+
* Typesense filter matching a source’s documents that this run did not touch:
|
|
16
|
+
* everything the source no longer contains, ready for a per-source sweep.
|
|
17
|
+
*
|
|
18
|
+
* @param sourceIri The dataset IRI stamped on the documents as `source`
|
|
19
|
+
* @param runId The current run; documents it wrote carry it as `last_seen`
|
|
20
|
+
*/
|
|
21
|
+
export declare function staleDocumentsFilter(sourceIri: string, runId: string): string;
|
|
22
|
+
/**
|
|
23
|
+
* Typesense filter matching all of a source’s documents, ready to drop a whole
|
|
24
|
+
* source (a departed source’s membership sweep, or a Blue/green writer rolling
|
|
25
|
+
* a failed dataset out of its not-yet-live collection).
|
|
26
|
+
*
|
|
27
|
+
* @param sourceIri The dataset IRI stamped on the documents as `source`
|
|
28
|
+
*/
|
|
29
|
+
export declare function sourceDocumentsFilter(sourceIri: string): string;
|
|
30
|
+
/**
|
|
31
|
+
* Typesense filter matching the documents **this run** wrote for a source: the
|
|
32
|
+
* inverse of {@link staleDocumentsFilter}. An In-place writer deletes these to
|
|
33
|
+
* discard a source’s in-progress writes (the dump-fallback reset) without
|
|
34
|
+
* touching its prior-run documents, which the success sweep or a failed run
|
|
35
|
+
* still owns.
|
|
36
|
+
*
|
|
37
|
+
* @param sourceIri The dataset IRI stamped on the documents as `source`
|
|
38
|
+
* @param runId The current run, stamped on its documents as `last_seen`
|
|
39
|
+
*/
|
|
40
|
+
export declare function thisRunDocumentsFilter(sourceIri: string, runId: string): string;
|
|
41
|
+
/**
|
|
42
|
+
* Typesense filters deleting every departed source’s documents, combined into
|
|
43
|
+
* as few filters as fit: deletes travel in the URL query string, so each
|
|
44
|
+
* filter stays under a conservative length budget rather than listing every
|
|
45
|
+
* source in one string.
|
|
46
|
+
*
|
|
47
|
+
* @param departed The departed source IRIs ({@link departedSources})
|
|
48
|
+
*/
|
|
49
|
+
export declare function membershipSweepFilters(departed: readonly string[]): string[];
|
|
50
|
+
//# sourceMappingURL=sweep.d.ts.map
|
|
@@ -0,0 +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"}
|
package/dist/sweep.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Pure deletion planning shared by the rebuild writers: which documents leave
|
|
2
|
+
// a collection, expressed as source sets and Typesense filter strings. Kept
|
|
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.
|
|
6
|
+
import { escapeFilterValue } from './query-compiler.js';
|
|
7
|
+
/** The document field carrying the dataset IRI a document came from. */
|
|
8
|
+
export const SOURCE_FIELD = 'source';
|
|
9
|
+
/** The document field carrying the id of the run that last wrote a document. */
|
|
10
|
+
export const LAST_SEEN_FIELD = 'last_seen';
|
|
11
|
+
/**
|
|
12
|
+
* Sources whose documents must leave the index: indexed, but no longer part
|
|
13
|
+
* of the run’s selection. Selection is membership, not processing – a dataset
|
|
14
|
+
* skipped as unchanged is still selected, so its documents survive.
|
|
15
|
+
*
|
|
16
|
+
* @param indexedSources Source IRIs present in the collection
|
|
17
|
+
* @param selectedSources Source IRIs the run’s selector produced
|
|
18
|
+
*/
|
|
19
|
+
export function departedSources(indexedSources, selectedSources) {
|
|
20
|
+
const selected = new Set(selectedSources);
|
|
21
|
+
return [...indexedSources].filter((source) => !selected.has(source));
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Typesense filter matching a source’s documents that this run did not touch:
|
|
25
|
+
* everything the source no longer contains, ready for a per-source sweep.
|
|
26
|
+
*
|
|
27
|
+
* @param sourceIri The dataset IRI stamped on the documents as `source`
|
|
28
|
+
* @param runId The current run; documents it wrote carry it as `last_seen`
|
|
29
|
+
*/
|
|
30
|
+
export function staleDocumentsFilter(sourceIri, runId) {
|
|
31
|
+
return `${sourceDocumentsFilter(sourceIri)} && ${LAST_SEEN_FIELD}:!=${escapeFilterValue(runId)}`;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Typesense filter matching all of a source’s documents, ready to drop a whole
|
|
35
|
+
* source (a departed source’s membership sweep, or a Blue/green writer rolling
|
|
36
|
+
* a failed dataset out of its not-yet-live collection).
|
|
37
|
+
*
|
|
38
|
+
* @param sourceIri The dataset IRI stamped on the documents as `source`
|
|
39
|
+
*/
|
|
40
|
+
export function sourceDocumentsFilter(sourceIri) {
|
|
41
|
+
return `${SOURCE_FIELD}:=${escapeFilterValue(sourceIri)}`;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Typesense filter matching the documents **this run** wrote for a source: the
|
|
45
|
+
* inverse of {@link staleDocumentsFilter}. An In-place writer deletes these to
|
|
46
|
+
* discard a source’s in-progress writes (the dump-fallback reset) without
|
|
47
|
+
* touching its prior-run documents, which the success sweep or a failed run
|
|
48
|
+
* still owns.
|
|
49
|
+
*
|
|
50
|
+
* @param sourceIri The dataset IRI stamped on the documents as `source`
|
|
51
|
+
* @param runId The current run, stamped on its documents as `last_seen`
|
|
52
|
+
*/
|
|
53
|
+
export function thisRunDocumentsFilter(sourceIri, runId) {
|
|
54
|
+
return `${sourceDocumentsFilter(sourceIri)} && ${LAST_SEEN_FIELD}:=${escapeFilterValue(runId)}`;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Typesense filters deleting every departed source’s documents, combined into
|
|
58
|
+
* as few filters as fit: deletes travel in the URL query string, so each
|
|
59
|
+
* filter stays under a conservative length budget rather than listing every
|
|
60
|
+
* source in one string.
|
|
61
|
+
*
|
|
62
|
+
* @param departed The departed source IRIs ({@link departedSources})
|
|
63
|
+
*/
|
|
64
|
+
export function membershipSweepFilters(departed) {
|
|
65
|
+
const filters = [];
|
|
66
|
+
let chunk = [];
|
|
67
|
+
let chunkLength = 0;
|
|
68
|
+
const flush = () => {
|
|
69
|
+
if (chunk.length > 0) {
|
|
70
|
+
filters.push(`${SOURCE_FIELD}:=[${chunk.join(',')}]`);
|
|
71
|
+
chunk = [];
|
|
72
|
+
chunkLength = 0;
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
for (const source of departed) {
|
|
76
|
+
const escaped = escapeFilterValue(source);
|
|
77
|
+
if (chunkLength + escaped.length > MAX_FILTER_VALUES_LENGTH) {
|
|
78
|
+
flush();
|
|
79
|
+
}
|
|
80
|
+
chunk.push(escaped);
|
|
81
|
+
chunkLength += escaped.length + 1;
|
|
82
|
+
}
|
|
83
|
+
flush();
|
|
84
|
+
return filters;
|
|
85
|
+
}
|
|
86
|
+
/** Stay well under Typesense’s ~4000-char URL query-string limit per delete. */
|
|
87
|
+
const MAX_FILTER_VALUES_LENGTH = 3000;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lde/search-typesense",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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,12 +25,16 @@
|
|
|
25
25
|
"!**/*.tsbuildinfo"
|
|
26
26
|
],
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@lde/search": "^0.
|
|
28
|
+
"@lde/search": "^0.3.0",
|
|
29
29
|
"@lde/text-normalization": "^0.1.1",
|
|
30
30
|
"tslib": "^2.3.0",
|
|
31
31
|
"typesense": "^3.0.6"
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
34
|
"testcontainers": "^12.0.3"
|
|
35
|
+
},
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"@lde/dataset": "^0.7.8",
|
|
38
|
+
"@lde/pipeline": "^0.33.0"
|
|
35
39
|
}
|
|
36
40
|
}
|
package/dist/adapter.d.ts
DELETED
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
import type { Client } from 'typesense';
|
|
2
|
-
import type { SearchType } from '@lde/search';
|
|
3
|
-
import { type CollectionSchemaOptions } from './collection-schema.js';
|
|
4
|
-
/** {@link rebuild} options: the collection-schema options (`name` is the
|
|
5
|
-
* logical index name the alias is kept on) plus the rebuild tuning knobs. */
|
|
6
|
-
export interface RebuildOptions extends CollectionSchemaOptions {
|
|
7
|
-
/** Documents imported per Typesense request (default 1000). */
|
|
8
|
-
readonly batchSize?: number;
|
|
9
|
-
/** A held lock older than this (ms) is reclaimed (default 10 minutes). */
|
|
10
|
-
readonly lockTtlMs?: number;
|
|
11
|
-
}
|
|
12
|
-
/**
|
|
13
|
-
* Blue/green-rebuild the search index `options.name` from one declarative
|
|
14
|
-
* source: the collection schema is derived from `searchType`
|
|
15
|
-
* ({@link buildCollectionSchema}) and the documents are streamed in — one call
|
|
16
|
-
* from declaration to live index.
|
|
17
|
-
*
|
|
18
|
-
* 1. create a fresh versioned collection (`${name}_<timestamp>`)
|
|
19
|
-
* 2. stream `documents` into it in batches
|
|
20
|
-
* 3. atomically repoint the `name` alias to the new collection, then
|
|
21
|
-
* drop the collection it superseded. The caller passes only the logical
|
|
22
|
-
* index `name`; the versioned collection name and the alias are managed here.
|
|
23
|
-
*
|
|
24
|
-
* The rebuild is **single-flight per index**: it first takes a lock (a marker
|
|
25
|
-
* document in a `rebuild_locks` collection, created on demand) via Typesense’s
|
|
26
|
-
* atomic create, so concurrent callers across pods never rebuild the same index
|
|
27
|
-
* at once. This keeps blue/green safe under replication.
|
|
28
|
-
*
|
|
29
|
-
* `documents` is an async iterable (e.g. a streaming projection); only one
|
|
30
|
-
* `batchSize`-sized chunk is held in memory at a time. On any failure before the
|
|
31
|
-
* swap nothing is repointed, so the live alias never points at a partial build,
|
|
32
|
-
* and the orphaned half-built collection is dropped.
|
|
33
|
-
*
|
|
34
|
-
* @returns the live collection name and the number of documents imported, or
|
|
35
|
-
* `null` when the rebuild was skipped because another rebuild for the same index
|
|
36
|
-
* was already running.
|
|
37
|
-
*
|
|
38
|
-
* Limitations:
|
|
39
|
-
* - **Advisory, not a strict mutex.** The lock is built on Typesense, not a
|
|
40
|
-
* consensus store. Under a TTL-reclaim race two rebuilds can briefly run at
|
|
41
|
-
* once; this is safe because blue/green is idempotent (worst case: redundant
|
|
42
|
-
* work and a transient orphaned collection).
|
|
43
|
-
* - **Single-flight, not coalescing.** A call made while a rebuild is in flight
|
|
44
|
-
* is skipped (returns `null`), not queued. If you must capture state that
|
|
45
|
-
* changed mid-build, re-trigger after the running rebuild finishes.
|
|
46
|
-
* - **Lock TTL.** A rebuild that runs longer than `lockTtlMs` (default 10
|
|
47
|
-
* minutes) can be reclaimed by another caller and run concurrently; size the
|
|
48
|
-
* TTL above your longest rebuild.
|
|
49
|
-
*/
|
|
50
|
-
export declare function rebuild<Document extends {
|
|
51
|
-
id: string;
|
|
52
|
-
}>(client: Client, documents: AsyncIterable<Document>, searchType: SearchType, options: RebuildOptions): Promise<{
|
|
53
|
-
collection: string;
|
|
54
|
-
imported: number;
|
|
55
|
-
} | null>;
|
|
56
|
-
//# sourceMappingURL=adapter.d.ts.map
|