@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,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
|
+
}
|