@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.
@@ -0,0 +1,563 @@
1
+ // Algolia API twin REQUEST HANDLER — a v1 slice of the Algolia hosted-search surface, backed by
2
+ // the event/action-log kernel (@volter/world-core). Contract:
3
+ // handleAlgoliaTwinRequest({ method, path, body, host?, appId?, root, readOnly }) ->
4
+ // { status, body, headers }
5
+ //
6
+ // SOURCE OF TRUTH (spec-sources.json has the full grounded-vs-doc-UNVERIFIED breakdown): every
7
+ // route path and wire-body shape claimed `done` below was independently verified LIVE — a
8
+ // throwaway Node http server standing in for this twin, driven by the ACTUAL installed
9
+ // `algoliasearch@4.27.0` npm package (fetched read-only via `npm pack`, then exercised with a
10
+ // real running client, never a guess from memory of the API) — BEFORE this handler was written,
11
+ // mirroring the rigor pinecone-sdk.integration.test.ts's header describes. That live pass
12
+ // surfaced a genuinely surprising, easy-to-miss fact a docs skim would not have caught: the v4
13
+ // JS SDK's `saveObject`/`partialUpdateObject`/`deleteObject` convenience methods do NOT hit a
14
+ // per-object `PUT`/`DELETE` endpoint at all — they ALL route through
15
+ // `POST /1/indexes/{indexName}/batch` with a `{requests:[{action,body}]}` envelope (⚠5, the
16
+ // batch action-name grammar: `addObject` for auto-objectID create, `updateObject` for add-or-
17
+ // replace-with-explicit-id, `partialUpdateObject`/`partialUpdateObjectNoCreate` for the two
18
+ // createIfNotExists states, `deleteObject`). This handler implements THAT grounded batch grammar
19
+ // as the primary record-write path (required for `algolia-sdk.integration.test.ts`'s rung-4
20
+ // parity — a fetch-fallback was NOT needed, see that file's header). The auth headers
21
+ // (`x-algolia-application-id` / `x-algolia-api-key`) and the `{message,status}` error envelope
22
+ // were also confirmed round-tripping correctly through the real SDK's own error type in the same
23
+ // live pass. A handful of items the live pass didn't settle (the exact unknown-objectID/index
24
+ // error MESSAGE text, the direct add/partial/delete-by-id endpoint response field names like
25
+ // `createdAt`/`updatedAt`) are annotated doc-UNVERIFIED inline — modeled on the well-known,
26
+ // widely-published Algolia convention rather than invented, per spec-sources.json.
27
+ //
28
+ // HOST-TOLERANT ROUTER (S-simplification, ⚠8): real Algolia's SDKs default to three host
29
+ // SHAPES — `{appId}.algolia.net` (write), `{appId}-dsn.algolia.net` (read), and
30
+ // `{appId}-1/2/3.algolianet.com` (fallback; all three grounded from the installed SDK's own
31
+ // `@algolia/client-search` dist source, not guessed) — and split control over which plane a
32
+ // request may land on. This twin does NOT physically split read/write/DSN planes (one kernel
33
+ // root) — `routeAlgoliaSurface`
34
+ // TOLERATES every one of those host shapes (and no host at all) and always resolves to the
35
+ // single `'api'` surface; every request is then routed by PATH ALONE (which is already fully
36
+ // self-describing in the real Algolia REST API — `/1/indexes/{indexName}/...` always carries the
37
+ // index name in the path, unlike Pinecone's per-index data-plane host). `mintHost`/
38
+ // `recoverAppIdFromHost` exist for host-shape round-trip proof (`auth.host_tolerant_routing`)
39
+ // and as the twin's own verifies' fallback (an explicit `appId` field, mirroring pinecone's
40
+ // `index` field fallback) — they carry no functional weight beyond that.
41
+ //
42
+ // State lives ENTIRELY in the kernel action log: writes go through `applyTwinWrite`, reads are
43
+ // the projection (`projectResources`). No Map/array side-store. No real Algolia is ever
44
+ // contacted. An index springs into existence on its first record write (real Algolia has no
45
+ // explicit "create index" endpoint — this matches the real product, not a twin shortcut).
46
+ //
47
+ // Unmodeled route/op returns Algolia's real single-plane error envelope `{message,status}`
48
+ // (never a fabricated success). `readOnly` rejects WRITES per-operation (not by HTTP verb:
49
+ // `POST /query`/`POST /browse` are read-shaped POSTs and stay allowed in readOnly mode, mirroring
50
+ // pinecone-twin.ts's / linear-state.ts's per-operation readOnly gating).
51
+ import { createHash } from 'node:crypto';
52
+ import { applyTwinWrite, projectResources } from '@volter/world-core';
53
+ import { matchesAlgoliaFilters, matchesFacetFilters, computeFacetCounts, type FacetFilters } from './algolia-filter.ts';
54
+ import { rankRecords, buildSynonymGroups, type CustomRankingRule, type SearchRecord } from './algolia-search.ts';
55
+
56
+ const SERVICE = 'algolia';
57
+
58
+ export type AlgoliaRequest = {
59
+ method: string;
60
+ path: string;
61
+ body?: string;
62
+ headers?: Record<string, string>;
63
+ /** Effective request host (real Host header, or the SDK-integration-test's target host). Fed
64
+ * through routeAlgoliaSurface/recoverAppIdFromHost purely for host-tolerance proof. */
65
+ host?: string;
66
+ /** Explicit appId override — the twin's OWN verifies use this directly (spec §6's documented
67
+ * fallback, mirrors pinecone's `index` field) instead of round-tripping through a host. */
68
+ appId?: string;
69
+ occurredAt?: string;
70
+ root?: string;
71
+ readOnly?: boolean;
72
+ };
73
+ export type AlgoliaResponse = { status: number; body: unknown; headers?: Record<string, string> };
74
+
75
+ export const ALGOLIA_RESOURCE_TYPES = ['index', 'record', 'synonym'] as const;
76
+ export type AlgoliaResourceType = typeof ALGOLIA_RESOURCE_TYPES[number];
77
+
78
+ // ── host-tolerant router (S-simplification — see file header) ──────────────────────────────
79
+ export type AlgoliaSurface = 'api';
80
+
81
+ /** Canonical WRITE-host form for an appId — real Algolia hosts literally embed the appId as a
82
+ * losslessly-recoverable prefix (unlike pinecone's synthetic per-index hash host), so no hash
83
+ * is needed; `root` is accepted for signature symmetry with pinecone's mintHost but does not
84
+ * affect the output (documented, not a bug — there is nothing per-root to disambiguate: the
85
+ * appId already IS the whole identity a host shape encodes). */
86
+ export function mintHost(appId: string, _root?: string): string {
87
+ return `${appId}.algolia.net`;
88
+ }
89
+
90
+ /** Recover an appId from ANY of the three real host shapes (or a bare Host: port-suffixed
91
+ * variant), tolerating whichever one a caller presents. Returns undefined for an unrecognized
92
+ * shape. */
93
+ export function recoverAppIdFromHost(host: string | undefined): string | undefined {
94
+ if (!host) return undefined;
95
+ const bare = host.replace(/^https?:\/\//, '').split(':')[0]!;
96
+ let m = bare.match(/^(.+)-dsn\.algolia\.net$/);
97
+ if (m) return m[1];
98
+ m = bare.match(/^(.+)\.algolia\.net$/);
99
+ if (m) return m[1];
100
+ m = bare.match(/^(.+)-\d+\.algolianet\.com$/);
101
+ if (m) return m[1];
102
+ return undefined;
103
+ }
104
+
105
+ /** Accepts EVERY real host shape (and no host at all) — always resolves to the single `'api'`
106
+ * surface; actual dispatch is by PATH alone (see file header). */
107
+ export function routeAlgoliaSurface(_req: { host?: string; path: string }): AlgoliaSurface {
108
+ return 'api';
109
+ }
110
+
111
+ // ── error envelope (single-plane — simpler than pinecone's two-plane split, spec §3) ─────────
112
+ function algoliaError(status: number, message: string): AlgoliaResponse {
113
+ return { status, body: { message, status } };
114
+ }
115
+ function readOnlyRejection(): AlgoliaResponse {
116
+ return { status: 405, body: { message: 'twin is read-only; omit readOnly to accept writes', status: 405 } };
117
+ }
118
+
119
+ // ── body / helpers ────────────────────────────────────────────────────────────────────────────
120
+ function parseBody(body?: string): unknown {
121
+ if (!body) return undefined;
122
+ try {
123
+ return JSON.parse(body);
124
+ } catch {
125
+ return undefined;
126
+ }
127
+ }
128
+ function parseObjectBody(body?: string): Record<string, unknown> {
129
+ const v = parseBody(body);
130
+ return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : {};
131
+ }
132
+ function nowIso(occurredAt?: string): string {
133
+ return occurredAt ?? new Date().toISOString();
134
+ }
135
+
136
+ // ── kernel subject ids (type-prefixed; D1) ──────────────────────────────────────────────────
137
+ function indexKey(name: string): string {
138
+ return `index:${name}`;
139
+ }
140
+ function recordKey(index: string, objectID: string): string {
141
+ return `record:${index}::${objectID}`;
142
+ }
143
+ function synonymKey(index: string, id: string): string {
144
+ return `synonym:${index}::${id}`;
145
+ }
146
+ function settingsKey(index: string): string {
147
+ return `settings:${index}`;
148
+ }
149
+ function parseRecordSuffix(suffix: string): { index: string; objectID: string } | undefined {
150
+ const parts = suffix.split('::');
151
+ if (parts.length !== 2) return undefined;
152
+ return { index: parts[0]!, objectID: parts[1]! };
153
+ }
154
+ function parseSynonymSuffix(suffix: string): { index: string; id: string } | undefined {
155
+ const parts = suffix.split('::');
156
+ if (parts.length !== 2) return undefined;
157
+ return { index: parts[0]!, id: parts[1]! };
158
+ }
159
+
160
+ type Row = Record<string, unknown> & { type: string; id: string };
161
+
162
+ function rowsOfType(type: string, root?: string): Row[] {
163
+ const prefix = `${type}:`;
164
+ return (projectResources(SERVICE, root) as Row[]).filter((r) => r.type === type && r.id.startsWith(prefix) && r._deleted !== true);
165
+ }
166
+ function getRowById(type: string, subjectId: string, root?: string): Row | undefined {
167
+ return (projectResources(SERVICE, root) as Row[]).find((r) => r.type === type && r.id === subjectId && r._deleted !== true);
168
+ }
169
+ /** Raw row lookup, WITHOUT filtering `_deleted` — used only to discover every field name the
170
+ * kernel has ever merged for a subject (see `writeAddOrReplace`'s header note on why a true
171
+ * "replace" needs this). */
172
+ function getRawRowById(type: string, subjectId: string, root?: string): Row | undefined {
173
+ return (projectResources(SERVICE, root) as Row[]).find((r) => r.type === type && r.id === subjectId);
174
+ }
175
+ /** The kernel's action log is FIELD-MERGE, not whole-record-replace (`shadow.ts`'s `applyFields`
176
+ * only ever ADDS/OVERWRITES the fields present in a given write — there is no way to remove a
177
+ * field from the merged shadow except writing it as `null`, which every reader below strips back
178
+ * out). `undefined` is silently skipped by the kernel (not a valid erase signal); `null` is a
179
+ * genuine value the kernel stores and merges, so this pack repurposes it as the erase sentinel.
180
+ * Also strips the kernel's OWN meta fields (`type`/`id`/`updatedAt` always ride along on every
181
+ * `projectResources` row; `_deleted` is this pack's own soft-delete/undelete marker) — a caller-
182
+ * supplied field literally named `updatedAt` would collide, an accepted, documented limitation
183
+ * (mirrors every other pack's kernel-row convention; see pinecone-twin.ts's view functions,
184
+ * which hand-pick fields for the same reason). */
185
+ const INTERNAL_ROW_FIELDS = new Set(['type', 'id', 'updatedAt', '_deleted']);
186
+ function publicFields(row: Record<string, unknown>): Record<string, unknown> {
187
+ const out: Record<string, unknown> = {};
188
+ for (const [k, v] of Object.entries(row)) {
189
+ if (INTERNAL_ROW_FIELDS.has(k) || v === null) continue;
190
+ out[k] = v;
191
+ }
192
+ return out;
193
+ }
194
+ async function applyWrite(subjectType: string, subjectId: string, fields: Record<string, unknown>, operation: string, req: AlgoliaRequest): Promise<Row> {
195
+ const { resource } = await applyTwinWrite(
196
+ SERVICE,
197
+ { operation, subjectType, subjectId, fields, ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}), actor: { kind: 'agent' } },
198
+ req.root,
199
+ );
200
+ return resource as Row;
201
+ }
202
+
203
+ /** Ensure `index:<name>` exists (real Algolia mints an index implicitly on first write — no
204
+ * explicit create-index endpoint, matches the real product). */
205
+ async function ensureIndex(name: string, req: AlgoliaRequest): Promise<void> {
206
+ if (getRowById('index', indexKey(name), req.root)) return;
207
+ await applyWrite('index', indexKey(name), { name, createdAt: nowIso(req.occurredAt) }, 'index.touch', req);
208
+ }
209
+
210
+ function recordsIn(index: string, root?: string): SearchRecord[] {
211
+ const prefix = `record:${index}::`;
212
+ return rowsOfType('record', root)
213
+ .filter((r) => r.id.startsWith(prefix))
214
+ .map((r) => {
215
+ const parsed = parseRecordSuffix(r.id.slice('record:'.length))!;
216
+ return { ...publicFields(r), objectID: parsed.objectID } as SearchRecord;
217
+ });
218
+ }
219
+ // Algolia's synonym schema has its OWN field literally called `type` (`"synonym"` |
220
+ // `"oneWaySynonym"` | ...) — a genuine collision with the kernel's reserved row-meta field of
221
+ // the SAME name (`projectResources`'s own `META` set silently drops any stored field named
222
+ // `type`/`id`/`updatedAt` — see `publicFields`'s header note). Stored under `synonymType`
223
+ // internally; renamed back to `type` on every read.
224
+ function toStoredSynonymFields(s: Record<string, unknown>): Record<string, unknown> {
225
+ const { objectID: _drop, type, ...rest } = s;
226
+ return { ...rest, ...(type !== undefined ? { synonymType: type } : {}) };
227
+ }
228
+ function fromStoredSynonymFields(fields: Record<string, unknown>): Record<string, unknown> {
229
+ const { synonymType, ...rest } = fields;
230
+ return { ...rest, ...(synonymType !== undefined ? { type: synonymType } : {}) };
231
+ }
232
+ function synonymsIn(index: string, root?: string): Array<Record<string, unknown> & { objectID: string }> {
233
+ const prefix = `synonym:${index}::`;
234
+ return rowsOfType('synonym', root)
235
+ .filter((r) => r.id.startsWith(prefix))
236
+ .map((r) => {
237
+ const parsed = parseSynonymSuffix(r.id.slice('synonym:'.length))!;
238
+ return { ...fromStoredSynonymFields(publicFields(r)), objectID: parsed.id };
239
+ });
240
+ }
241
+ function readSettings(index: string, root?: string): Record<string, unknown> {
242
+ const row = getRowById('settings', settingsKey(index), root);
243
+ return {
244
+ searchableAttributes: (row?.searchableAttributes as string[] | undefined) ?? [],
245
+ attributesForFaceting: (row?.attributesForFaceting as string[] | undefined) ?? [],
246
+ // This default is the vendor's documented stock ranking-criteria list (returned faithfully so
247
+ // reads/round-trips match real Algolia); it is NOT the pack's modeled ranking subset —
248
+ // `rankRecords` (algolia-search.ts) ignores this array entirely and always ranks by its own
249
+ // faithful-subset criteria order, custom ranking excepted.
250
+ ranking: (row?.ranking as string[] | undefined) ?? ['typo', 'geo', 'words', 'filters', 'proximity', 'attribute', 'exact', 'custom'],
251
+ customRanking: (row?.customRanking as string[] | undefined) ?? [],
252
+ };
253
+ }
254
+ function parseCustomRanking(customRanking: readonly string[]): CustomRankingRule[] {
255
+ const rules: CustomRankingRule[] = [];
256
+ for (const raw of customRanking) {
257
+ const m = /^(asc|desc)\(([^)]+)\)$/.exec(raw.trim());
258
+ if (m) rules.push({ direction: m[1] as 'asc' | 'desc', attribute: m[2]! });
259
+ }
260
+ return rules;
261
+ }
262
+
263
+ function recordView(r: Row): SearchRecord {
264
+ const parsed = parseRecordSuffix(r.id.slice('record:'.length))!;
265
+ return { ...publicFields(r), objectID: parsed.objectID } as SearchRecord;
266
+ }
267
+
268
+ // ── record write primitives (shared by direct endpoints AND the /batch grammar, ⚠5) ─────────
269
+ // R9 (serve-path determinism) AT THE RESOURCE LEVEL: the minted id is a function of the WRITE —
270
+ // index + the world instant the request occurred at + the ordinal this record takes in the index —
271
+ // and of NOTHING ELSE. It deliberately does NOT hash the world `root`: the root is a filesystem
272
+ // path, so folding it in made the served objectID a function of WHERE the world happens to live,
273
+ // and two identical worlds under different directories served different ids (found 2026-09-02 by
274
+ // the R9 replay sweep, which reads a minted id back through `GET /1/indexes/:name/:objectID`).
275
+ // `salt` is the count of records in the index BEFORE the insert, which is what keeps two adds
276
+ // landing in the SAME millisecond distinct.
277
+ function mintObjectId(index: string, occurredAt: string, salt: number): string {
278
+ return createHash('sha256').update(`algolia-twin-objectid:${index}:${occurredAt}:${salt}`).digest('hex').slice(0, 12);
279
+ }
280
+
281
+ /** Add-or-REPLACE the whole record: any field the PREVIOUS write set that is absent from the new
282
+ * body is explicitly nulled out (the kernel's field-merge model has no other way to make a field
283
+ * disappear — see `publicFields`'s header note). Looks at the RAW historical row (not the
284
+ * filtered current one) so a replace after a delete-then-recreate still starts genuinely fresh. */
285
+ async function writeAddOrReplace(index: string, objectID: string, body: Record<string, unknown>, req: AlgoliaRequest): Promise<void> {
286
+ await ensureIndex(index, req);
287
+ const raw = getRawRowById('record', recordKey(index, objectID), req.root);
288
+ const { objectID: _drop, ...fields } = body;
289
+ const erase: Record<string, unknown> = {};
290
+ if (raw) {
291
+ const { type: _t, id: _id, _deleted: _d, ...oldFields } = raw;
292
+ for (const k of Object.keys(oldFields)) if (!(k in fields)) erase[k] = null;
293
+ }
294
+ await applyWrite('record', recordKey(index, objectID), { ...erase, ...fields, _deleted: false }, 'record.add_replace', req);
295
+ }
296
+ async function writePartialUpdate(index: string, objectID: string, patch: Record<string, unknown>, req: AlgoliaRequest, createIfNotExists: boolean): Promise<'ok' | 'not_found'> {
297
+ const existing = getRowById('record', recordKey(index, objectID), req.root);
298
+ if (!existing && !createIfNotExists) return 'not_found';
299
+ await ensureIndex(index, req);
300
+ const { objectID: _drop, ...patchFields } = patch;
301
+ // A genuine MERGE, unlike writeAddOrReplace: the kernel's own field-merge (shadow.ts's
302
+ // applyFields) already does exactly this on top of whatever is currently stored, so writing
303
+ // just the patch (no manual `{...existing, ...patch}` pre-merge) is both correct and simpler;
304
+ // `_deleted: false` covers the createIfNotExists-on-a-previously-deleted-id edge case.
305
+ await applyWrite('record', recordKey(index, objectID), { ...patchFields, _deleted: false }, 'record.partial_update', req);
306
+ return 'ok';
307
+ }
308
+ async function writeDelete(index: string, objectID: string, req: AlgoliaRequest): Promise<void> {
309
+ await applyWrite('record', recordKey(index, objectID), { _deleted: true }, 'record.delete', req);
310
+ }
311
+
312
+ // ── route handler ────────────────────────────────────────────────────────────────────────────
313
+ export async function handleAlgoliaTwinRequest(req: AlgoliaRequest): Promise<AlgoliaResponse> {
314
+ const method = req.method.toUpperCase();
315
+ const [rawPath, rawQuery] = req.path.split('?');
316
+ const path = (rawPath ?? '/').replace(/\/+$/, '') || '/';
317
+ const query = new URLSearchParams(rawQuery ?? '');
318
+ const seg = path.replace(/^\/+/, '').split('/').filter(Boolean);
319
+ const idAt = (i: number) => decodeURIComponent(seg[i] ?? '');
320
+ routeAlgoliaSurface({ host: req.host, path }); // host-tolerance is unconditional — see file header
321
+
322
+ if (path === '/' || seg.length === 0) {
323
+ return { status: 200, body: { service: 'algolia', object: 'twin' } };
324
+ }
325
+ if (seg[0] !== '1' || seg[1] !== 'indexes') {
326
+ return algoliaError(404, `Route not found: ${method} ${path}`);
327
+ }
328
+
329
+ // ── GET /1/indexes — list ────────────────────────────────────────────────────────────────
330
+ if (seg.length === 2 && method === 'GET') {
331
+ const items = rowsOfType('index', req.root).map((r) => ({
332
+ name: (r.name as string) ?? '',
333
+ entries: recordsIn(r.name as string, req.root).length,
334
+ createdAt: r.createdAt ?? nowIso(req.occurredAt),
335
+ updatedAt: r.createdAt ?? nowIso(req.occurredAt),
336
+ }));
337
+ return { status: 200, body: { items, nbPages: 1 } };
338
+ }
339
+
340
+ const indexName = idAt(2);
341
+
342
+ // ── DELETE /1/indexes/:name — delete index + its records/settings/synonyms ─────────────────
343
+ if (seg.length === 3 && method === 'DELETE') {
344
+ if (req.readOnly) return readOnlyRejection();
345
+ for (const r of recordsIn(indexName, req.root)) await writeDelete(indexName, r.objectID, req);
346
+ for (const s of synonymsIn(indexName, req.root)) await applyWrite('synonym', synonymKey(indexName, s.objectID), { _deleted: true }, 'synonym.delete', req);
347
+ await applyWrite('index', indexKey(indexName), { _deleted: true }, 'index.delete', req);
348
+ return { status: 200, body: { taskID: 0, deletedAt: nowIso(req.occurredAt) } };
349
+ }
350
+
351
+ // ── POST /1/indexes/:name — add object, auto objectID ───────────────────────────────────
352
+ if (seg.length === 3 && method === 'POST') {
353
+ if (req.readOnly) return readOnlyRejection();
354
+ const body = parseObjectBody(req.body);
355
+ const occurredAt = nowIso(req.occurredAt);
356
+ const objectID = typeof body.objectID === 'string' && body.objectID.length > 0 ? body.objectID : mintObjectId(indexName, occurredAt, recordsIn(indexName, req.root).length);
357
+ await writeAddOrReplace(indexName, objectID, { ...body, objectID }, req);
358
+ // Algolia's write endpoints conventionally return 200, not 201 (doc-UNVERIFIED — not
359
+ // strictly REST-resource-creation-shaped; modeled on the well-known convention).
360
+ return { status: 200, body: { objectID, taskID: 0, createdAt: occurredAt } };
361
+ }
362
+
363
+ // ── /1/indexes/:name/clear|batch|query|settings|synonyms ────────────────────────────────
364
+ const sub = seg.length >= 4 ? seg[3] : undefined;
365
+
366
+ if (seg.length === 4 && sub === 'clear' && method === 'POST') {
367
+ if (req.readOnly) return readOnlyRejection();
368
+ if (!getRowById('index', indexKey(indexName), req.root)) return algoliaError(404, `Index ${indexName} does not exist`);
369
+ for (const r of recordsIn(indexName, req.root)) await writeDelete(indexName, r.objectID, req);
370
+ return { status: 200, body: { taskID: 0, updatedAt: nowIso(req.occurredAt) } };
371
+ }
372
+
373
+ if (seg.length === 4 && sub === 'batch' && method === 'POST') {
374
+ if (req.readOnly) return readOnlyRejection();
375
+ const body = parseObjectBody(req.body);
376
+ const requests = Array.isArray(body.requests) ? (body.requests as Array<Record<string, unknown>>) : [];
377
+ const occurredAt = nowIso(req.occurredAt);
378
+ const objectIDs: string[] = [];
379
+ for (let i = 0; i < requests.length; i++) {
380
+ const action = requests[i]!.action as string;
381
+ const recBody = (requests[i]!.body as Record<string, unknown>) ?? {};
382
+ if (action === 'addObject') {
383
+ const objectID = typeof recBody.objectID === 'string' && recBody.objectID.length > 0 ? recBody.objectID : mintObjectId(indexName, occurredAt, recordsIn(indexName, req.root).length + i);
384
+ await writeAddOrReplace(indexName, objectID, { ...recBody, objectID }, req);
385
+ objectIDs.push(objectID);
386
+ } else if (action === 'updateObject') {
387
+ const objectID = typeof recBody.objectID === 'string' ? recBody.objectID : '';
388
+ if (!objectID) return algoliaError(400, 'updateObject requires an objectID in the request body');
389
+ await writeAddOrReplace(indexName, objectID, recBody, req);
390
+ objectIDs.push(objectID);
391
+ } else if (action === 'partialUpdateObject' || action === 'partialUpdateObjectNoCreate') {
392
+ const objectID = typeof recBody.objectID === 'string' ? recBody.objectID : '';
393
+ if (!objectID) return algoliaError(400, `${action} requires an objectID in the request body`);
394
+ const outcome = await writePartialUpdate(indexName, objectID, recBody, req, action === 'partialUpdateObject');
395
+ if (outcome === 'not_found') return algoliaError(404, `Object not found - Received createIfNotExist=false and object ${objectID} does not exist yet`);
396
+ objectIDs.push(objectID);
397
+ } else if (action === 'deleteObject') {
398
+ const objectID = typeof recBody.objectID === 'string' ? recBody.objectID : '';
399
+ if (!objectID) return algoliaError(400, 'deleteObject requires an objectID in the request body');
400
+ await writeDelete(indexName, objectID, req);
401
+ objectIDs.push(objectID);
402
+ } else {
403
+ return algoliaError(400, `Invalid batch action: ${String(action)}`);
404
+ }
405
+ }
406
+ return { status: 200, body: { objectIDs, taskID: 0 } };
407
+ }
408
+
409
+ if (seg.length === 4 && sub === 'query' && method === 'POST') {
410
+ return runSearch(indexName, parseObjectBody(req.body), req);
411
+ }
412
+
413
+ if (seg.length === 4 && sub === 'settings' && method === 'GET') {
414
+ return { status: 200, body: readSettings(indexName, req.root) };
415
+ }
416
+ if (seg.length === 4 && sub === 'settings' && method === 'PUT') {
417
+ if (req.readOnly) return readOnlyRejection();
418
+ const patch = parseObjectBody(req.body);
419
+ const existing = readSettings(indexName, req.root);
420
+ await applyWrite('settings', settingsKey(indexName), { ...existing, ...patch }, 'settings.set', req);
421
+ return { status: 200, body: { taskID: 0, updatedAt: nowIso(req.occurredAt) } };
422
+ }
423
+
424
+ // ── synonyms ──────────────────────────────────────────────────────────────────────────────
425
+ if (sub === 'synonyms') {
426
+ if (seg.length === 5 && seg[4] === 'batch' && method === 'POST') {
427
+ if (req.readOnly) return readOnlyRejection();
428
+ const arr = parseBody(req.body);
429
+ const synonyms = Array.isArray(arr) ? (arr as Array<Record<string, unknown>>) : [];
430
+ for (const s of synonyms) {
431
+ const objectID = typeof s.objectID === 'string' ? s.objectID : '';
432
+ if (!objectID) return algoliaError(400, 'each synonym requires an objectID');
433
+ await ensureIndex(indexName, req);
434
+ await applyWrite('synonym', synonymKey(indexName, objectID), toStoredSynonymFields(s), 'synonym.save', req);
435
+ }
436
+ return { status: 200, body: { updatedAt: nowIso(req.occurredAt), taskID: 0 } };
437
+ }
438
+ if (seg.length === 5 && method === 'GET') {
439
+ const objectID = idAt(4);
440
+ const row = getRowById('synonym', synonymKey(indexName, objectID), req.root);
441
+ if (!row) return algoliaError(404, `ObjectID does not exist`);
442
+ return { status: 200, body: { ...fromStoredSynonymFields(publicFields(row)), objectID } };
443
+ }
444
+ if (seg.length === 5 && method === 'DELETE') {
445
+ if (req.readOnly) return readOnlyRejection();
446
+ const objectID = idAt(4);
447
+ await applyWrite('synonym', synonymKey(indexName, objectID), { _deleted: true }, 'synonym.delete', req);
448
+ return { status: 200, body: { deletedAt: nowIso(req.occurredAt), taskID: 0 } };
449
+ }
450
+ return algoliaError(404, `Route not found: ${method} ${path}`);
451
+ }
452
+
453
+ // ── record direct endpoints: GET/PUT/DELETE /1/indexes/:name/:objectID (+ /partial) ───────
454
+ if (seg.length === 4) {
455
+ const objectID = idAt(3);
456
+ if (method === 'GET') {
457
+ const row = getRowById('record', recordKey(indexName, objectID), req.root);
458
+ if (!row) return algoliaError(404, `ObjectID does not exist`);
459
+ return { status: 200, body: recordView(row) };
460
+ }
461
+ if (method === 'PUT') {
462
+ if (req.readOnly) return readOnlyRejection();
463
+ const body = parseObjectBody(req.body);
464
+ await writeAddOrReplace(indexName, objectID, { ...body, objectID }, req);
465
+ return { status: 200, body: { objectID, taskID: 0, updatedAt: nowIso(req.occurredAt) } };
466
+ }
467
+ if (method === 'DELETE') {
468
+ if (req.readOnly) return readOnlyRejection();
469
+ // Delete is idempotent — deleting an already-absent objectID is still a 200, mirroring the
470
+ // real product (not a fabricated success: nothing false is claimed, the end state is
471
+ // genuinely "absent" either way).
472
+ await writeDelete(indexName, objectID, req);
473
+ return { status: 200, body: { deletedAt: nowIso(req.occurredAt), taskID: 0 } };
474
+ }
475
+ }
476
+ if (seg.length === 5 && seg[4] === 'partial' && method === 'POST') {
477
+ if (req.readOnly) return readOnlyRejection();
478
+ const objectID = idAt(3);
479
+ const body = parseObjectBody(req.body);
480
+ // Direct endpoint default: createIfNotExists defaults to TRUE when the query param is
481
+ // omitted (the well-known, widely-published REST-endpoint default — doc-UNVERIFIED not
482
+ // live-fetched, distinct from the JS SDK v4 client's own more-conservative default of
483
+ // `false`/NoCreate when its OWN option is omitted, confirmed live via probe — see file
484
+ // header and ⚠4/spec-sources.json).
485
+ const createIfNotExists = query.get('createIfNotExists') === 'false' ? false : true;
486
+ const outcome = await writePartialUpdate(indexName, objectID, body, req, createIfNotExists);
487
+ if (outcome === 'not_found') return algoliaError(404, `Object not found - Received createIfNotExist=false and object ${objectID} does not exist yet`);
488
+ return { status: 200, body: { objectID, taskID: 0, updatedAt: nowIso(req.occurredAt) } };
489
+ }
490
+
491
+ return algoliaError(404, `Route not found: ${method} ${path}`);
492
+ }
493
+
494
+ function runSearch(indexName: string, body: Record<string, unknown>, req: AlgoliaRequest): AlgoliaResponse {
495
+ const indexRow = getRowById('index', indexKey(indexName), req.root);
496
+ if (!indexRow) return algoliaError(404, `Index ${indexName} does not exist`);
497
+ const settings = readSettings(indexName, req.root);
498
+ const query = typeof body.query === 'string' ? body.query : '';
499
+ const page = typeof body.page === 'number' ? body.page : 0;
500
+ const hitsPerPage = typeof body.hitsPerPage === 'number' ? body.hitsPerPage : 20;
501
+ const filters = typeof body.filters === 'string' ? body.filters : undefined;
502
+ const facetFilters = (body.facetFilters as FacetFilters | undefined) ?? undefined;
503
+ const attributesToRetrieve = Array.isArray(body.attributesToRetrieve) ? (body.attributesToRetrieve as string[]) : undefined;
504
+ const requestedFacets = Array.isArray(body.facets) ? (body.facets as string[]) : undefined;
505
+
506
+ const allRecords = recordsIn(indexName, req.root);
507
+ const synonymGroups = buildSynonymGroups(synonymsIn(indexName, req.root) as Array<{ type?: string; synonyms?: string[] }>);
508
+ const preFiltered = allRecords.filter((r) => matchesAlgoliaFilters(r, filters) && matchesFacetFilters(r, facetFilters));
509
+
510
+ const customRanking = parseCustomRanking(settings.customRanking as string[]);
511
+ const ranked = rankRecords(preFiltered, query, {
512
+ searchableAttributes: settings.searchableAttributes as string[],
513
+ customRanking,
514
+ synonymGroups,
515
+ });
516
+
517
+ const nbHits = ranked.length;
518
+ const hitsPerPageEff = Math.max(1, hitsPerPage);
519
+ const nbPages = Math.max(1, Math.ceil(nbHits / hitsPerPageEff));
520
+ const pageHits = ranked.slice(page * hitsPerPageEff, page * hitsPerPageEff + hitsPerPageEff);
521
+
522
+ const project = (record: SearchRecord): Record<string, unknown> => {
523
+ if (!attributesToRetrieve || attributesToRetrieve.includes('*')) return { ...record, objectID: record.objectID };
524
+ const out: Record<string, unknown> = { objectID: record.objectID };
525
+ for (const attr of attributesToRetrieve) if (attr in record) out[attr] = record[attr];
526
+ return out;
527
+ };
528
+
529
+ const facetAttrs = (settings.attributesForFaceting as string[]).filter((a) => !requestedFacets || requestedFacets.includes('*') || requestedFacets.includes(a));
530
+ const responseBody: Record<string, unknown> = {
531
+ hits: pageHits.map((h) => project(h.record)),
532
+ nbHits,
533
+ page,
534
+ nbPages,
535
+ hitsPerPage: hitsPerPageEff,
536
+ processingTimeMS: 0,
537
+ query,
538
+ params: '',
539
+ };
540
+ if (requestedFacets && requestedFacets.length > 0) {
541
+ responseBody.facets = computeFacetCounts(preFiltered, facetAttrs);
542
+ }
543
+ return { status: 200, body: responseBody };
544
+ }
545
+
546
+ export type AlgoliaTwinSnapshot = {
547
+ resourceTypes: readonly AlgoliaResourceType[];
548
+ implementedEndpoints: readonly string[];
549
+ };
550
+
551
+ export function algoliaTwinSnapshot(): AlgoliaTwinSnapshot {
552
+ return {
553
+ resourceTypes: ALGOLIA_RESOURCE_TYPES,
554
+ implementedEndpoints: [
555
+ 'GET /1/indexes', 'DELETE /1/indexes/:name', 'POST /1/indexes/:name', 'POST /1/indexes/:name/clear',
556
+ 'POST /1/indexes/:name/batch', 'POST /1/indexes/:name/query',
557
+ 'GET /1/indexes/:name/settings', 'PUT /1/indexes/:name/settings',
558
+ 'POST /1/indexes/:name/synonyms/batch', 'GET /1/indexes/:name/synonyms/:objectID', 'DELETE /1/indexes/:name/synonyms/:objectID',
559
+ 'GET /1/indexes/:name/:objectID', 'PUT /1/indexes/:name/:objectID', 'DELETE /1/indexes/:name/:objectID',
560
+ 'POST /1/indexes/:name/:objectID/partial',
561
+ ],
562
+ };
563
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env node
2
+ import { keepProcessAlive } from '@volter/world-core/lifecycle';
3
+ // world-algolia CLI: serve the KERNEL-BACKED Algolia API twin, or run conformance. State lives in
4
+ // the @volter/world-core action log (no in-memory side-store). Conformance is dev-only + lazy-imported
5
+ // so the bin runs without @volter/world-tooling (E2).
6
+ import { hasFlag, optionValue } from '@volter/world-core/args';
7
+ import { createAlgoliaTwinServer } from './algolia-server.ts';
8
+
9
+ const [cmd, ...rest] = process.argv.slice(2);
10
+ const port = Number(optionValue(rest, '--port', String(process.env.PORT ?? '0'))) || undefined;
11
+ const root = optionValue(rest, '--root') || undefined;
12
+ const readOnly = hasFlag(rest, '--read-only'); // a twin accepts writes unless started read-only
13
+
14
+ if (cmd === 'serve' || cmd === undefined) {
15
+ const s = await createAlgoliaTwinServer({ readOnly, ...(root ? { root } : {}), ...(port ? { port } : {}) });
16
+ process.stdout.write(`algolia twin (hosted search: indices/records + real tokenize/typo/filter/facet/ranking search)${readOnly ? ' [read-only]' : ''} at http://127.0.0.1:${s.port}\n`);
17
+ await keepProcessAlive();
18
+ } else if (cmd === 'conformance') {
19
+ const { checkAlgoliaConformance } = await import('./algolia-conformance.ts');
20
+ const report = checkAlgoliaConformance();
21
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
22
+ if (!report.ok) process.exitCode = 1;
23
+ } else {
24
+ process.stdout.write('Usage: world-algolia serve|conformance [--port N] [--root DIR] [--read-only]\n');
25
+ }