@volter/twin-algolia 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.
- package/LICENSE +202 -0
- package/README.md +105 -0
- package/dist/src/algolia-budget.d.ts +85 -0
- package/dist/src/algolia-budget.js +416 -0
- package/dist/src/algolia-capabilities.d.ts +4 -0
- package/dist/src/algolia-capabilities.js +352 -0
- package/dist/src/algolia-conformance.d.ts +7 -0
- package/dist/src/algolia-conformance.js +33 -0
- package/dist/src/algolia-connector.d.ts +35 -0
- package/dist/src/algolia-connector.js +67 -0
- package/dist/src/algolia-filter.d.ts +16 -0
- package/dist/src/algolia-filter.js +125 -0
- package/dist/src/algolia-search.d.ts +42 -0
- package/dist/src/algolia-search.js +174 -0
- package/dist/src/algolia-server.d.ts +14 -0
- package/dist/src/algolia-server.js +35 -0
- package/dist/src/algolia-twin.d.ts +45 -0
- package/dist/src/algolia-twin.js +540 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +26 -0
- package/dist/src/index.d.ts +13 -0
- package/dist/src/index.js +62 -0
- package/package.json +51 -0
- package/src/algolia-budget.ts +462 -0
- package/src/algolia-capabilities.ts +396 -0
- package/src/algolia-conformance.ts +38 -0
- package/src/algolia-connector.ts +84 -0
- package/src/algolia-filter.ts +150 -0
- package/src/algolia-search.ts +204 -0
- package/src/algolia-server.ts +43 -0
- package/src/algolia-twin.ts +563 -0
- package/src/cli.ts +25 -0
- package/src/index.ts +103 -0
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
// Algolia capability manifest — the EXPECTED REAL-PRODUCT SURFACE (the target), authored
|
|
2
|
+
// top-down from the real Algolia hosted-search API — grounded LIVE against the installed
|
|
3
|
+
// `algoliasearch@4.27.0` npm package (fetched read-only via `npm pack`, then driven end-to-end
|
|
4
|
+
// against a throwaway local Node http server BEFORE this handler/manifest was written — see
|
|
5
|
+
// spec-sources.json for the full grounded-vs-doc-UNVERIFIED breakdown). This is the honest
|
|
6
|
+
// denominator: most entries start as `todo` and coverage reads LOW until the twin truly reaches
|
|
7
|
+
// 100% of the real API. `verify()` (required to count as `done`) is ground truth and drives the
|
|
8
|
+
// KERNEL-BACKED handler on a FRESH temp root — never a spawned mock.
|
|
9
|
+
//
|
|
10
|
+
// v1 SLICE (honesty, per the build spec's S-sizing verdict — do NOT pad to M): index list/
|
|
11
|
+
// delete/clear + record CRUD (add-auto-objectID/add-replace/get/partial/delete/batch) + a REAL
|
|
12
|
+
// search engine (tokenize + Damerau-Levenshtein typo tolerance + filter/facetFilters evaluation +
|
|
13
|
+
// exact facet-count aggregation + a faithful-subset ranking + customRanking tie-breaks) +
|
|
14
|
+
// settings get/set + basic bidirectional synonym expansion + safety/state + connector are
|
|
15
|
+
// modeled `done`. Query-suggestions, personalization, A/B testing, query rules, replicas,
|
|
16
|
+
// insights, secured/HMAC API keys, geo search, dictionaries, advanced per-length typo tuning,
|
|
17
|
+
// highlighting/snippeting, multi-index, and browse are left `todo` with vendor-shaped failure
|
|
18
|
+
// (unmodeled route -> 404) — a deliberate v1 scope cut, not an oversight; see README `## Coverage`.
|
|
19
|
+
//
|
|
20
|
+
// THE SEARCH ENGINE IS A GENUINE STRONG DONE, NOT A STUB (the biggest structural difference from
|
|
21
|
+
// this repo's generative twins, mirroring pinecone's query engine): tokenize + real Damerau-
|
|
22
|
+
// Levenshtein typo matching + real filter/facetFilters evaluation + exact facet-count aggregation
|
|
23
|
+
// + a faithful-subset ranking tie-break chain is deterministic, correct compute — not a
|
|
24
|
+
// placeholder. Widening the ranking criteria and adding language processing are filed as todos.
|
|
25
|
+
// See algolia-search.ts / algolia-filter.ts file headers + README.
|
|
26
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
27
|
+
import { tmpdir } from 'node:os';
|
|
28
|
+
import { join } from 'node:path';
|
|
29
|
+
import { checkCapabilities, type CapabilityReport, type CapabilitySpec, verifyBoundary } from '@volter/world-tooling';
|
|
30
|
+
import { handleAlgoliaTwinRequest, type AlgoliaResponse } from './algolia-twin.ts';
|
|
31
|
+
import { checkAlgoliaConformance } from './algolia-conformance.ts';
|
|
32
|
+
import { syncAlgoliaFromReal, type AlgoliaLikeClient } from './algolia-connector.ts';
|
|
33
|
+
|
|
34
|
+
// (Algolia is an API-first vendor with no product UI worth mirroring — the Algolia dashboard is
|
|
35
|
+
// a config/analytics console, not an agent navigation target (ui-scope.json) — so this pack
|
|
36
|
+
// ships no mirror, and there are no UI capabilities.)
|
|
37
|
+
|
|
38
|
+
// ── API verify: drive REAL requests against a fresh temp root, then assert exact values ───────
|
|
39
|
+
type Step = { m: string; p: string; b?: unknown; host?: string; readOnly?: boolean };
|
|
40
|
+
type Body = Record<string, any>;
|
|
41
|
+
|
|
42
|
+
/** Run a sequence of real Algolia-twin requests against an isolated root; return all responses. */
|
|
43
|
+
async function withRoot(steps: (h: (s: Step) => Promise<AlgoliaResponse>) => Promise<boolean>): Promise<boolean> {
|
|
44
|
+
const root = mkdtempSync(join(tmpdir(), 'algolia-cap-'));
|
|
45
|
+
const h = (s: Step) =>
|
|
46
|
+
handleAlgoliaTwinRequest({
|
|
47
|
+
method: s.m,
|
|
48
|
+
path: s.p,
|
|
49
|
+
body: s.b === undefined ? undefined : JSON.stringify(s.b),
|
|
50
|
+
root,
|
|
51
|
+
...(s.host !== undefined ? { host: s.host } : {}),
|
|
52
|
+
...(s.readOnly !== undefined ? { readOnly: s.readOnly } : {}),
|
|
53
|
+
});
|
|
54
|
+
try {
|
|
55
|
+
return await verifyBoundary('algolia.withRoot', () => steps(h));
|
|
56
|
+
} finally {
|
|
57
|
+
rmSync(root, { recursive: true, force: true });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const ok = (r: AlgoliaResponse) => r.status >= 200 && r.status < 300;
|
|
62
|
+
const field = (r: AlgoliaResponse, k: string) => (r.body as Body)?.[k];
|
|
63
|
+
|
|
64
|
+
/** Add-or-replace one record via the direct PUT endpoint (the simplest ground path most `done`
|
|
65
|
+
* verifies below build their fixtures with). */
|
|
66
|
+
async function putRecord(h: (s: Step) => Promise<AlgoliaResponse>, index: string, objectID: string, fields: Body): Promise<Body> {
|
|
67
|
+
const r = await h({ m: 'PUT', p: `/1/indexes/${index}/${objectID}`, b: { ...fields, objectID } });
|
|
68
|
+
return r.body as Body;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const done = (id: string, area: string, title: string, dimension: CapabilitySpec['dimension'], tier: CapabilitySpec['tier'], verify: CapabilitySpec['verify']): CapabilitySpec => ({ id, area, title, dimension, tier, expected: 'done', verify });
|
|
72
|
+
const todo = (id: string, area: string, title: string, dimension: CapabilitySpec['dimension'], tier: CapabilitySpec['tier']): CapabilitySpec => ({ id, area, title, dimension, tier, expected: 'todo' });
|
|
73
|
+
|
|
74
|
+
export const ALGOLIA_CAPABILITIES: CapabilitySpec[] = [
|
|
75
|
+
todo('algolia.search.ranking_criteria_coverage', 'search', "Fuller ranking: the remaining documented tie-break criteria and their weighting (geo, filters-as-criterion) on top of the typo/attribute/proximity/exact/custom chain this twin already computes", 'api', 'core'),
|
|
76
|
+
todo('algolia.search.language_processing', 'search', 'Language processing over the indexed text: stemming, plural-folding, CJK/Thai segmentation and language-aware normalization (tokenization is deterministic ASCII/Unicode word-boundary splitting today)', 'api', 'niche'),
|
|
77
|
+
|
|
78
|
+
// ── INDEXES ──────────────────────────────────────────────────────────────────────────────
|
|
79
|
+
done('algolia.indexes.list', 'indexes', 'GET /1/indexes lists every index that has ever received a write, incl. entries (record count)', 'api', 'core', () =>
|
|
80
|
+
withRoot(async (h) => {
|
|
81
|
+
await putRecord(h, 'catalog', 'p1', { name: 'Widget' });
|
|
82
|
+
const r = await h({ m: 'GET', p: '/1/indexes' });
|
|
83
|
+
const items = field(r, 'items') as Body[];
|
|
84
|
+
const catalog = items.find((i) => i.name === 'catalog');
|
|
85
|
+
return ok(r) && !!catalog && catalog.entries === 1;
|
|
86
|
+
})),
|
|
87
|
+
done('algolia.indexes.delete', 'indexes', 'DELETE /1/indexes/:name removes the index AND every record in it', 'api', 'core', () =>
|
|
88
|
+
withRoot(async (h) => {
|
|
89
|
+
await putRecord(h, 'temp-index', 'p1', { name: 'X' });
|
|
90
|
+
const del = await h({ m: 'DELETE', p: '/1/indexes/temp-index' });
|
|
91
|
+
const list = await h({ m: 'GET', p: '/1/indexes' });
|
|
92
|
+
const gone = await h({ m: 'GET', p: '/1/indexes/temp-index/p1' });
|
|
93
|
+
return ok(del) && !(field(list, 'items') as Body[]).some((i) => i.name === 'temp-index') && gone.status === 404;
|
|
94
|
+
})),
|
|
95
|
+
done('algolia.indexes.clear', 'indexes', 'POST /1/indexes/:name/clear removes every record but the index itself survives', 'api', 'common', () =>
|
|
96
|
+
withRoot(async (h) => {
|
|
97
|
+
await putRecord(h, 'clearable', 'p1', { name: 'X' });
|
|
98
|
+
await putRecord(h, 'clearable', 'p2', { name: 'Y' });
|
|
99
|
+
const clear = await h({ m: 'POST', p: '/1/indexes/clearable/clear' });
|
|
100
|
+
const list = await h({ m: 'GET', p: '/1/indexes' });
|
|
101
|
+
const gone = await h({ m: 'GET', p: '/1/indexes/clearable/p1' });
|
|
102
|
+
return ok(clear) && (field(list, 'items') as Body[]).some((i) => i.name === 'clearable' && i.entries === 0) && gone.status === 404;
|
|
103
|
+
})),
|
|
104
|
+
|
|
105
|
+
// ── RECORDS ──────────────────────────────────────────────────────────────────────────────
|
|
106
|
+
done('algolia.records.add_auto_objectid', 'records', 'POST /1/indexes/:name (no objectID) mints a non-empty deterministic-per-call objectID; GET round-trips the stored fields', 'api', 'core', () =>
|
|
107
|
+
withRoot(async (h) => {
|
|
108
|
+
const r = await h({ m: 'POST', p: '/1/indexes/autogen', b: { name: 'Widget', price: 9 } });
|
|
109
|
+
const objectID = field(r, 'objectID') as string;
|
|
110
|
+
if (!ok(r) || typeof objectID !== 'string' || objectID.length === 0) return false;
|
|
111
|
+
const got = await h({ m: 'GET', p: `/1/indexes/autogen/${objectID}` });
|
|
112
|
+
return field(got, 'name') === 'Widget' && field(got, 'price') === 9;
|
|
113
|
+
})),
|
|
114
|
+
done('algolia.records.add_replace', 'records', 'PUT /1/indexes/:name/:objectID REPLACES the whole record (a stale field from the first write does not survive a replace)', 'api', 'core', () =>
|
|
115
|
+
withRoot(async (h) => {
|
|
116
|
+
await putRecord(h, 'replace-idx', 'p1', { name: 'Old', stale: 'gone-after-replace' });
|
|
117
|
+
await putRecord(h, 'replace-idx', 'p1', { name: 'New' });
|
|
118
|
+
const got = await h({ m: 'GET', p: '/1/indexes/replace-idx/p1' });
|
|
119
|
+
return field(got, 'name') === 'New' && field(got, 'stale') === undefined;
|
|
120
|
+
})),
|
|
121
|
+
done('algolia.records.get', 'records', 'GET /1/indexes/:name/:objectID returns the exact stored fields', 'api', 'core', () =>
|
|
122
|
+
withRoot(async (h) => {
|
|
123
|
+
await putRecord(h, 'get-idx', 'p1', { name: 'Widget', tags: ['a', 'b'] });
|
|
124
|
+
const r = await h({ m: 'GET', p: '/1/indexes/get-idx/p1' });
|
|
125
|
+
return ok(r) && field(r, 'name') === 'Widget' && JSON.stringify(field(r, 'tags')) === JSON.stringify(['a', 'b']);
|
|
126
|
+
})),
|
|
127
|
+
done('algolia.records.get_unknown_404', 'records', 'GET an unknown objectID -> 404 {message,status} (⚠ message text doc-UNVERIFIED, modeled on the well-known convention)', 'api', 'core', () =>
|
|
128
|
+
withRoot(async (h) => {
|
|
129
|
+
await putRecord(h, 'exists-idx', 'p1', { name: 'X' });
|
|
130
|
+
const r = await h({ m: 'GET', p: '/1/indexes/exists-idx/does-not-exist' });
|
|
131
|
+
return r.status === 404 && typeof field(r, 'message') === 'string' && field(r, 'status') === 404;
|
|
132
|
+
})),
|
|
133
|
+
done('algolia.records.partial_update', 'records', 'POST /1/indexes/:name/:objectID/partial MERGES fields (untouched fields survive); createIfNotExists=false on an unknown id -> 404 (⚠ merge semantics)', 'api', 'core', () =>
|
|
134
|
+
withRoot(async (h) => {
|
|
135
|
+
await putRecord(h, 'partial-idx', 'p1', { name: 'Widget', price: 10 });
|
|
136
|
+
const patch = await h({ m: 'POST', p: '/1/indexes/partial-idx/p1/partial', b: { price: 20 } });
|
|
137
|
+
const got = await h({ m: 'GET', p: '/1/indexes/partial-idx/p1' });
|
|
138
|
+
const missing = await h({ m: 'POST', p: '/1/indexes/partial-idx/nope/partial?createIfNotExists=false', b: { price: 1 } });
|
|
139
|
+
return ok(patch) && field(got, 'price') === 20 && field(got, 'name') === 'Widget' && missing.status === 404;
|
|
140
|
+
})),
|
|
141
|
+
done('algolia.records.delete', 'records', 'DELETE /1/indexes/:name/:objectID removes it (idempotent — a second delete is still 200, not a fabricated 404)', 'api', 'core', () =>
|
|
142
|
+
withRoot(async (h) => {
|
|
143
|
+
await putRecord(h, 'del-idx', 'p1', { name: 'X' });
|
|
144
|
+
const del = await h({ m: 'DELETE', p: '/1/indexes/del-idx/p1' });
|
|
145
|
+
const del2 = await h({ m: 'DELETE', p: '/1/indexes/del-idx/p1' });
|
|
146
|
+
const got = await h({ m: 'GET', p: '/1/indexes/del-idx/p1' });
|
|
147
|
+
return ok(del) && ok(del2) && got.status === 404;
|
|
148
|
+
})),
|
|
149
|
+
done('algolia.records.batch', 'records', 'POST /1/indexes/:name/batch runs HETEROGENEOUS actions (addObject/updateObject/partialUpdateObject/deleteObject) in one call, each taking effect (⚠ action grammar grounded LIVE against the real SDK)', 'api', 'common', () =>
|
|
150
|
+
withRoot(async (h) => {
|
|
151
|
+
await putRecord(h, 'batch-idx', 'existing', { name: 'Old' });
|
|
152
|
+
const r = await h({
|
|
153
|
+
m: 'POST',
|
|
154
|
+
p: '/1/indexes/batch-idx/batch',
|
|
155
|
+
b: { requests: [
|
|
156
|
+
{ action: 'addObject', body: { name: 'Auto' } },
|
|
157
|
+
{ action: 'updateObject', body: { objectID: 'explicit', name: 'Explicit' } },
|
|
158
|
+
{ action: 'partialUpdateObject', body: { objectID: 'existing', name: 'New' } },
|
|
159
|
+
{ action: 'deleteObject', body: { objectID: 'explicit' } },
|
|
160
|
+
] },
|
|
161
|
+
});
|
|
162
|
+
const objectIDs = field(r, 'objectIDs') as string[];
|
|
163
|
+
const explicitGone = await h({ m: 'GET', p: '/1/indexes/batch-idx/explicit' });
|
|
164
|
+
const existingUpdated = await h({ m: 'GET', p: '/1/indexes/batch-idx/existing' });
|
|
165
|
+
return ok(r) && objectIDs.length === 4 && explicitGone.status === 404 && field(existingUpdated, 'name') === 'New';
|
|
166
|
+
})),
|
|
167
|
+
|
|
168
|
+
// ── SEARCH (real tokenize + typo + filter + facet + ranking; the pack's signature strength) ─
|
|
169
|
+
done('algolia.search.basic_ranking', 'search', 'POST /1/indexes/:name/query matches records containing every query word and excludes non-matching records', 'api', 'core', () =>
|
|
170
|
+
withRoot(async (h) => {
|
|
171
|
+
await putRecord(h, 'basic-idx', 'a', { name: 'red widget' });
|
|
172
|
+
await putRecord(h, 'basic-idx', 'b', { name: 'blue gadget' });
|
|
173
|
+
const r = await h({ m: 'POST', p: '/1/indexes/basic-idx/query', b: { query: 'red widget' } });
|
|
174
|
+
const hits = field(r, 'hits') as Body[];
|
|
175
|
+
return ok(r) && field(r, 'nbHits') === 1 && hits[0]!.objectID === 'a';
|
|
176
|
+
})),
|
|
177
|
+
done('algolia.search.typo_tolerance', 'search', 'A 1-typo query matches ONLY the near-typo record, NOT an unrelated record — a return-all implementation reports the wrong nbHits', 'api', 'core', () =>
|
|
178
|
+
withRoot(async (h) => {
|
|
179
|
+
await putRecord(h, 'typo-idx', 'near', { name: 'widget' });
|
|
180
|
+
await putRecord(h, 'typo-idx', 'far', { name: 'gadget' }); // Damerau-Levenshtein('widgt','gadget') > 2, no match
|
|
181
|
+
const r = await h({ m: 'POST', p: '/1/indexes/typo-idx/query', b: { query: 'widgt' } });
|
|
182
|
+
const hits = field(r, 'hits') as Body[];
|
|
183
|
+
return ok(r) && field(r, 'nbHits') === 1 && hits[0]!.objectID === 'near';
|
|
184
|
+
})),
|
|
185
|
+
done('algolia.search.filters_numeric', 'filters', 'A numeric `filters` string EXCLUDES the textually-stronger match, changing which record wins hits[0] — a filter-ignoring implementation returns the wrong hits[0]', 'api', 'core', () =>
|
|
186
|
+
withRoot(async (h) => {
|
|
187
|
+
// 'near' is an EXACT text match (0 typos) but filtered OUT by price; 'far' matches via 1
|
|
188
|
+
// typo (weaker text match) but passes the filter — a filter-ignoring impl returns 'near'.
|
|
189
|
+
await putRecord(h, 'filternum-idx', 'near', { name: 'widget', price: 200 });
|
|
190
|
+
await putRecord(h, 'filternum-idx', 'far', { name: 'widgt', price: 50 });
|
|
191
|
+
const r = await h({ m: 'POST', p: '/1/indexes/filternum-idx/query', b: { query: 'widget', filters: 'price < 100' } });
|
|
192
|
+
const hits = field(r, 'hits') as Body[];
|
|
193
|
+
return ok(r) && hits.length === 1 && hits[0]!.objectID === 'far';
|
|
194
|
+
})),
|
|
195
|
+
done('algolia.search.facet_filters', 'facets', 'facetFilters (incl. an inner OR-array) selects only records with a matching facet value', 'api', 'core', () =>
|
|
196
|
+
withRoot(async (h) => {
|
|
197
|
+
await putRecord(h, 'facetfilt-idx', 'red', { name: 'widget', color: 'red' });
|
|
198
|
+
await putRecord(h, 'facetfilt-idx', 'blue', { name: 'widget', color: 'blue' });
|
|
199
|
+
await putRecord(h, 'facetfilt-idx', 'green', { name: 'widget', color: 'green' });
|
|
200
|
+
const single = await h({ m: 'POST', p: '/1/indexes/facetfilt-idx/query', b: { query: 'widget', facetFilters: ['color:red'] } });
|
|
201
|
+
const orGroup = await h({ m: 'POST', p: '/1/indexes/facetfilt-idx/query', b: { query: 'widget', facetFilters: [['color:red', 'color:blue']] } });
|
|
202
|
+
const ids = (r: AlgoliaResponse) => (field(r, 'hits') as Body[]).map((x) => x.objectID).sort();
|
|
203
|
+
return JSON.stringify(ids(single)) === JSON.stringify(['red']) && JSON.stringify(ids(orGroup)) === JSON.stringify(['blue', 'red']);
|
|
204
|
+
})),
|
|
205
|
+
done('algolia.search.facets_counts', 'facets', 'requesting `facets` returns EXACT counts per facet value over the matched+filtered hit set (not just a non-empty object)', 'api', 'core', () =>
|
|
206
|
+
withRoot(async (h) => {
|
|
207
|
+
await putRecord(h, 'facetcount-idx', '1', { name: 'widget', color: 'red' });
|
|
208
|
+
await putRecord(h, 'facetcount-idx', '2', { name: 'widget', color: 'red' });
|
|
209
|
+
await putRecord(h, 'facetcount-idx', '3', { name: 'widget', color: 'blue' });
|
|
210
|
+
await h({ m: 'PUT', p: '/1/indexes/facetcount-idx/settings', b: { attributesForFaceting: ['color'] } });
|
|
211
|
+
const r = await h({ m: 'POST', p: '/1/indexes/facetcount-idx/query', b: { query: 'widget', facets: ['color'] } });
|
|
212
|
+
const facets = field(r, 'facets') as Body;
|
|
213
|
+
return ok(r) && facets.color.red === 2 && facets.color.blue === 1;
|
|
214
|
+
})),
|
|
215
|
+
done('algolia.search.attributes_to_retrieve', 'search', '`attributesToRetrieve` projects each hit to ONLY objectID + the requested fields; omitting it returns every field', 'api', 'common', () =>
|
|
216
|
+
withRoot(async (h) => {
|
|
217
|
+
await putRecord(h, 'attrret-idx', 'p1', { name: 'Widget', price: 10, description: 'a fine widget' });
|
|
218
|
+
const restricted = await h({ m: 'POST', p: '/1/indexes/attrret-idx/query', b: { query: 'widget', attributesToRetrieve: ['name'] } });
|
|
219
|
+
const full = await h({ m: 'POST', p: '/1/indexes/attrret-idx/query', b: { query: 'widget' } });
|
|
220
|
+
const rHit = (field(restricted, 'hits') as Body[])[0]!;
|
|
221
|
+
const fHit = (field(full, 'hits') as Body[])[0]!;
|
|
222
|
+
return rHit.name === 'Widget' && rHit.price === undefined && rHit.description === undefined && fHit.price === 10;
|
|
223
|
+
})),
|
|
224
|
+
done('algolia.search.pagination', 'pagination', 'page 0 and page 1 (hitsPerPage=1) return DIFFERENT single hits; nbPages reflects the real hit count', 'api', 'core', () =>
|
|
225
|
+
withRoot(async (h) => {
|
|
226
|
+
await putRecord(h, 'page-idx', 'a', { name: 'widget' });
|
|
227
|
+
await putRecord(h, 'page-idx', 'b', { name: 'widget' });
|
|
228
|
+
await putRecord(h, 'page-idx', 'c', { name: 'widget' });
|
|
229
|
+
const p0 = await h({ m: 'POST', p: '/1/indexes/page-idx/query', b: { query: 'widget', page: 0, hitsPerPage: 1 } });
|
|
230
|
+
const p1 = await h({ m: 'POST', p: '/1/indexes/page-idx/query', b: { query: 'widget', page: 1, hitsPerPage: 1 } });
|
|
231
|
+
const id0 = (field(p0, 'hits') as Body[])[0]!.objectID;
|
|
232
|
+
const id1 = (field(p1, 'hits') as Body[])[0]!.objectID;
|
|
233
|
+
return id0 !== id1 && field(p0, 'nbPages') === 3 && field(p0, 'nbHits') === 3;
|
|
234
|
+
})),
|
|
235
|
+
done('algolia.search.custom_ranking', 'search', 'customRanking flips the order of two TEXT-TIED records (same query relevance, differ only in the ranked numeric field) — an ignoring implementation returns them in the opposite/insertion order', 'api', 'core', () =>
|
|
236
|
+
withRoot(async (h) => {
|
|
237
|
+
await putRecord(h, 'customrank-idx', 'x', { name: 'widget', popularity: 1 });
|
|
238
|
+
await putRecord(h, 'customrank-idx', 'y', { name: 'widget', popularity: 9 });
|
|
239
|
+
await h({ m: 'PUT', p: '/1/indexes/customrank-idx/settings', b: { customRanking: ['desc(popularity)'] } });
|
|
240
|
+
const desc = await h({ m: 'POST', p: '/1/indexes/customrank-idx/query', b: { query: 'widget' } });
|
|
241
|
+
await h({ m: 'PUT', p: '/1/indexes/customrank-idx/settings', b: { customRanking: ['asc(popularity)'] } });
|
|
242
|
+
const asc = await h({ m: 'POST', p: '/1/indexes/customrank-idx/query', b: { query: 'widget' } });
|
|
243
|
+
const first = (r: AlgoliaResponse) => (field(r, 'hits') as Body[])[0]!.objectID;
|
|
244
|
+
return first(desc) === 'y' && first(asc) === 'x';
|
|
245
|
+
})),
|
|
246
|
+
|
|
247
|
+
// ── SETTINGS ─────────────────────────────────────────────────────────────────────────────
|
|
248
|
+
done('algolia.settings.get_set', 'settings', 'PUT /1/indexes/:name/settings MERGES onto existing settings (setting A does not erase a previously-set B); GET round-trips', 'api', 'core', () =>
|
|
249
|
+
withRoot(async (h) => {
|
|
250
|
+
await putRecord(h, 'settings-idx', 'p1', { name: 'x' });
|
|
251
|
+
await h({ m: 'PUT', p: '/1/indexes/settings-idx/settings', b: { searchableAttributes: ['name'] } });
|
|
252
|
+
await h({ m: 'PUT', p: '/1/indexes/settings-idx/settings', b: { customRanking: ['desc(popularity)'] } });
|
|
253
|
+
const r = await h({ m: 'GET', p: '/1/indexes/settings-idx/settings' });
|
|
254
|
+
return JSON.stringify(field(r, 'searchableAttributes')) === JSON.stringify(['name']) && JSON.stringify(field(r, 'customRanking')) === JSON.stringify(['desc(popularity)']);
|
|
255
|
+
})),
|
|
256
|
+
done('algolia.settings.searchable_attributes_affects_search', 'settings', 'Configuring searchableAttributes CHANGES the hit set for the identical query — data-coupling proof (a settings-ignoring implementation returns the same result either way)', 'api', 'core', () =>
|
|
257
|
+
withRoot(async (h) => {
|
|
258
|
+
await putRecord(h, 'searchable-idx', 'p1', { title: 'other', body: 'widget' });
|
|
259
|
+
const before = await h({ m: 'POST', p: '/1/indexes/searchable-idx/query', b: { query: 'widget' } });
|
|
260
|
+
await h({ m: 'PUT', p: '/1/indexes/searchable-idx/settings', b: { searchableAttributes: ['title'] } });
|
|
261
|
+
const after = await h({ m: 'POST', p: '/1/indexes/searchable-idx/query', b: { query: 'widget' } });
|
|
262
|
+
return field(before, 'nbHits') === 1 && field(after, 'nbHits') === 0;
|
|
263
|
+
})),
|
|
264
|
+
|
|
265
|
+
// ── SYNONYMS ─────────────────────────────────────────────────────────────────────────────
|
|
266
|
+
done('algolia.synonyms.save_get', 'synonyms', 'POST /1/indexes/:name/synonyms/batch saves a synonym group; GET round-trips it (⚠ batch array-body grammar grounded LIVE against the real SDK)', 'api', 'common', () =>
|
|
267
|
+
withRoot(async (h) => {
|
|
268
|
+
await putRecord(h, 'syn-idx', 'p1', { name: 'tv' });
|
|
269
|
+
const save = await h({ m: 'POST', p: '/1/indexes/syn-idx/synonyms/batch', b: [{ objectID: 'syn1', type: 'synonym', synonyms: ['tv', 'television'] }] });
|
|
270
|
+
const got = await h({ m: 'GET', p: '/1/indexes/syn-idx/synonyms/syn1' });
|
|
271
|
+
return ok(save) && JSON.stringify(field(got, 'synonyms')) === JSON.stringify(['tv', 'television']);
|
|
272
|
+
})),
|
|
273
|
+
done('algolia.synonyms.search_expansion', 'synonyms', 'A query matches ONLY via a configured synonym group; the SAME query on an index WITHOUT that synonym matches nothing — a control pair that kills both an empty-return AND a return-all implementation', 'api', 'core', () =>
|
|
274
|
+
withRoot(async (h) => {
|
|
275
|
+
await putRecord(h, 'synexp-idx', 'p1', { name: 'television' });
|
|
276
|
+
await h({ m: 'POST', p: '/1/indexes/synexp-idx/synonyms/batch', b: [{ objectID: 'syn1', type: 'synonym', synonyms: ['tv', 'television'] }] });
|
|
277
|
+
const withSynonym = await h({ m: 'POST', p: '/1/indexes/synexp-idx/query', b: { query: 'tv' } });
|
|
278
|
+
|
|
279
|
+
await putRecord(h, 'control-idx', 'p1', { name: 'television' }); // identical data, NO synonym configured
|
|
280
|
+
const control = await h({ m: 'POST', p: '/1/indexes/control-idx/query', b: { query: 'tv' } });
|
|
281
|
+
|
|
282
|
+
return field(withSynonym, 'nbHits') === 1 && field(control, 'nbHits') === 0;
|
|
283
|
+
})),
|
|
284
|
+
|
|
285
|
+
// ── AUTH / SAFETY / STATE ────────────────────────────────────────────────────────────────
|
|
286
|
+
done('algolia.auth.rejects_writes', 'auth', 'readOnly mode rejects a write (add record -> 405) but allows reads AND read-shaped POSTs (query)', 'api', 'common', () =>
|
|
287
|
+
withRoot(async (h) => {
|
|
288
|
+
await putRecord(h, 'ro-idx', 'p1', { name: 'x' });
|
|
289
|
+
const write = await h({ m: 'PUT', p: '/1/indexes/ro-idx/p2', readOnly: true, b: { name: 'blocked' } });
|
|
290
|
+
const readList = await h({ m: 'GET', p: '/1/indexes', readOnly: true });
|
|
291
|
+
const readQuery = await h({ m: 'POST', p: '/1/indexes/ro-idx/query', readOnly: true, b: { query: '' } });
|
|
292
|
+
return write.status === 405 && readList.status === 200 && readQuery.status === 200;
|
|
293
|
+
})),
|
|
294
|
+
done('algolia.unmodeled_route.404', 'safety', 'An unmodeled route returns Algolia\'s real error envelope (never a fabricated success)', 'api', 'common', () =>
|
|
295
|
+
withRoot(async (h) => {
|
|
296
|
+
const r = await h({ m: 'GET', p: '/1/totally-unknown-resource' });
|
|
297
|
+
return r.status === 404 && typeof field(r, 'message') === 'string';
|
|
298
|
+
})),
|
|
299
|
+
done('algolia.state.kernel_persisted', 'state', 'State is kernel-folded — a record written in one call is visible, with correct values, in the next', 'api', 'core', () =>
|
|
300
|
+
withRoot(async (h) => {
|
|
301
|
+
const created = await putRecord(h, 'persist-idx', 'persisted', { value: 42 });
|
|
302
|
+
// a dead twin returns {} — objectID must be a REAL non-empty string first, or the final
|
|
303
|
+
// comparison is vacuous.
|
|
304
|
+
if (typeof created.objectID !== 'string' || created.objectID.length === 0) return false;
|
|
305
|
+
const r = await h({ m: 'GET', p: '/1/indexes/persist-idx/persisted' });
|
|
306
|
+
return field(r, 'value') === 42;
|
|
307
|
+
})),
|
|
308
|
+
|
|
309
|
+
// ── CONFORMANCE / CONNECTOR ──────────────────────────────────────────────────────────────
|
|
310
|
+
done('algolia.conformance.snapshot', 'conformance', 'Conformance snapshot covers every resource type + the core write/search/settings/synonym endpoints', 'api', 'common', () => {
|
|
311
|
+
const report = checkAlgoliaConformance();
|
|
312
|
+
return report.ok && report.endpointsChecked >= 12 && report.resourceTypesChecked === 3;
|
|
313
|
+
}),
|
|
314
|
+
done('algolia.connector.sync.entrypoint', 'connector', 'syncAlgoliaFromReal folds indices + records into the twin, idempotent, read-through-handler', 'connector', 'core', async () => {
|
|
315
|
+
const root = mkdtempSync(join(tmpdir(), 'algolia-conn-'));
|
|
316
|
+
try {
|
|
317
|
+
const client: AlgoliaLikeClient = {
|
|
318
|
+
listIndices: async () => ({ items: [{ name: 'real-index' }] }),
|
|
319
|
+
listRecords: async () => ({ records: [{ index: 'real-index', objectID: 'real-rec-1', fields: { name: 'Real' } }] }),
|
|
320
|
+
};
|
|
321
|
+
const first = await syncAlgoliaFromReal(client, { root, budgetOptions: { root } });
|
|
322
|
+
if (first.observed !== 2 || first.deltasAppended < 2) return false;
|
|
323
|
+
// a re-pull of identical state is a no-op (shadow-diff dedup) -> 0 deltas appended
|
|
324
|
+
const second = await syncAlgoliaFromReal(client, { root, budgetOptions: { root } });
|
|
325
|
+
if (second.deltasAppended !== 0) return false;
|
|
326
|
+
// the pulled data is now visible via the twin handler (real fold, not a count)
|
|
327
|
+
const list = await handleAlgoliaTwinRequest({ method: 'GET', path: '/1/indexes', root });
|
|
328
|
+
const got = await handleAlgoliaTwinRequest({ method: 'GET', path: '/1/indexes/real-index/real-rec-1', root });
|
|
329
|
+
return (list.body as Body).items.some((i: Body) => i.name === 'real-index') && (got.body as Body).name === 'Real';
|
|
330
|
+
} finally {
|
|
331
|
+
rmSync(root, { recursive: true, force: true });
|
|
332
|
+
}
|
|
333
|
+
}),
|
|
334
|
+
done('algolia.connector.empty_client', 'connector', 'syncAlgoliaFromReal with an empty client observes nothing', 'connector', 'common', async () => {
|
|
335
|
+
const root = mkdtempSync(join(tmpdir(), 'algolia-conn-empty-'));
|
|
336
|
+
try {
|
|
337
|
+
const result = await syncAlgoliaFromReal({}, { root, budgetOptions: { root } });
|
|
338
|
+
return result.observed === 0 && result.deltasAppended === 0;
|
|
339
|
+
} finally {
|
|
340
|
+
rmSync(root, { recursive: true, force: true });
|
|
341
|
+
}
|
|
342
|
+
}),
|
|
343
|
+
|
|
344
|
+
// ── TODO: the rest of the Algolia v1 surface (honest denominator) ───────────────────────
|
|
345
|
+
todo('algolia.search.geo_search', 'search', 'aroundLatLng / insideBoundingBox / insidePolygon geo search', 'api', 'niche'),
|
|
346
|
+
todo('algolia.search.query_rules', 'search', 'Query Rules (condition -> consequence, e.g. pinning/boosting/filtering results for a matched query)', 'api', 'common'),
|
|
347
|
+
todo('algolia.search.optional_words', 'search', '`optionalWords` — words that may be dropped from the strict-AND match without excluding the record', 'api', 'common'),
|
|
348
|
+
todo('algolia.search.remove_words_if_no_results', 'search', '`removeWordsIfNoResults` progressive query-word relaxation when the strict match returns 0 hits', 'api', 'common'),
|
|
349
|
+
todo('algolia.search.advanced_typo_tuning', 'search', 'Per-word-length graduated typo tolerance (`minWordSizefor1Typo`/`minWordSizefor2Typos`) instead of this twin\'s uniform <=2', 'api', 'common'),
|
|
350
|
+
todo('algolia.search.distinct_dedup', 'search', '`distinct` — deduplicate hits sharing an attribute value, keeping only the top-ranked one per group', 'api', 'common'),
|
|
351
|
+
todo('algolia.search.highlighting', 'search', '`_highlightResult` — per-attribute matched-substring highlighting in each hit', 'api', 'common'),
|
|
352
|
+
todo('algolia.search.snippeting', 'search', '`_snippetResult` — a truncated, highlighted excerpt around the match', 'api', 'niche'),
|
|
353
|
+
todo('algolia.search.ranking_info', 'search', '`getRankingInfo` — per-hit exposed scoring breakdown (nbTypos/proximityDistance/etc) in the response', 'api', 'niche'),
|
|
354
|
+
todo('algolia.facets.disjunctive_facets', 'facets', 'Disjunctive facet counting (`disjunctiveFacets` — counts as if THIS facet\'s own filter were not applied)', 'api', 'common'),
|
|
355
|
+
todo('algolia.synonyms.one_way', 'synonyms', '`oneWaySynonym` — asymmetric synonym expansion (input -> synonyms, not the reverse)', 'api', 'common'),
|
|
356
|
+
todo('algolia.synonyms.placeholder', 'synonyms', '`placeholder` synonyms (token substitution, e.g. `<streetnumber>`)', 'api', 'niche'),
|
|
357
|
+
todo('algolia.synonyms.alt_correction', 'synonyms', '`altCorrection1`/`altCorrection2` typo-correction synonyms', 'api', 'niche'),
|
|
358
|
+
todo('algolia.synonyms.search_endpoint', 'synonyms', 'POST /1/indexes/:name/synonyms/search — paginated/query-filtered synonym search (today: only get-by-id)', 'api', 'niche'),
|
|
359
|
+
todo('algolia.records.delete_by_query', 'records', 'POST /1/indexes/:name/deleteByQuery — delete every record matching a filter/query in one call', 'api', 'common'),
|
|
360
|
+
todo('algolia.records.get_multiple', 'records', 'POST /1/indexes/*/objects — fetch several objectIDs across one or more indices in one call', 'api', 'common'),
|
|
361
|
+
todo('algolia.settings.replicas', 'settings', 'Index replicas (`replicas` setting) for pre-sorted/differently-ranked query variants', 'api', 'common'),
|
|
362
|
+
todo('algolia.settings.attributes_to_snippet', 'settings', '`attributesToSnippet` settings-level default (paired with search.snippeting)', 'api', 'niche'),
|
|
363
|
+
todo('algolia.facets.facet_value_search', 'facets', 'POST /1/indexes/:name/facets/:facetName/query — search WITHIN a facet\'s own values (facet-value autocomplete)', 'api', 'common'),
|
|
364
|
+
todo('algolia.facets.max_values_per_facet', 'facets', '`maxValuesPerFacet` cap on returned facet values per attribute', 'api', 'niche'),
|
|
365
|
+
todo('algolia.indexes.copy_move', 'indexes', 'POST /1/indexes/:name/operation — copy/move an entire index (incl. settings/synonyms/rules)', 'api', 'common'),
|
|
366
|
+
todo('algolia.indexes.wait_task', 'indexes', 'GET /1/indexes/:name/task/:taskID — poll a real async task to completion (today: taskID is a deterministic 0 stub)', 'api', 'common'),
|
|
367
|
+
todo('algolia.browse.all', 'browse', 'POST /1/indexes/:name/browse — full unranked export of every record in an index (page/cursor iteration)', 'api', 'core'),
|
|
368
|
+
todo('algolia.browse.cursor_pagination', 'browse', 'Real opaque `cursor` fidelity across successive browse calls', 'api', 'common'),
|
|
369
|
+
todo('algolia.multi_index.multiple_queries', 'multi_index', 'POST /1/indexes/*/queries — run several DIFFERENT-index queries in one round trip, incl. the `params` urlencoded-string request shape', 'api', 'common'),
|
|
370
|
+
todo('algolia.auth.api_keys_crud', 'auth', 'POST/GET/PUT/DELETE /1/keys — scoped API key management', 'api', 'common'),
|
|
371
|
+
todo('algolia.auth.secured_api_keys_generate', 'auth', 'HMAC-signed secured/virtual API keys (`generateSecuredApiKey`) for per-user filtered access', 'api', 'common'),
|
|
372
|
+
todo('algolia.search.query_suggestions', 'search', 'Query Suggestions index generation from search analytics', 'api', 'niche'),
|
|
373
|
+
todo('algolia.search.personalization', 'search', 'Personalization re-ranking from a user\'s click/conversion history', 'api', 'niche'),
|
|
374
|
+
todo('algolia.search.ab_testing', 'search', 'A/B test configuration + traffic split across two index variants', 'api', 'niche'),
|
|
375
|
+
todo('algolia.search.rules_save_batch', 'search', 'POST /1/indexes/:name/rules/batch — bulk Query Rules upsert', 'api', 'niche'),
|
|
376
|
+
todo('algolia.search.insights_events', 'search', 'POST /1/events — click/conversion/view event tracking (Insights API)', 'api', 'niche'),
|
|
377
|
+
todo('algolia.search.analytics_top_searches', 'search', 'GET /2/searches — top-searches / no-results analytics', 'api', 'niche'),
|
|
378
|
+
todo('algolia.search.dictionary_stopwords_plurals', 'search', 'Dictionary entries (stopwords/plurals/compounds) management', 'api', 'niche'),
|
|
379
|
+
todo('algolia.connector.push', 'connector', 'Push locally-created indices/records against the real API', 'connector', 'common'),
|
|
380
|
+
todo('algolia.connector.pull_synonyms', 'connector', 'Connector: pull synonyms from the real account', 'connector', 'common'),
|
|
381
|
+
todo('algolia.connector.fixtures_seed', 'connector', 'Seed indices/records from fixtures', 'connector', 'niche'),
|
|
382
|
+
todo('algolia.filters.parentheses', 'filters', 'Parenthesized `filters` precedence override (today: OR-of-AND-groups only, no parens)', 'api', 'common'),
|
|
383
|
+
todo('algolia.filters.tag_shorthand', 'filters', '`_tags`/`tag:` shorthand filter syntax', 'api', 'niche'),
|
|
384
|
+
todo('algolia.filters.geo', 'filters', 'Geo filters (`_geoloc` radius/bounding-box) inside the `filters` string', 'api', 'niche'),
|
|
385
|
+
todo('algolia.errors.wire_shape_confirmed', 'errors', 'Independently confirm the exact unknown-objectID/unknown-index error MESSAGE TEXT against a live account (currently doc-UNVERIFIED, modeled on the well-known convention — the live SDK pass confirmed the {message,status} SHAPE round-trips, not the exact wording)', 'api', 'common'),
|
|
386
|
+
todo('algolia.errors.rate_limiting', 'errors', '429 rate-limit error envelope + Retry-After semantics (twin never rate-limits)', 'api', 'niche'),
|
|
387
|
+
];
|
|
388
|
+
|
|
389
|
+
export const ALGOLIA_AREAS = [
|
|
390
|
+
'indexes', 'records', 'search', 'filters', 'facets', 'settings', 'synonyms', 'multi_index',
|
|
391
|
+
'browse', 'pagination', 'auth', 'errors', 'connector', 'conformance', 'safety', 'state',
|
|
392
|
+
] as const;
|
|
393
|
+
|
|
394
|
+
export async function algoliaCapabilities(): Promise<CapabilityReport> {
|
|
395
|
+
return checkCapabilities('algolia', ALGOLIA_CAPABILITIES);
|
|
396
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Algolia conformance (dev-only; lazy-imported by the CLI, NEVER from index.ts/runtime — E2).
|
|
2
|
+
// Checks the twin's endpoint/resource inventory against the modeled Algolia surface. Like
|
|
3
|
+
// pinecone/replicate/fal, this is a self-referential snapshot check (against the twin's own
|
|
4
|
+
// declared inventory), not a systematic per-resource spec-diff — see docs/contributing/conformance.md's "2 spec"
|
|
5
|
+
// column note.
|
|
6
|
+
import { algoliaTwinSnapshot, ALGOLIA_RESOURCE_TYPES } from './algolia-twin.ts';
|
|
7
|
+
|
|
8
|
+
export type AlgoliaConformanceReport = {
|
|
9
|
+
ok: boolean;
|
|
10
|
+
endpointsChecked: number;
|
|
11
|
+
resourceTypesChecked: number;
|
|
12
|
+
violations: string[];
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function checkAlgoliaConformance(): AlgoliaConformanceReport {
|
|
16
|
+
const snapshot = algoliaTwinSnapshot();
|
|
17
|
+
const violations: string[] = [];
|
|
18
|
+
const stemFor: Record<string, string> = {
|
|
19
|
+
index: '/1/indexes',
|
|
20
|
+
record: '/1/indexes/:name/:objectID',
|
|
21
|
+
synonym: '/1/indexes/:name/synonyms',
|
|
22
|
+
};
|
|
23
|
+
for (const type of ALGOLIA_RESOURCE_TYPES) {
|
|
24
|
+
const stem = stemFor[type];
|
|
25
|
+
if (!stem || !snapshot.implementedEndpoints.some((e) => e.includes(stem))) {
|
|
26
|
+
violations.push(`resource type '${type}' has no implemented endpoint`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (!snapshot.implementedEndpoints.some((e) => e === 'POST /1/indexes/:name/query')) violations.push('missing search endpoint');
|
|
30
|
+
if (!snapshot.implementedEndpoints.some((e) => e === 'POST /1/indexes/:name/batch')) violations.push('missing batch endpoint');
|
|
31
|
+
if (!snapshot.implementedEndpoints.some((e) => e === 'GET /1/indexes')) violations.push('missing index list endpoint');
|
|
32
|
+
return {
|
|
33
|
+
ok: violations.length === 0,
|
|
34
|
+
endpointsChecked: snapshot.implementedEndpoints.length,
|
|
35
|
+
resourceTypesChecked: snapshot.resourceTypes.length,
|
|
36
|
+
violations,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Algolia CONNECTOR — the live-vendor pull path over an INJECTED client (the auth boundary; a
|
|
2
|
+
// fake in tests, the real `algoliasearch` SDK in prod). The pack imports NO SDK and holds NO key
|
|
3
|
+
// at runtime — only this file's *type* shapes describe what a real client call would return; the
|
|
4
|
+
// actual SDK is a devDependency imported ONLY in *.test.ts (architecture.test.ts).
|
|
5
|
+
//
|
|
6
|
+
// PULL (real -> twin): fetch real indices + records via the injected client, map them to
|
|
7
|
+
// SyncResource[], and fold them into the event log via `syncPull` (shadow-diff dedup, so a
|
|
8
|
+
// re-pull of identical state is a no-op). Kernel subject ids are TYPE-PREFIXED to stay
|
|
9
|
+
// collision-safe (mirrors algolia-twin.ts).
|
|
10
|
+
//
|
|
11
|
+
// Records are genuinely BROWSE-DRIVEN, the same honest shape pinecone's connector uses for
|
|
12
|
+
// vectors (see pinecone-connector.ts's file header): a real production implementation loops
|
|
13
|
+
// `client.listIndices()` then browses each index's objects (`index.browseObjects()`) and
|
|
14
|
+
// flattens the result into exactly this connector's own pull-source shape — there is no single
|
|
15
|
+
// cross-index "list every record" vendor endpoint, so `AlgoliaLikeClient.listRecords` is a
|
|
16
|
+
// connector-owned aggregation shape (notes echoed in pull-audit.json). Synonyms are NOT pulled
|
|
17
|
+
// (`algolia.connector.pull_synonyms`, todo).
|
|
18
|
+
//
|
|
19
|
+
// ── The client-side RATE BUDGET is not optional here ────────────────────────────────────────
|
|
20
|
+
// Every entrypoint below GUARDS the injected client before touching it (`guardAlgoliaClient`, which
|
|
21
|
+
// is idempotent — a caller who already wrapped is not double-charged, a caller who forgot is
|
|
22
|
+
// protected anyway). Algolia publishes no scalar limit for search/browse/listIndices and explicitly
|
|
23
|
+
// excludes `browse` from its QPS accounting, while `listRecords` here is a cross-index browse
|
|
24
|
+
// aggregation that fans out; there is deliberately no option that turns the budget off. See
|
|
25
|
+
// algolia-budget.ts.
|
|
26
|
+
import { syncPull } from '@volter/world-core';
|
|
27
|
+
import type { SyncResource } from '@volter/world-core';
|
|
28
|
+
import { algoliaBudgetOf, guardAlgoliaClient, type AlgoliaBudgetedOptions } from './algolia-budget.ts';
|
|
29
|
+
|
|
30
|
+
const SERVICE = 'algolia';
|
|
31
|
+
|
|
32
|
+
export type { AlgoliaBudgetedOptions };
|
|
33
|
+
|
|
34
|
+
export type AlgoliaRealIndex = { name: string };
|
|
35
|
+
export type AlgoliaRealRecord = { index: string; objectID: string; fields?: Record<string, unknown> | null };
|
|
36
|
+
|
|
37
|
+
// The injected client exposes the minimal subset the connector calls; a real Algolia-backed
|
|
38
|
+
// implementation is structurally assignable (see file header for why `listRecords` is a
|
|
39
|
+
// connector-owned aggregation shape, not a literal SDK method name).
|
|
40
|
+
export interface AlgoliaLikeClient {
|
|
41
|
+
listIndices?: () => Promise<{ items: AlgoliaRealIndex[] }>;
|
|
42
|
+
listRecords?: () => Promise<{ records: AlgoliaRealRecord[] }>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function indexKey(name: string): string {
|
|
46
|
+
return `index:${name}`;
|
|
47
|
+
}
|
|
48
|
+
function recordKey(index: string, objectID: string): string {
|
|
49
|
+
return `record:${index}::${objectID}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── PULL mappers (real Algolia object -> SyncResource) ─────────────────────────────────────
|
|
53
|
+
export function mapIndex(m: AlgoliaRealIndex): SyncResource {
|
|
54
|
+
return { type: 'index', id: indexKey(m.name), fields: { name: m.name } };
|
|
55
|
+
}
|
|
56
|
+
export function mapRecord(r: AlgoliaRealRecord): SyncResource {
|
|
57
|
+
return { type: 'record', id: recordKey(r.index, r.objectID), fields: { ...(r.fields ?? {}) } };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function pullAlgoliaIndexes(rawClient: AlgoliaLikeClient, opts: AlgoliaBudgetedOptions = {}): Promise<SyncResource[]> {
|
|
61
|
+
const client = guardAlgoliaClient(rawClient, opts);
|
|
62
|
+
if (!client.listIndices) return [];
|
|
63
|
+
return (await client.listIndices()).items.map(mapIndex);
|
|
64
|
+
}
|
|
65
|
+
export async function pullAlgoliaRecords(rawClient: AlgoliaLikeClient, opts: AlgoliaBudgetedOptions = {}): Promise<SyncResource[]> {
|
|
66
|
+
const client = guardAlgoliaClient(rawClient, opts);
|
|
67
|
+
if (!client.listRecords) return [];
|
|
68
|
+
return (await client.listRecords()).records.map(mapRecord);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* D7 entry point: pull from real Algolia (indices + records, via browse) and fold EVERY domain
|
|
73
|
+
* into the twin via ONE syncPull (shadow-diff dedup). Returns { observed, deltasAppended } — a
|
|
74
|
+
* re-pull of identical state appends ZERO deltas (observed stays, deltasAppended drops to 0).
|
|
75
|
+
*/
|
|
76
|
+
export async function syncAlgoliaFromReal(rawClient: AlgoliaLikeClient, opts: { root?: string; occurredAt?: string } & AlgoliaBudgetedOptions = {}): Promise<{ observed: number; deltasAppended: number }> {
|
|
77
|
+
// Guard ONCE here and hand the guarded client down: this is the entrypoint whose browse
|
|
78
|
+
// aggregation fans out, so it is the one that must be unable to run unbudgeted.
|
|
79
|
+
const client = guardAlgoliaClient(rawClient, algoliaBudgetOf(opts));
|
|
80
|
+
const occurredAt = opts.occurredAt ?? new Date().toISOString();
|
|
81
|
+
const resources: SyncResource[] = [...(await pullAlgoliaIndexes(client)), ...(await pullAlgoliaRecords(client))];
|
|
82
|
+
const result = syncPull({ service: SERVICE, resources, occurredAt, ...(opts.root !== undefined ? { root: opts.root } : {}) });
|
|
83
|
+
return { observed: result.observed, deltasAppended: result.deltasAppended };
|
|
84
|
+
}
|