@voltro/plugin-search 0.33.0 → 0.35.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,119 @@ 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
+
199
+ export declare const makeSearchStatsBuffer: (options: SearchStatsBufferOptions) => SearchStatsBuffer;
200
+
68
201
  /** Meilisearch. Native: filters (range+negation), facets
69
202
  * (`facets`→`facetDistribution`), highlighting (`attributesToHighlight`), typo
70
203
  * tolerance (on by default; `fuzziness:0` disables), engine-param passthrough.
71
204
  * DEGRADED: Meilisearch has no numeric edit-distance knob — a `fuzziness`
72
205
  * number other than 0 falls back to its built-in typo tolerance (documented). */
73
- export declare const meilisearchBackend: (cfg: VendorConfig) => SearchBackend;
206
+ export declare const meilisearchBackend: (cfg: VendorConfig, hooks?: BackendHooks) => SearchBackend;
207
+
208
+ /**
209
+ * A backend that is BOTH per-process and non-durable, tagged so the plugin can
210
+ * recognise it however it arrived — as the `'memory'` string, as the
211
+ * zero-config default, or handed in through the `SearchBackend` slot.
212
+ *
213
+ * The tag exists because the boot refusal (`./memoryGuard`) would otherwise be
214
+ * decorative: `backend: memoryBackend()` is the same deployment as
215
+ * `backend: 'memory'` and must read the same to the guard.
216
+ */
217
+ export declare interface MemoryBackend extends SearchBackend {
218
+ /** Discriminator read by `backendKindOf` — see `./memoryGuard`. */
219
+ readonly engineKind: 'memory';
220
+ }
74
221
 
75
222
  /** In-process inverted-ish search: substring (or fuzzy) match over stringified
76
223
  * doc values + structured filters. Computes facets + highlights ITSELF (it's
77
224
  * the honest-degradation reference — every feature works, just not at scale).
78
225
  * Not for scale — but correct + dependency-free, and the right default for
79
- * dev/tests/single-process apps. */
80
- export declare const memoryBackend: () => SearchBackend;
226
+ * dev/tests/single-process apps.
227
+ *
228
+ * It holds every doc in THIS process's heap: a second replica has its own
229
+ * copy, and a restart starts empty. `./memoryGuard` is what keeps that from
230
+ * being a production surprise. */
231
+ export declare const memoryBackend: () => MemoryBackend;
232
+
233
+ export declare interface MemoryBackendAuditInput {
234
+ readonly backendKind: SearchBackendKind;
235
+ /** `NODE_ENV === 'production'`. */
236
+ readonly production: boolean;
237
+ /** The app asserted the single-process + re-seed contract — see
238
+ * `SearchPluginOptions.singleProcessMemoryIndex`. */
239
+ readonly singleProcessDeclared: boolean;
240
+ }
241
+
242
+ /**
243
+ * `'quiet'` says nothing at all (the common, correct cases). `'note'` logs at
244
+ * info — the app declared the memory contract and we restate what it signed up
245
+ * for, once, at boot. `'refuse'` aborts the boot.
246
+ */
247
+ export declare type MemoryBackendVerdict = {
248
+ readonly level: 'quiet';
249
+ } | {
250
+ readonly level: 'note';
251
+ readonly message: string;
252
+ } | {
253
+ readonly level: 'refuse';
254
+ readonly message: string;
255
+ };
256
+
257
+ /** In-process ledger. The default until `bindDataStore` swaps in the durable
258
+ * one — it still makes drift OBSERVABLE (`/indexes` reports it) and still
259
+ * self-heals in a running dev process; it just does not survive a restart. */
260
+ export declare const memoryDriftLedger: () => DriftLedger;
81
261
 
82
262
  export declare const memoryStatsStore: () => StatsStore;
83
263
 
