@voltro/plugin-search 0.1.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.
@@ -0,0 +1,222 @@
1
+ import { ColumnDefinition } from '@voltro/database';
2
+ import { DataStore } from '@voltro/database';
3
+ import { PluginChangeEvent } from '@voltro/protocol';
4
+ import { TableIndex } from '@voltro/database';
5
+ import { TableLike } from '@voltro/database';
6
+ import { VoidIfEmpty } from 'effect/Types';
7
+ import { VoltroPlugin } from '@voltro/protocol';
8
+ import { YieldableError } from 'effect/Cause';
9
+
10
+ /** Algolia. Native: filters (facetFilters + numericFilters), facets
11
+ * (`facets`→`facets` response), highlighting (`_highlightResult`), typo
12
+ * tolerance (`typoTolerance`), engine-param passthrough.
13
+ * DEGRADED: like Meili, no numeric edit-distance knob — `fuzziness:0` maps to
14
+ * `typoTolerance:false`; other values keep Algolia's built-in tolerance. */
15
+ export declare const algoliaBackend: (cfg: VendorConfig) => SearchBackend;
16
+
17
+ /** Apply one ChangeEvent to the backend (pure-ish; exported for tests). */
18
+ export declare const applyChange: (backend: SearchBackend, spec: IndexSpec, event: PluginChangeEvent) => Promise<void>;
19
+
20
+ /** Seed an index from existing rows (call from a `*.startup.tsx` or a CLI). */
21
+ export declare const backfillIndex: (backend: SearchBackend, spec: IndexSpec, rows: ReadonlyArray<Record<string, unknown>>) => Promise<number>;
22
+
23
+ /**
24
+ * DataStore-backed stats. Each replica owns one row per index (keyed by
25
+ * `replicaId`). A counter bump is a compare-and-set loop on THAT row (mirror of
26
+ * `plugin-billing`'s usage-store CAS): read `synced`/`removed`, conditionally
27
+ * `updateMany` gated on the read value, `insertIgnore` the first time. Reads
28
+ * aggregate every replica's row for the index.
29
+ */
30
+ export declare const dataStoreStatsStore: (store: DataStore, replicaId?: string) => StatsStore;
31
+
32
+ /** field → (value → count). Present only for the requested `facets`. */
33
+ export declare type FacetCounts = Record<string, Record<string, number>>;
34
+
35
+ /** Typo-tolerance knob: a max edit distance (0 = exact), or `'auto'` to let the
36
+ * engine pick per-term length. Memory approximates `'auto'` as distance 1–2. */
37
+ export declare type Fuzziness = number | 'auto';
38
+
39
+ export declare interface HighlightSpec {
40
+ /** Fields to highlight. Omit / empty ⇒ every text field the engine indexes. */
41
+ readonly fields?: ReadonlyArray<string>;
42
+ /** Wrapping tags around a matched term. Default `<mark>`/`</mark>`. */
43
+ readonly preTag?: string;
44
+ readonly postTag?: string;
45
+ }
46
+
47
+ export declare interface IndexSpec {
48
+ /** Target index/collection name in the backend. */
49
+ readonly index: string;
50
+ /** Map a table row → a flat search doc (must include `id`). */
51
+ readonly map: (row: Record<string, unknown>) => SearchDoc;
52
+ /** Doc field carrying the tenant id — when set, `search.query` auto-filters
53
+ * to the caller's tenant (no cross-tenant leakage). */
54
+ readonly tenantField?: string;
55
+ }
56
+
57
+ /** Aggregated, cross-replica stats for one index — the exact wire shape the
58
+ * `/indexes` inspect endpoint + the dashboard panel already consume. */
59
+ export declare interface IndexStats {
60
+ readonly synced: number;
61
+ readonly removed: number;
62
+ /** The MOST RECENT reindex across all replicas (max of each replica's). */
63
+ readonly lastReindexAt: string | null;
64
+ /** Docs seeded by that most-recent reindex. */
65
+ readonly lastReindexCount: number;
66
+ }
67
+
68
+ /** Meilisearch. Native: filters (range+negation), facets
69
+ * (`facets`→`facetDistribution`), highlighting (`attributesToHighlight`), typo
70
+ * tolerance (on by default; `fuzziness:0` disables), engine-param passthrough.
71
+ * DEGRADED: Meilisearch has no numeric edit-distance knob — a `fuzziness`
72
+ * number other than 0 falls back to its built-in typo tolerance (documented). */
73
+ export declare const meilisearchBackend: (cfg: VendorConfig) => SearchBackend;
74
+
75
+ /** In-process inverted-ish search: substring (or fuzzy) match over stringified
76
+ * doc values + structured filters. Computes facets + highlights ITSELF (it's
77
+ * the honest-degradation reference — every feature works, just not at scale).
78
+ * Not for scale — but correct + dependency-free, and the right default for
79
+ * dev/tests/single-process apps. */
80
+ export declare const memoryBackend: () => SearchBackend;
81
+
82
+ export declare const memoryStatsStore: () => StatsStore;
83
+
84
+ /**
85
+ * Compute the backend filters for a query, FAIL-CLOSED on tenant scoping.
86
+ * Returns `null` when the caller must see NOTHING: a tenant-scoped index
87
+ * (`tenantField` set) queried by a caller with no tenant (`tenantId == null`).
88
+ * A tenant-scoped index gets an `eq(tenantField, tenantId)` clause ANDed into
89
+ * whatever filters the caller passed; anon on a tenant-scoped index gets an
90
+ * empty result (not an unfiltered all-tenant query — the cross-tenant leak).
91
+ */
92
+ export declare const scopeSearchFilters: (tenantField: string | undefined, tenantId: string | null, filters: ReadonlyArray<SearchFilter> | undefined) => ReadonlyArray<SearchFilter> | null;
93
+
94
+ export declare const SEARCH_STATS_TABLE = "_voltro_search_stats";
95
+
96
+ export declare interface SearchBackend {
97
+ readonly upsert: (index: string, docs: ReadonlyArray<SearchDoc>) => Promise<void>;
98
+ readonly remove: (index: string, ids: ReadonlyArray<string>) => Promise<void>;
99
+ readonly query: (index: string, query: SearchQuery) => Promise<SearchResult>;
100
+ }
101
+
102
+ export declare type SearchBackendConfig = 'memory' | SearchBackend | ({
103
+ readonly engine: 'typesense' | 'meilisearch' | 'algolia';
104
+ } & VendorConfig);
105
+
106
+ /** Typed failure surfaced by a backend op. Tagged so a caller can discriminate
107
+ * a backend outage / bad query from a generic rpc error. `transient` marks a
108
+ * retry-worthy failure (network / engine unavailable) vs. a permanent one
109
+ * (unsupported query shape). */
110
+ export declare class SearchBackendError extends SearchBackendError_base<{
111
+ readonly engine: string;
112
+ readonly op: 'upsert' | 'remove' | 'query';
113
+ readonly message: string;
114
+ readonly transient: boolean;
115
+ }> {
116
+ }
117
+
118
+ declare const SearchBackendError_base: new <A extends Record<string, any> = {}>(args: VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => YieldableError & {
119
+ readonly _tag: "SearchBackendError";
120
+ } & Readonly<A>;
121
+
122
+ export declare interface SearchDoc {
123
+ readonly id: string;
124
+ readonly [field: string]: unknown;
125
+ }
126
+
127
+ export declare interface SearchFilter {
128
+ readonly field: string;
129
+ readonly op: SearchFilterOp;
130
+ /** Scalar for eq/neq/gt/gte/lt/lte; an array for in/nin. */
131
+ readonly value: SearchFilterValue | ReadonlyArray<SearchFilterValue>;
132
+ }
133
+
134
+ /** A single filter clause. `op` widens the old equality-only model to range
135
+ * (`gt`/`gte`/`lt`/`lte`) and negation (`neq`/`nin`). `in`/`nin` take an
136
+ * array value; the scalar ops take a string|number|boolean. */
137
+ export declare type SearchFilterOp = 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'in' | 'nin';
138
+
139
+ declare type SearchFilterValue = string | number | boolean;
140
+
141
+ /** One hit — the doc plus, when `highlight` was requested and the engine
142
+ * supports it, per-field snippet HTML. */
143
+ export declare interface SearchHit {
144
+ readonly doc: SearchDoc;
145
+ /** field → snippet with matched terms wrapped. Absent when highlight wasn't
146
+ * requested OR the engine doesn't support it (degraded — see per backend). */
147
+ readonly highlights?: Record<string, string>;
148
+ }
149
+
150
+ export declare const searchPlugin: (options: SearchPluginOptions) => VoltroPlugin;
151
+
152
+ export declare interface SearchPluginOptions {
153
+ readonly backend?: SearchBackendConfig;
154
+ /** Map of source table → index spec. */
155
+ readonly indexes: Record<string, IndexSpec>;
156
+ readonly name?: string;
157
+ }
158
+
159
+ export declare interface SearchQuery {
160
+ readonly q: string;
161
+ readonly limit?: number;
162
+ /** Skip this many leading hits before returning `limit` — offset paging. */
163
+ readonly offset?: number;
164
+ /** Filter clauses ANDed together — equality, range (`gt`/`lte`/…) and
165
+ * negation (`neq`/`nin`). Replaces the old equality-only `Record`. */
166
+ readonly filters?: ReadonlyArray<SearchFilter>;
167
+ /** Request facet counts for these fields (returned in `SearchResult.facets`). */
168
+ readonly facets?: ReadonlyArray<string>;
169
+ /** Request matched-term snippets (returned per-hit in `SearchResult.hits`). */
170
+ readonly highlight?: HighlightSpec;
171
+ /** Typo tolerance. Omit ⇒ the engine default (memory: exact substring). */
172
+ 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). */
176
+ readonly engineParams?: Record<string, unknown>;
177
+ }
178
+
179
+ export declare interface SearchResult {
180
+ readonly hits: ReadonlyArray<SearchHit>;
181
+ /** Facet counts for the requested fields. `{}` when none requested or the
182
+ * engine can't facet natively (degraded — memory computes them itself). */
183
+ readonly facets: FacetCounts;
184
+ }
185
+
186
+ /** `_voltro_search_stats` — one row per (indexName, replicaId). UNIQUE over the
187
+ * pair so each replica's create-path `insertIgnore` is idempotent and the CAS
188
+ * increment targets exactly its own row (no cross-replica contention). */
189
+ export declare const searchStatsTable: StatsTable;
190
+
191
+ /** All tables for `extendSchema.tables`. */
192
+ export declare const searchTables: () => ReadonlyArray<StatsTable>;
193
+
194
+ /** The stats store. All ops key on the index name; the durable impl fans out to
195
+ * per-replica rows internally and aggregates on read. */
196
+ 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}. */
200
+ readonly recordReindex: (index: string, count: number, at: string) => Promise<void>;
201
+ /** Aggregated stats for one index (summed across replicas). */
202
+ readonly get: (index: string) => Promise<IndexStats>;
203
+ }
204
+
205
+ declare interface StatsTable extends TableLike {
206
+ readonly fields: Record<string, ColumnDefinition<unknown>>;
207
+ readonly appliedIndexes: ReadonlyArray<TableIndex>;
208
+ }
209
+
210
+ /** Typesense (self-host / cloud). Native: filters (range+negation), facets,
211
+ * highlighting, typo tolerance (`num_typos`), engine-param passthrough. */
212
+ export declare const typesenseBackend: (cfg: VendorConfig) => SearchBackend;
213
+
214
+ export declare interface VendorConfig {
215
+ readonly url?: string;
216
+ readonly host?: string;
217
+ readonly apiKey: string;
218
+ /** Algolia application id. */
219
+ readonly appId?: string;
220
+ }
221
+
222
+ export { }