@voltro/plugin-search 0.32.0 → 0.34.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.d.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  import { ColumnDefinition } from '@voltro/database';
2
2
  import { DataStore } from '@voltro/database';
3
3
  import { PluginChangeEvent } from '@voltro/protocol';
4
+ import { QueryDescriptor } from '@voltro/database';
5
+ import { Schedule } from 'effect';
6
+ import { Schema } from 'effect';
4
7
  import { TableIndex } from '@voltro/database';
5
8
  import { TableLike } from '@voltro/database';
6
9
  import { VoidIfEmpty } from 'effect/Types';
@@ -12,14 +15,56 @@ import { YieldableError } from 'effect/Cause';
12
15
  * tolerance (`typoTolerance`), engine-param passthrough.
13
16
  * DEGRADED: like Meili, no numeric edit-distance knob — `fuzziness:0` maps to
14
17
  * `typoTolerance:false`; other values keep Algolia's built-in tolerance. */
15
- export declare const algoliaBackend: (cfg: VendorConfig) => SearchBackend;
18
+ export declare const algoliaBackend: (cfg: VendorConfig, hooks?: BackendHooks) => SearchBackend;
19
+
20
+ /** The engineParams keys this engine accepts from a wire caller — exported so
21
+ * the docs and tests read the same list the runtime enforces. */
22
+ export declare const allowedEngineParams: (engine: SearchEngine) => ReadonlyArray<string>;
16
23
 
17
24
  /** Apply one ChangeEvent to the backend (pure-ish; exported for tests). */
18
25
  export declare const applyChange: (backend: SearchBackend, spec: IndexSpec, event: PluginChangeEvent) => Promise<void>;
19
26
 
27
+ /**
28
+ * Coerce whatever the sync path threw into the typed error the retry policy
29
+ * reads. Anything that is not a `SearchBackendError` — a `map(row)` that threw
30
+ * a TypeError on one row's shape — is by definition NOT transient: retrying a
31
+ * deterministic throw spends the whole budget and cannot succeed.
32
+ */
33
+ export declare const asBackendError: (e: unknown) => SearchBackendError;
34
+
35
+ export declare const auditMemoryBackend: (input: MemoryBackendAuditInput) => MemoryBackendVerdict;
36
+
37
+ /** Anything the `backend` option accepts, narrowed to what the guard reads. */
38
+ export declare type BackendConfigLike = undefined | 'memory' | (SearchBackend & {
39
+ readonly engineKind?: string;
40
+ }) | {
41
+ readonly engine?: string;
42
+ };
43
+
44
+ /**
45
+ * Server-side knobs a vendor backend takes from whoever CONSTRUCTS it (the
46
+ * plugin, from `app.config.ts`). Deliberately not part of `VendorConfig` and
47
+ * never reachable from the wire — these decide how much of a wire caller's
48
+ * `engineParams` is honoured, so a caller must not be able to set them.
49
+ */
50
+ export declare interface BackendHooks {
51
+ /** Extra `engineParams` keys THIS app trusts, on top of the built-in
52
+ * presentation-only allowlist. Keys that select the document set
53
+ * (`filter_by`, `facetFilters`, `restrictSearchableAttributes`, …) stay
54
+ * refused regardless — see `NEVER_ALLOWED_ENGINE_PARAMS`. */
55
+ readonly allowEngineParams?: ReadonlyArray<string>;
56
+ /** Called when a query is stripped of `engineParams` keys, so a refusal that
57
+ * changes the answer is visible in the log rather than silent. */
58
+ readonly onWarn?: (message: string, fields?: Record<string, unknown>) => void;
59
+ }
60
+
61
+ export declare const backendKindOf: (cfg: BackendConfigLike) => SearchBackendKind;
62
+
20
63
  /** Seed an index from existing rows (call from a `*.startup.tsx` or a CLI). */