264
+ /**
265
+ * The message logged when a peer replica actually announces itself while this
266
+ * process is serving search from its own heap. Fires once per boot, in ANY
267
+ * environment — this is an observation, so it does not care what `NODE_ENV`
268
+ * says.
269
+ */
270
+ export declare const peerObservedWarning: (peerInstanceId: string, singleProcessDeclared: boolean) => string;
271
+
272
+ export declare type ResolvedSyncOptions = {
273
+ readonly [K in keyof typeof SYNC_DEFAULTS]: number;
274
+ };
275
+
276
+ export declare const resolveSyncOptions: (o: SearchSyncOptions | undefined) => ResolvedSyncOptions;
277
+
278
+ /**
279
+ * The surviving keys, split by HOW they were allowed — the two halves merge on
280
+ * opposite sides of the adapter's own mapped params:
281
+ *
282
+ * - `params` (built-in list) merges FIRST, so a wire caller can fill a gap
283
+ * the adapter leaves but never overwrite something it mapped. That is the
284
+ * hedge against the allowlist itself being wrong about a key.
285
+ * - `appAllowed` (this app's `allowedEngineParams`) merges LAST, because the
286
+ * app owner widened it on purpose — usually to reach a key the adapter
287
+ * defaults (Typesense `query_by: '*'`), which merging first would make a
288
+ * silent no-op.
289
+ */
290
+ export declare interface SanitizedEngineParams {
291
+ readonly params: Record<string, unknown>;
292
+ readonly appAllowed: Record<string, unknown>;
293
+ /** The keys that survived neither, in input order. Never silent: the caller
294
+ * asked for something the server refused, and that changes the answer. */
295
+ readonly dropped: ReadonlyArray<string>;
296
+ }
297
+
298
+ export declare const sanitizeEngineParams: (engine: SearchEngine, params: Record<string, unknown> | undefined, extraAllowed: ReadonlyArray<string> | undefined) => SanitizedEngineParams;
299
+
84
300
  /**
85
301
  * Compute the backend filters for a query, FAIL-CLOSED on tenant scoping.
86
302
  * Returns `null` when the caller must see NOTHING: a tenant-scoped index
@@ -91,6 +307,8 @@ export declare const memoryStatsStore: () => StatsStore;
91
307
  */
92
308
  export declare const scopeSearchFilters: (tenantField: string | undefined, tenantId: string | null, filters: ReadonlyArray<SearchFilter> | undefined) => ReadonlyArray<SearchFilter> | null;
93
309
 
310
+ export declare const SEARCH_DRIFT_TABLE = "_voltro_search_drift";
311
+
94
312
  export declare const SEARCH_STATS_TABLE = "_voltro_search_stats";
95
313
 
