@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/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';
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}). Every engine specific
39
- * stays here; consumers see only logical documents.
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
@@ -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,EAEL,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;;;;;;;;;;;;;;GAcG;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,CAkGrB;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,CAyBtC;AAsBD,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"}
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}). Every engine specific
12
- * stays here; consumers see only logical documents.
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
- // Cached path: the once-loaded full collection serves labels by in-memory
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
- // Labels are supplementary: a failed lookup (e.g. the sidecar collection
80
- // mid-rebuild) degrades to id-only references rather than failing the whole
81
- // search, so the listing still renders with bare IRIs.
82
- let labels = new Map();
83
- if (cachedLabelsPromise !== undefined) {
84
- labels = await cachedLabelsPromise;
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
- else if (options.labelsCollection !== undefined) {
87
- try {
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
- return parseSearchResponse(response, searchType, labels);
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
  }
@@ -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.2.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.2.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