21
64
  export declare const backfillIndex: (backend: SearchBackend, spec: IndexSpec, rows: ReadonlyArray<Record<string, unknown>>) => Promise<number>;
22
65
 
66
+ export declare const dataStoreDriftLedger: (store: DataStore) => DriftLedger;
67
+
23
68
  /**
24
69
  * DataStore-backed stats. Each replica owns one row per index (keyed by
25
70
  * `replicaId`). A counter bump is a compare-and-set loop on THAT row (mirror of
@@ -29,9 +74,76 @@ export declare const backfillIndex: (backend: SearchBackend, spec: IndexSpec, ro
29
74
  */
30
75
  export declare const dataStoreStatsStore: (store: DataStore, replicaId?: string) => StatsStore;
31
76
 
77
+ /** One un-applied change, as the ledger stores it. */
78
+ export declare interface DriftEntry {
79
+ readonly indexName: string;
80
+ /** Source table the row lives in — repair re-reads it from here. */
81
+ readonly sourceTable: string;
82
+ /** Primary key of the source ROW (what repair re-reads). */
83
+ readonly rowId: string;
84
+ /** Id of the search DOC (`map(row).id`) — what repair removes when the row
85
+ * is gone. Not always the row id: `map` may re-key. */
86
+ readonly docId: string;
87
+ readonly op: DriftOp;
88
+ /** How many times applying this row has failed (first failure = 1). */
89
+ readonly attempts: number;
90
+ readonly lastError: string;
91
+ readonly firstFailedAt: string;
92
+ readonly lastFailedAt: string;
93
+ }
94
+
95
+ export declare interface DriftLedger {
96
+ /** Record (or re-record) a row whose index update did not land. */
97
+ readonly record: (entry: DriftRecord) => Promise<void>;
98
+ /** Oldest-first batch to repair. */
99
+ readonly list: (limit: number) => Promise<ReadonlyArray<DriftEntry>>;
100
+ readonly summary: (indexName: string) => Promise<DriftSummary>;
101
+ /** Drop the entry — the row is in sync again. */
102
+ readonly clear: (indexName: string, rowId: string) => Promise<void>;
103
+ }
104
+
105
+ /** What the repair has to do for this row: put the doc back, or take it out. */
106
+ export declare type DriftOp = 'upsert' | 'remove';
107
+
108
+ /** The write side of `DriftEntry` — the counters/timestamps are the ledger's. */
109
+ export declare interface DriftRecord {
110
+ readonly indexName: string;
111
+ readonly sourceTable: string;
112
+ readonly rowId: string;
113
+ readonly docId: string;
114
+ readonly op: DriftOp;
115
+ readonly error: string;
116
+ }
117
+
118
+ /** Re-read ONE source row by primary key. Exported so `index.ts` can drive the
119
+ * repair through the store handle it already holds, without importing the
120
+ * query builder itself. */
121
+ export declare const driftSourceQuery: (sourceTable: string, rowId: string) => QueryDescriptor;
122
+
123
+ /** Per-index drift, as `/indexes` reports it. `pending > 0` means the index is
124
+ * KNOWN to disagree with the database right now. */
125
+ export declare interface DriftSummary {
126
+ readonly pending: number;
127
+ readonly lastFailedAt: string | null;
128
+ }
129
+
130
+ declare interface DriftTable extends TableLike {
131
+ readonly fields: Record<string, ColumnDefinition<unknown>>;
132
+ readonly appliedIndexes: ReadonlyArray<TableIndex>;
133
+ }
134
+
135
+ /** All tables for `extendSchema.tables`. */
136
+ export declare const driftTables: () => ReadonlyArray<DriftTable>;
137
+
32
138
  /** field → (value → count). Present only for the requested `facets`. */
33
139
  export declare type FacetCounts = Record<string, Record<string, number>>;
34
140
 