96
314
  export declare interface SearchBackend {
@@ -119,11 +337,65 @@ declare const SearchBackendError_base: new <A extends Record<string, any> = {}>(
119
337
  readonly _tag: "SearchBackendError";
120
338
  } & Readonly<A>;
121
339
 
340
+ /**
341
+ * How the resolved backend answers queries — not which vendor it is.
342
+ *
343
+ * `'custom'` is an app-supplied `SearchBackend` the framework knows nothing
344
+ * about; the app owns its durability. `memoryBackend()` is NOT custom however
345
+ * it was passed (it carries `engineKind: 'memory'`), because it is the same
346
+ * deployment either way.
347
+ */
348
+ export declare type SearchBackendKind = 'memory' | 'vendor' | 'custom';
349
+
350
+ /** Thrown from `onActivate` to abort the boot. */
351
+ export declare class SearchBackendNotDurable extends Error {
352
+ readonly _tag = "SearchBackendNotDurable";
353
+ constructor(message: string);
354
+ }
355
+
122
356
  export declare interface SearchDoc {
123
357
  readonly id: string;
124
358
  readonly [field: string]: unknown;
125
359
  }
126
360
 
361
+ /**
362
+ * `_voltro_search_drift` — one row per (indexName, rowId) that is known to be
363
+ * missing from / stale in the index. UNIQUE over the pair so every replica's
364
+ * `insertIgnore` is idempotent and a repeatedly-failing row stays ONE entry.
365
+ *
366
+ * A `_voltro_*` table needs an explicit `id({ prefix })` (a typeid prefix
367
+ * cannot be derived from a name starting with `_`), and needs no codemod: the
368
+ * declarative differ reconciles it on `voltro db apply` and on a `voltro dev`
369
+ * boot, on every dialect.
370
+ */
371
+ export declare const searchDriftTable: DriftTable;
372
+
373
+ export declare type SearchEngine = 'typesense' | 'meilisearch' | 'algolia';
374
+
375
+ /**
376
+ * A field name the caller supplied — in `filters[].field`, `facets[]` or
377
+ * `highlight.fields[]` — was refused before it could reach the engine.
378
+ *
379
+ * `reason: 'not-an-identifier'` — the name is not a plain field path
380
+ * (`^[A-Za-z_][A-Za-z0-9_.]*$`). Every vendor adapter interpolates the name
381
+ * into that engine's filter DSL, so anything else is a control-plane injection
382
+ * (a Typesense `filter_by` is ONE flat string with `||`, so a crafted name
383
+ * re-groups the boolean tree around the appended tenant clause).
384
+ *
385
+ * `reason: 'not-queryable'` — the name is well-formed but not in the index's
386
+ * declared `queryableFields` allowlist.
387
+ */
388
+ export declare class SearchFieldRejected extends SearchFieldRejected_base {
389
+ }
390
+
391
+ declare const SearchFieldRejected_base: Schema.TaggedErrorClass<SearchFieldRejected, "SearchFieldRejected", {
392
+ readonly _tag: Schema.tag<"SearchFieldRejected">;
393
+ } & {
394
+ field: typeof Schema.String;
395
+ where: Schema.Literal<["filter", "facet", "highlight"]>;
396
+ reason: Schema.Literal<["not-an-identifier", "not-queryable"]>;
397
+ }>;
398
+
127
399
  export declare interface SearchFilter {
128
400
  readonly field: string;
129
401
  readonly op: SearchFilterOp;
@@ -147,13 +419,80 @@ export declare interface SearchHit {
147
419
  readonly highlights?: Record<string, string>;
148
420
  }
149
421
 
422
+ /**
423
+ * The queried index is not one the app declared in `searchPlugin({ indexes })`.
424
+ *
425
+ * This is a REFUSAL, not an empty result, and the distinction is the whole
426
+ * point: an unknown index has no `IndexSpec`, therefore no `tenantField`,
427
+ * therefore no tenant clause — so answering it would run an unfiltered query
428
+ * against whatever collection of that name exists on the (usually shared)
429
+ * engine. Fail instead of querying something we cannot scope.
430
+ */
431
+ export declare class SearchIndexNotFound extends SearchIndexNotFound_base {
432
+ }
433
+
434
+ declare const SearchIndexNotFound_base: Schema.TaggedErrorClass<SearchIndexNotFound, "SearchIndexNotFound", {
435
+ readonly _tag: Schema.tag<"SearchIndexNotFound">;
436
+ } & {
437
+ index: typeof Schema.String;
438
+ }>;
439
+
150
440
  export declare const searchPlugin: (options: SearchPluginOptions) => VoltroPlugin;
151
441
 
152
442
  export declare interface SearchPluginOptions {
153
443
  readonly backend?: SearchBackendConfig;
154
444
  /** Map of source table → index spec. */
155
445
  readonly indexes: Record<string, IndexSpec>;
446
+ /** Retry / drift-repair tunables — see {@link SearchSyncOptions}. */
447
+ readonly sync?: SearchSyncOptions;
448
+ /**
449
+ * Namespace for this plugin's rpc tags + inspect endpoints. Default `search`.
450
+ *
451
+ * Set it when your app already publishes under that name — an exact tag
452
+ * collision is fatal at codegen, and this is the way out. Orthogonal to
453
+ * `name` below: `alias` REPLACES the namespace, `name` distinguishes two
454
+ * installations within it.
455
+ *
456
+ * The cost, stated because nothing else states it: the local and cloud
457
+ * dashboards fetch this plugin's panel at the DEFAULT slug, so an aliased
458
+ * install keeps working while its dashboard panel 404s. Alias to escape a
459
+ * collision, not for taste.
460
+ */
461
+ readonly alias?: string;
462
+ /**
463
+ * Discriminator for a SECOND installation of this plugin, when one app runs
464
+ * two (`@voltro/plugin-search#analytics`). Not a rename — for that use
465
+ * `alias`.
466
+ */
156
467
  readonly name?: string;
468
+ /**
469
+ * Extra `engineParams` keys a wire caller may set, on top of the built-in
470
+ * per-engine allowlist of presentation-only keys (paging / ordering / typo
471
+ * tolerance / highlight shaping).
472
+ *
473
+ * This is the deliberate, server-side widening of the escape hatch — an app
474
+ * that needs, say, Typesense's `query_by` says so in `app.config.ts` rather
475
+ * than every caller getting it. Keys that select the DOCUMENT SET
476
+ * (`filter_by`, `filter`, `facetFilters`, `restrictSearchableAttributes`,
477
+ * `preset`, `pinned_hits`, …) stay refused even when listed here: they are
478
+ * the authority the injected tenant clause holds, and a filter belongs in the
479
+ * validated `filters[]` list, which is ANDed with that clause.
480
+ */
481
+ readonly allowedEngineParams?: ReadonlyArray<string>;
482
+ /**
483
+ * Assert that this deployment runs the in-memory backend the only way it can
484
+ * be correct outside dev: **exactly one process**, which **re-seeds every
485
+ * index at startup** (a `*.startup.tsx` calling `backfillIndex`).
486
+ *
487
+ * Without it, `NODE_ENV=production` + the memory backend REFUSES to boot —
488
+ * see `./memoryGuard` for why a single replica is not on its own enough (the
489
+ * heap-resident index is empty after every deploy).
490
+ *
491
+ * This is not a mute switch. It is a claim the plugin holds you to: if the
492
+ * membership registry reports a peer replica, the plugin logs that the claim
493
+ * has been contradicted, in any environment.
494
+ */
495
+ readonly singleProcessMemoryIndex?: boolean;
157
496
  }
158
497
 
159
498
  export declare interface SearchQuery {
@@ -170,9 +509,15 @@ export declare interface SearchQuery {
170
509
  readonly highlight?: HighlightSpec;
171
510
  /** Typo tolerance. Omit ⇒ the engine default (memory: exact substring). */
172
511
  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). */
512
+ /** Escape hatch, ALLOWLISTED per engine: keys on that engine's
513
+ * presentation-only list (paging / ordering / typo / highlight see
514
+ * `sanitize.ts`) are shallow-merged BEFORE the mapped params, so they can
515
+ * never out-vote the injected tenant filter; keys the APP widened to via
516
+ * `BackendHooks.allowEngineParams` merge after (that widening is
517
+ * server-side config, and its point is to reach a key the adapter
518
+ * defaults). Every other key is dropped and reported through
519
+ * `BackendHooks.onWarn`. Ignored by the memory backend (nothing to forward
520
+ * to). */
176
521
  readonly engineParams?: Record<string, unknown>;
177
522
  }
178
523
 
@@ -183,33 +528,161 @@ export declare interface SearchResult {
183
528
  readonly facets: FacetCounts;
184
529
  }
185
530
 
531
+ export declare interface SearchStatsBuffer {
532
+ /** Count one sync/remove/drop. Synchronous, no I/O. */
533
+ readonly bump: (index: string, kind: StatsKind, scope: StatsScope) => void;
534
+ /** Write everything pending now. */
535
+ readonly flushNow: () => Promise<void>;
536
+ /** Flush the tail and stop the timer. Idempotent. */
537
+ readonly shutdown: () => Promise<void>;
538
+ /** Buffered bump count — for tests and the truthfulness flush in `/indexes`. */
539
+ readonly pending: () => number;
540
+ }
541
+
542
+ export declare interface SearchStatsBufferOptions {
543
+ /** Apply one index's accumulated window. Must ADD the deltas — never assign. */
544
+ readonly flush: (index: string, delta: StatsDelta, scope: StatsScope) => Promise<void>;
545
+ /** Flush cadence (ms). Tunable via `searchPlugin({ sync: { statsFlushIntervalMs } })`. */
546
+ readonly intervalMs: number;
547
+ /** Flush early once this many bumps are buffered ACROSS all indexes, so a
548
+ * burst doesn't sit a whole window away from durability. Tunable via
549
+ * `sync.statsFlushMaxBuffered`. */
550
+ readonly maxBuffered: number;
551
+ }
552
+
186
553
  /** `_voltro_search_stats` — one row per (indexName, replicaId). UNIQUE over the
187
554
  * pair so each replica's create-path `insertIgnore` is idempotent and the CAS
188
555
  * increment targets exactly its own row (no cross-replica contention). */
189
556
  export declare const searchStatsTable: StatsTable;
190
557
 
558
+ /**
559
+ * Durability knobs for the change→index sync. Every number the plugin would
560
+ * otherwise pick on your behalf lives here, with a default.
561
+ *
562
+ * The retry only fires for a failure the backend marked `transient` (network /
563
+ * engine unavailable / 5xx / rate-limited). A permanent failure — an
564
+ * unsupported query shape, a `map(row)` that throws on this row — is NOT
565
+ * retried: repeating it costs latency and changes nothing. Both kinds end in
566
+ * the drift ledger, which is what makes the distinction safe to draw.
567
+ */
568
+ export declare interface SearchSyncOptions {
569
+ /** Retries AFTER the first attempt, for a `transient` failure. Default 5
570
+ * (≈ 200 ms → 3.2 s of backoff before giving up). `0` disables retry. */
571
+ readonly retries?: number;
572
+ /** First backoff delay; doubles per attempt. Default 200 ms. */
573
+ readonly retryBaseDelayMs?: number;
574
+ /** Backoff ceiling — no single wait exceeds this. Default 10_000 ms. */
575
+ readonly retryMaxDelayMs?: number;
576
+ /**
577
+ * How often the cluster-coordinated sweep repairs drift-ledger entries.
578
+ * Default 60_000 ms. `0` turns the automatic sweep off entirely, leaving the
579
+ * `POST /resync` inspect endpoint as the (manual) repair path — the ledger
580
+ * still records, so nothing is lost, it just waits for an operator.
581
+ */
582
+ readonly resyncIntervalMs?: number;
583
+ /** Max ledger entries repaired per sweep. Default 200. */
584
+ readonly resyncBatchSize?: number;
585
+ /**
586
+ * Rows per page for the `/reindex` backfill. The endpoint STREAMS the table
587
+ * (keyset-paginated `streamTable`) and upserts one page at a time, so memory
588
+ * is bounded by this number regardless of table size — it never loads the
589
+ * whole table. Default 1000.
590
+ */
591
+ readonly reindexBatchSize?: number;
592
+ /**
593
+ * How often buffered sync-stat counters are flushed to
594
+ * `_voltro_search_stats` (ms). Counting is in-memory per event; the
595
+ * read+CAS against the OLTP primary happens once per window per index
596
+ * instead of once per write. Default 5000. `0` disables buffering — every
597
+ * event pays the per-write CAS again (the pre-buffer behaviour).
598
+ *
599
+ * The trade, stated plainly: a CRASH loses the current window (up to this
600
+ * many ms of counts). A graceful shutdown loses nothing — the plugin's
601
+ * deactivate flushes the tail. Counters, not billing inputs.
602
+ */
603
+ readonly statsFlushIntervalMs?: number;
604
+ /** Flush the stats buffer early once this many bumps are pending across all
605
+ * indexes, so a burst is never a whole window away from durability.
606
+ * Default 1000. */
607
+ readonly statsFlushMaxBuffered?: number;
608
+ }
609
+
191
610
  /** All tables for `extendSchema.tables`. */
192
611
  export declare const searchTables: () => ReadonlyArray<StatsTable>;
193
612
 
613
+ /** One flushed window of buffered counts — what `StatsStore.add` applies in a
614
+ * single CAS round instead of one round-trip per event. */
615
+ export declare interface StatsDelta {
616
+ readonly synced: number;
617
+ readonly removed: number;
618
+ readonly dropped: number;
619
+ }
620
+
621
+ /** What a bump counts. `dropped` is the drift counter — the one an operator
622
+ * must be able to see WITHOUT reading logs. */
623
+ export declare type StatsKind = 'synced' | 'removed' | 'dropped';
624
+
625
+ /** The store's change visibility, as `PluginChangeEvent.changeScope` reports
626
+ * it — it decides SUM vs MAX on read (see the header). */
627
+ export declare type StatsScope = 'local' | 'fleet';
628
+
194
629
  /** The stats store. All ops key on the index name; the durable impl fans out to
195
630
  * per-replica rows internally and aggregates on read. */
196
631
  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}. */
632
+ /** +1 synced (an insert/update mirrored), +1 removed (a delete), or +1
633
+ * dropped (retries exhausted drift). `scope` is the event's changeScope;
634
+ * it decides how this replica's row aggregates with its peers'. */
635
+ readonly bump: (index: string, kind: StatsKind, scope: StatsScope) => Promise<void>;
636
+ /** Apply one buffered window's deltas — ADDS each counter (never assigns),
637
+ * in ONE CAS round. This is what the in-memory stats buffer flushes through:
638
+ * N indexed-table writes cost one read+CAS per flush window instead of one
639
+ * per write against the OLTP primary. */
640
+ readonly add: (index: string, delta: StatsDelta, scope: StatsScope) => Promise<void>;
641
+ /** Record a reindex: +count synced AND set lastReindex{At,Count}. Takes no
642
+ * scope — a reindex is one deliberate act on one replica, and stamping the
643
+ * row's `scope` from it would overwrite what the change STREAM reported. */
200
644
  readonly recordReindex: (index: string, count: number, at: string) => Promise<void>;
201
- /** Aggregated stats for one index (summed across replicas). */
645
+ /** Aggregated stats for one index (summed or maxed across replicas — see the
646
+ * file header). */
202
647
  readonly get: (index: string) => Promise<IndexStats>;
203
648
  }