141
+ /** A caller-supplied field name must be a plain field path: an identifier,
142
+ * optionally dotted for a nested attribute. No spaces, quotes, brackets,
143
+ * operators or boolean keywords — i.e. nothing that means anything to a
144
+ * filter DSL. */
145
+ export declare const FIELD_NAME_PATTERN: RegExp;
146
+
35
147
  /** Typo-tolerance knob: a max edit distance (0 = exact), or `'auto'` to let the
36
148
  * engine pick per-term length. Memory approximates `'auto'` as distance 1–2. */
37
149
  export declare type Fuzziness = number | 'auto';
@@ -52,6 +164,19 @@ export declare interface IndexSpec {
52
164
  /** Doc field carrying the tenant id — when set, `search.query` auto-filters
53
165
  * to the caller's tenant (no cross-tenant leakage). */
54
166
  readonly tenantField?: string;
167
+ /**
168
+ * Doc fields a WIRE caller may name — in `filters[].field`, `facets[]` and
169
+ * `highlight.fields[]`. When set, anything else is refused with a typed
170
+ * `SearchFieldRejected`; when omitted, any plain field path
171
+ * (`^[A-Za-z_][A-Za-z0-9_.]*$`) is accepted.
172
+ *
173
+ * Opt-in because it cannot be derived: `map(row)` is a FUNCTION, so the doc's
174
+ * field set only exists at runtime. The pattern is the floor every index gets
175
+ * without declaring anything; this narrows it to what your UI actually needs.
176
+ * `tenantField` does not belong here — the tenant clause is injected AFTER
177
+ * this check, by the server, and is never a caller's to name.
178
+ */
179
+ readonly queryableFields?: ReadonlyArray<string>;
55
180
  }
56
181
 
57
182
  /** Aggregated, cross-replica stats for one index — the exact wire shape the
@@ -59,28 +184,117 @@ export declare interface IndexSpec {
59
184
  export declare interface IndexStats {
60
185
  readonly synced: number;
61
186
  readonly removed: number;
187
+ /** Changes that exhausted the sync retry and were written to the drift
188
+ * ledger instead of reaching the engine. Cumulative; `> 0` means the index
189
+ * HAS drifted at some point (see `DriftSummary.pending` for now). */
190
+ readonly dropped: number;
62
191
  /** The MOST RECENT reindex across all replicas (max of each replica's). */
63
192
  readonly lastReindexAt: string | null;
64
193
  /** Docs seeded by that most-recent reindex. */
65
194
  readonly lastReindexCount: number;
66
195
  }
67
196
 
197
+ export declare const isSafeFieldName: (field: string) => boolean;
198
+
68
199
  /** Meilisearch. Native: filters (range+negation), facets
69
200
  * (`facets`→`facetDistribution`), highlighting (`attributesToHighlight`), typo
70
201
  * tolerance (on by default; `fuzziness:0` disables), engine-param passthrough.
71
202
  * DEGRADED: Meilisearch has no numeric edit-distance knob — a `fuzziness`
72
203
  * number other than 0 falls back to its built-in typo tolerance (documented). */
73
- export declare const meilisearchBackend: (cfg: VendorConfig) => SearchBackend;
204
+ export declare const meilisearchBackend: (cfg: VendorConfig, hooks?: BackendHooks) => SearchBackend;
205
+
206
+ /**
207
+ * A backend that is BOTH per-process and non-durable, tagged so the plugin can
208
+ * recognise it however it arrived — as the `'memory'` string, as the
209
+ * zero-config default, or handed in through the `SearchBackend` slot.
210
+ *
211
+ * The tag exists because the boot refusal (`./memoryGuard`) would otherwise be
212
+ * decorative: `backend: memoryBackend()` is the same deployment as
213
+ * `backend: 'memory'` and must read the same to the guard.
214
+ */
215
+ export declare interface MemoryBackend extends SearchBackend {
216
+ /** Discriminator read by `backendKindOf` — see `./memoryGuard`. */
217
+ readonly engineKind: 'memory';
218
+ }
74
219
 