204
649
 
650
+ /** Adapt a (live-rebindable) StatsStore into the buffer's flush fn. Reads the
651
+ * store through the getter at FLUSH time, so the memory→durable swap
652
+ * `bindDataStore` performs is honoured by windows buffered before it ran. */
653
+ export declare const statsStoreFlush: (store: () => StatsStore) => SearchStatsBufferOptions["flush"];
654
+
205
655
  declare interface StatsTable extends TableLike {
206
656
  readonly fields: Record<string, ColumnDefinition<unknown>>;
207
657
  readonly appliedIndexes: ReadonlyArray<TableIndex>;
208
658
  }
209
659
 
660
+ /** Defaults for every {@link SearchSyncOptions} knob, in ONE place — the
661
+ * `/indexes` panel reports the policy actually in force from here, so a
662
+ * default can never be a number only the source knows. */
663
+ export declare const SYNC_DEFAULTS: {
664
+ readonly retries: 5;
665
+ readonly retryBaseDelayMs: 200;
666
+ readonly retryMaxDelayMs: 10000;
667
+ readonly resyncIntervalMs: 60000;
668
+ readonly resyncBatchSize: 200;
669
+ readonly reindexBatchSize: 1000;
670
+ readonly statsFlushIntervalMs: 5000;
671
+ readonly statsFlushMaxBuffered: 1000;
672
+ };
673
+
674
+ /**
675
+ * Bounded, capped exponential backoff — and the first READER of
676
+ * `SearchBackendError.transient`, which every vendor adapter has been setting
677
+ * into the void. `whileInput` exits the retry the moment the failure is
678
+ * permanent; `intersect(recurs)` bounds the attempts; `either(spaced)` caps a
679
+ * single wait at `retryMaxDelayMs`.
680
+ */
681
+ export declare const syncRetrySchedule: (o: ResolvedSyncOptions) => Schedule.Schedule<unknown, SearchBackendError>;
682
+
210
683
  /** Typesense (self-host / cloud). Native: filters (range+negation), facets,
211
684
  * highlighting, typo tolerance (`num_typos`), engine-param passthrough. */
212
- export declare const typesenseBackend: (cfg: VendorConfig) => SearchBackend;
685
+ export declare const typesenseBackend: (cfg: VendorConfig, hooks?: BackendHooks) => SearchBackend;
213
686
 
214
687
  export declare interface VendorConfig {
215
688
  readonly url?: string;