75
220
  /** In-process inverted-ish search: substring (or fuzzy) match over stringified
76
221
  * doc values + structured filters. Computes facets + highlights ITSELF (it's
77
222
  * the honest-degradation reference — every feature works, just not at scale).
78
223
  * Not for scale — but correct + dependency-free, and the right default for
79
- * dev/tests/single-process apps. */
80
- export declare const memoryBackend: () => SearchBackend;
224
+ * dev/tests/single-process apps.
225
+ *
226
+ * It holds every doc in THIS process's heap: a second replica has its own
227
+ * copy, and a restart starts empty. `./memoryGuard` is what keeps that from
228
+ * being a production surprise. */
229
+ export declare const memoryBackend: () => MemoryBackend;
230
+
231
+ export declare interface MemoryBackendAuditInput {
232
+ readonly backendKind: SearchBackendKind;
233
+ /** `NODE_ENV === 'production'`. */
234
+ readonly production: boolean;
235
+ /** The app asserted the single-process + re-seed contract — see
236
+ * `SearchPluginOptions.singleProcessMemoryIndex`. */
237
+ readonly singleProcessDeclared: boolean;
238
+ }
239
+
240
+ /**
241
+ * `'quiet'` says nothing at all (the common, correct cases). `'note'` logs at
242
+ * info — the app declared the memory contract and we restate what it signed up
243
+ * for, once, at boot. `'refuse'` aborts the boot.
244
+ */
245
+ export declare type MemoryBackendVerdict = {
246
+ readonly level: 'quiet';
247
+ } | {
248
+ readonly level: 'note';
249
+ readonly message: string;
250
+ } | {
251
+ readonly level: 'refuse';
252
+ readonly message: string;
253
+ };
254
+
255
+ /** In-process ledger. The default until `bindDataStore` swaps in the durable
256
+ * one — it still makes drift OBSERVABLE (`/indexes` reports it) and still
257
+ * self-heals in a running dev process; it just does not survive a restart. */
258
+ export declare const memoryDriftLedger: () => DriftLedger;
81
259
 
82
260
  export declare const memoryStatsStore: () => StatsStore;
83
261
 
262
+ /**
263
+ * The message logged when a peer replica actually announces itself while this
264
+ * process is serving search from its own heap. Fires once per boot, in ANY
265
+ * environment — this is an observation, so it does not care what `NODE_ENV`
266
+ * says.
267
+ */
268
+ export declare const peerObservedWarning: (peerInstanceId: string, singleProcessDeclared: boolean) => string;
269
+
270
+ export declare type ResolvedSyncOptions = {
271
+ readonly [K in keyof typeof SYNC_DEFAULTS]: number;
272
+ };
273
+
274
+ export declare const resolveSyncOptions: (o: SearchSyncOptions | undefined) => ResolvedSyncOptions;
275
+
276
+ /**
277
+ * The surviving keys, split by HOW they were allowed — the two halves merge on
278
+ * opposite sides of the adapter's own mapped params:
279
+ *
280
+ * - `params` (built-in list) merges FIRST, so a wire caller can fill a gap
281
+ * the adapter leaves but never overwrite something it mapped. That is the
282
+ * hedge against the allowlist itself being wrong about a key.
283
+ * - `appAllowed` (this app's `allowedEngineParams`) merges LAST, because the
284
+ * app owner widened it on purpose — usually to reach a key the adapter
285
+ * defaults (Typesense `query_by: '*'`), which merging first would make a
286
+ * silent no-op.
287
+ */
288
+ export declare interface SanitizedEngineParams {
289
+ readonly params: Record<string, unknown>;
290
+ readonly appAllowed: Record<string, unknown>;
291
+ /** The keys that survived neither, in input order. Never silent: the caller
292
+ * asked for something the server refused, and that changes the answer. */
293
+ readonly dropped: ReadonlyArray<string>;
294
+ }
295
+
296
+ export declare const sanitizeEngineParams: (engine: SearchEngine, params: Record<string, unknown> | undefined, extraAllowed: ReadonlyArray<string> | undefined) => SanitizedEngineParams;
297
+
84
298
  /**
85
299
  * Compute the backend filters for a query, FAIL-CLOSED on tenant scoping.
86
300
  * Returns `null` when the caller must see NOTHING: a tenant-scoped index
@@ -91,6 +305,8 @@ export declare const memoryStatsStore: () => StatsStore;
91
305
  */
92
306
  export declare const scopeSearchFilters: (tenantField: string | undefined, tenantId: string | null, filters: ReadonlyArray<SearchFilter> | undefined) => ReadonlyArray<SearchFilter> | null;
93
307
 
308
+ export declare const SEARCH_DRIFT_TABLE = "_voltro_search_drift";
309
+
94
310
  export declare const SEARCH_STATS_TABLE = "_voltro_search_stats";
95
311
 
96
312
  export declare interface SearchBackend {
@@ -119,11 +335,65 @@ declare const SearchBackendError_base: new <A extends Record<string, any> = {}>(
119
335
  readonly _tag: "SearchBackendError";
120
336
  } & Readonly<A>;
121
337
 
338
+ /**
339
+ * How the resolved backend answers queries — not which vendor it is.
340
+ *
341
+ * `'custom'` is an app-supplied `SearchBackend` the framework knows nothing
342
+ * about; the app owns its durability. `memoryBackend()` is NOT custom however
343
+ * it was passed (it carries `engineKind: 'memory'`), because it is the same
344
+ * deployment either way.
345
+ */
346
+ export declare type SearchBackendKind = 'memory' | 'vendor' | 'custom';
347
+
348
+ /** Thrown from `onActivate` to abort the boot. */
349
+ export declare class SearchBackendNotDurable extends Error {
350
+ readonly _tag = "SearchBackendNotDurable";
351
+ constructor(message: string);
352
+ }
353
+
122
354
  export declare interface SearchDoc {
123
355
  readonly id: string;
124
356
  readonly [field: string]: unknown;
125
357
  }
126
358
 
359
+ /**
360
+ * `_voltro_search_drift` — one row per (indexName, rowId) that is known to be
361
+ * missing from / stale in the index. UNIQUE over the pair so every replica's
362
+ * `insertIgnore` is idempotent and a repeatedly-failing row stays ONE entry.
363
+ *
364
+ * A `_voltro_*` table needs an explicit `id({ prefix })` (a typeid prefix
365
+ * cannot be derived from a name starting with `_`), and needs no codemod: the
366
+ * declarative differ reconciles it on `voltro db apply` and on a `voltro dev`
367
+ * boot, on every dialect.
368
+ */
369
+ export declare const searchDriftTable: DriftTable;
370
+
371
+ export declare type SearchEngine = 'typesense' | 'meilisearch' | 'algolia';
372
+
373
+ /**
374
+ * A field name the caller supplied — in `filters[].field`, `facets[]` or
375
+ * `highlight.fields[]` — was refused before it could reach the engine.
376
+ *
377
+ * `reason: 'not-an-identifier'` — the name is not a plain field path
378
+ * (`^[A-Za-z_][A-Za-z0-9_.]*$`). Every vendor adapter interpolates the name
379
+ * into that engine's filter DSL, so anything else is a control-plane injection
380
+ * (a Typesense `filter_by` is ONE flat string with `||`, so a crafted name
381
+ * re-groups the boolean tree around the appended tenant clause).
382
+ *
383
+ * `reason: 'not-queryable'` — the name is well-formed but not in the index's
384
+ * declared `queryableFields` allowlist.
385
+ */
386
+ export declare class SearchFieldRejected extends SearchFieldRejected_base {
387
+ }
388
+
389
+ declare const SearchFieldRejected_base: Schema.TaggedErrorClass<SearchFieldRejected, "SearchFieldRejected", {
390
+ readonly _tag: Schema.tag<"SearchFieldRejected">;
391
+ } & {
392
+ field: typeof Schema.String;
393
+ where: Schema.Literal<["filter", "facet", "highlight"]>;
394
+ reason: Schema.Literal<["not-an-identifier", "not-queryable"]>;
395
+ }>;
396
+
127
397
  export declare interface SearchFilter {
128
398
  readonly field: string;
129
399
  readonly op: SearchFilterOp;
@@ -147,13 +417,80 @@ export declare interface SearchHit {
147
417
  readonly highlights?: Record<string, string>;
148
418
  }
149
419
 
420
+ /**
421
+ * The queried index is not one the app declared in `searchPlugin({ indexes })`.
422
+ *
423
+ * This is a REFUSAL, not an empty result, and the distinction is the whole
424
+ * point: an unknown index has no `IndexSpec`, therefore no `tenantField`,
425
+ * therefore no tenant clause — so answering it would run an unfiltered query
426
+ * against whatever collection of that name exists on the (usually shared)
427
+ * engine. Fail instead of querying something we cannot scope.
428
+ */
429
+ export declare class SearchIndexNotFound extends SearchIndexNotFound_base {
430
+ }
431
+
432
+ declare const SearchIndexNotFound_base: Schema.TaggedErrorClass<SearchIndexNotFound, "SearchIndexNotFound", {
433
+ readonly _tag: Schema.tag<"SearchIndexNotFound">;
434
+ } & {
435
+ index: typeof Schema.String;
436
+ }>;
437
+
150
438
  export declare const searchPlugin: (options: SearchPluginOptions) => VoltroPlugin;
151
439
 
152
440
  export declare interface SearchPluginOptions {
153
441
  readonly backend?: SearchBackendConfig;
154
442
  /** Map of source table → index spec. */
155
443
  readonly indexes: Record<string, IndexSpec>;
444
+ /** Retry / drift-repair tunables — see {@link SearchSyncOptions}. */
445
+ readonly sync?: SearchSyncOptions;
446
+ /**
447
+ * Namespace for this plugin's rpc tags + inspect endpoints. Default `search`.
448
+ *
449
+ * Set it when your app already publishes under that name — an exact tag
450
+ * collision is fatal at codegen, and this is the way out. Orthogonal to
451
+ * `name` below: `alias` REPLACES the namespace, `name` distinguishes two
452
+ * installations within it.
453
+ *
454
+ * The cost, stated because nothing else states it: the local and cloud
455
+ * dashboards fetch this plugin's panel at the DEFAULT slug, so an aliased
456
+ * install keeps working while its dashboard panel 404s. Alias to escape a
457
+ * collision, not for taste.
458
+ */
459
+ readonly alias?: string;
460
+ /**
461
+ * Discriminator for a SECOND installation of this plugin, when one app runs
462
+ * two (`@voltro/plugin-search#analytics`). Not a rename — for that use
463
+ * `alias`.
464
+ */
156
465
  readonly name?: string;
466
+ /**
467
+ * Extra `engineParams` keys a wire caller may set, on top of the built-in
468
+ * per-engine allowlist of presentation-only keys (paging / ordering / typo
469
+ * tolerance / highlight shaping).
470
+ *
471
+ * This is the deliberate, server-side widening of the escape hatch — an app
472
+ * that needs, say, Typesense's `query_by` says so in `app.config.ts` rather
473
+ * than every caller getting it. Keys that select the DOCUMENT SET
474
+ * (`filter_by`, `filter`, `facetFilters`, `restrictSearchableAttributes`,
475
+ * `preset`, `pinned_hits`, …) stay refused even when listed here: they are
476
+ * the authority the injected tenant clause holds, and a filter belongs in the
477
+ * validated `filters[]` list, which is ANDed with that clause.
478
+ */
479
+ readonly allowedEngineParams?: ReadonlyArray<string>;
480
+ /**
481
+ * Assert that this deployment runs the in-memory backend the only way it can
482
+ * be correct outside dev: **exactly one process**, which **re-seeds every
483
+ * index at startup** (a `*.startup.tsx` calling `backfillIndex`).
484
+ *
485
+ * Without it, `NODE_ENV=production` + the memory backend REFUSES to boot —
486
+ * see `./memoryGuard` for why a single replica is not on its own enough (the
487
+ * heap-resident index is empty after every deploy).
488
+ *
489
+ * This is not a mute switch. It is a claim the plugin holds you to: if the
490
+ * membership registry reports a peer replica, the plugin logs that the claim
491
+ * has been contradicted, in any environment.
492
+ */
493
+ readonly singleProcessMemoryIndex?: boolean;
157
494
  }
158
495
 
159
496
  export declare interface SearchQuery {
@@ -170,9 +507,15 @@ export declare interface SearchQuery {
170
507
  readonly highlight?: HighlightSpec;
171
508
  /** Typo tolerance. Omit ⇒ the engine default (memory: exact substring). */
172
509
  readonly fuzziness?: Fuzziness;
173
- /** Escape hatch: forwarded VERBATIM into the engine's search-params object,
174
- * shallow-merged AFTER the mapped params (so it can override them). Ignored
175
- * by the memory backend (nothing to forward to). */
510
+ /** Escape hatch, ALLOWLISTED per engine: keys on that engine's
511
+ * presentation-only list (paging / ordering / typo / highlight see
512
+ * `sanitize.ts`) are shallow-merged BEFORE the mapped params, so they can
513
+ * never out-vote the injected tenant filter; keys the APP widened to via
514
+ * `BackendHooks.allowEngineParams` merge after (that widening is
515
+ * server-side config, and its point is to reach a key the adapter
516
+ * defaults). Every other key is dropped and reported through
517
+ * `BackendHooks.onWarn`. Ignored by the memory backend (nothing to forward
518
+ * to). */
176
519
  readonly engineParams?: Record<string, unknown>;
177
520
  }
178
521
 
@@ -188,17 +531,59 @@ export declare interface SearchResult {
188
531
  * increment targets exactly its own row (no cross-replica contention). */
189
532
  export declare const searchStatsTable: StatsTable;
190
533
 
534
+ /**
535
+ * Durability knobs for the change→index sync. Every number the plugin would
536
+ * otherwise pick on your behalf lives here, with a default.
537
+ *
538
+ * The retry only fires for a failure the backend marked `transient` (network /
539
+ * engine unavailable / 5xx / rate-limited). A permanent failure — an
540
+ * unsupported query shape, a `map(row)` that throws on this row — is NOT
541
+ * retried: repeating it costs latency and changes nothing. Both kinds end in
542
+ * the drift ledger, which is what makes the distinction safe to draw.
543
+ */
544
+ export declare interface SearchSyncOptions {
545
+ /** Retries AFTER the first attempt, for a `transient` failure. Default 5
546
+ * (≈ 200 ms → 3.2 s of backoff before giving up). `0` disables retry. */
547
+ readonly retries?: number;
548
+ /** First backoff delay; doubles per attempt. Default 200 ms. */
549
+ readonly retryBaseDelayMs?: number;
550
+ /** Backoff ceiling — no single wait exceeds this. Default 10_000 ms. */
551
+ readonly retryMaxDelayMs?: number;
552
+ /**
553
+ * How often the cluster-coordinated sweep repairs drift-ledger entries.
554
+ * Default 60_000 ms. `0` turns the automatic sweep off entirely, leaving the
555
+ * `POST /resync` inspect endpoint as the (manual) repair path — the ledger
556
+ * still records, so nothing is lost, it just waits for an operator.
557
+ */
558
+ readonly resyncIntervalMs?: number;
559
+ /** Max ledger entries repaired per sweep. Default 200. */
560
+ readonly resyncBatchSize?: number;
561
+ }
562
+
191
563
  /** All tables for `extendSchema.tables`. */
192
564
  export declare const searchTables: () => ReadonlyArray<StatsTable>;
193
565
 
566
+ /** What a bump counts. `dropped` is the drift counter — the one an operator
567
+ * must be able to see WITHOUT reading logs. */
568
+ export declare type StatsKind = 'synced' | 'removed' | 'dropped';
569
+
570
+ /** The store's change visibility, as `PluginChangeEvent.changeScope` reports
571
+ * it — it decides SUM vs MAX on read (see the header). */
572
+ export declare type StatsScope = 'local' | 'fleet';
573
+
194
574
  /** The stats store. All ops key on the index name; the durable impl fans out to
195
575
  * per-replica rows internally and aggregates on read. */
196
576
  export declare interface StatsStore {
197
- /** +1 synced (an insert/update mirrored) or +1 removed (a delete). */
198
- readonly bump: (index: string, kind: 'synced' | 'removed') => Promise<void>;
199
- /** Record a reindex: +count synced AND set lastReindex{At,Count}. */
577
+ /** +1 synced (an insert/update mirrored), +1 removed (a delete), or +1
578
+ * dropped (retries exhausted drift). `scope` is the event's changeScope;
579
+ * it decides how this replica's row aggregates with its peers'. */
580
+ readonly bump: (index: string, kind: StatsKind, scope: StatsScope) => Promise<void>;
581
+ /** Record a reindex: +count synced AND set lastReindex{At,Count}. Takes no
582
+ * scope — a reindex is one deliberate act on one replica, and stamping the
583
+ * row's `scope` from it would overwrite what the change STREAM reported. */
200
584
  readonly recordReindex: (index: string, count: number, at: string) => Promise<void>;
201
- /** Aggregated stats for one index (summed across replicas). */
585
+ /** Aggregated stats for one index (summed or maxed across replicas — see the
586
+ * file header). */
202
587
  readonly get: (index: string) => Promise<IndexStats>;
203
588
  }
204
589
 
@@ -207,9 +592,29 @@ declare interface StatsTable extends TableLike {
207
592
  readonly appliedIndexes: ReadonlyArray<TableIndex>;
208
593
  }
209
594
 
595
+ /** Defaults for every {@link SearchSyncOptions} knob, in ONE place — the
596
+ * `/indexes` panel reports the policy actually in force from here, so a
597
+ * default can never be a number only the source knows. */
598
+ export declare const SYNC_DEFAULTS: {
599
+ readonly retries: 5;
600
+ readonly retryBaseDelayMs: 200;
601
+ readonly retryMaxDelayMs: 10000;
602
+ readonly resyncIntervalMs: 60000;
603
+ readonly resyncBatchSize: 200;
604
+ };
605
+
606
+ /**
607
+ * Bounded, capped exponential backoff — and the first READER of
608
+ * `SearchBackendError.transient`, which every vendor adapter has been setting
609
+ * into the void. `whileInput` exits the retry the moment the failure is
610
+ * permanent; `intersect(recurs)` bounds the attempts; `either(spaced)` caps a
611
+ * single wait at `retryMaxDelayMs`.
612
+ */
613
+ export declare const syncRetrySchedule: (o: ResolvedSyncOptions) => Schedule.Schedule<unknown, SearchBackendError>;
614
+
210
615
  /** Typesense (self-host / cloud). Native: filters (range+negation), facets,
211
616
  * highlighting, typo tolerance (`num_typos`), engine-param passthrough. */
212
- export declare const typesenseBackend: (cfg: VendorConfig) => SearchBackend;
617
+ export declare const typesenseBackend: (cfg: VendorConfig, hooks?: BackendHooks) => SearchBackend;
213
618
 
214
619
  export declare interface VendorConfig {
215
620
  readonly url?: string;