@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,540 @@
|
|
|
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 } from "./algolia-filter.js";
|
|
54
|
+
import { rankRecords, buildSynonymGroups } from "./algolia-search.js";
|
|
55
|
+
const SERVICE = 'algolia';
|
|
56
|
+
export const ALGOLIA_RESOURCE_TYPES = ['index', 'record', 'synonym'];
|
|
57
|
+
/** Canonical WRITE-host form for an appId — real Algolia hosts literally embed the appId as a
|
|
58
|
+
* losslessly-recoverable prefix (unlike pinecone's synthetic per-index hash host), so no hash
|
|
59
|
+
* is needed; `root` is accepted for signature symmetry with pinecone's mintHost but does not
|
|
60
|
+
* affect the output (documented, not a bug — there is nothing per-root to disambiguate: the
|
|
61
|
+
* appId already IS the whole identity a host shape encodes). */
|
|
62
|
+
export function mintHost(appId, _root) {
|
|
63
|
+
return `${appId}.algolia.net`;
|
|
64
|
+
}
|
|
65
|
+
/** Recover an appId from ANY of the three real host shapes (or a bare Host: port-suffixed
|
|
66
|
+
* variant), tolerating whichever one a caller presents. Returns undefined for an unrecognized
|
|
67
|
+
* shape. */
|
|
68
|
+
export function recoverAppIdFromHost(host) {
|
|
69
|
+
if (!host)
|
|
70
|
+
return undefined;
|
|
71
|
+
const bare = host.replace(/^https?:\/\//, '').split(':')[0];
|
|
72
|
+
let m = bare.match(/^(.+)-dsn\.algolia\.net$/);
|
|
73
|
+
if (m)
|
|
74
|
+
return m[1];
|
|
75
|
+
m = bare.match(/^(.+)\.algolia\.net$/);
|
|
76
|
+
if (m)
|
|
77
|
+
return m[1];
|
|
78
|
+
m = bare.match(/^(.+)-\d+\.algolianet\.com$/);
|
|
79
|
+
if (m)
|
|
80
|
+
return m[1];
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
/** Accepts EVERY real host shape (and no host at all) — always resolves to the single `'api'`
|
|
84
|
+
* surface; actual dispatch is by PATH alone (see file header). */
|
|
85
|
+
export function routeAlgoliaSurface(_req) {
|
|
86
|
+
return 'api';
|
|
87
|
+
}
|
|
88
|
+
// ── error envelope (single-plane — simpler than pinecone's two-plane split, spec §3) ─────────
|
|
89
|
+
function algoliaError(status, message) {
|
|
90
|
+
return { status, body: { message, status } };
|
|
91
|
+
}
|
|
92
|
+
function readOnlyRejection() {
|
|
93
|
+
return { status: 405, body: { message: 'twin is read-only; omit readOnly to accept writes', status: 405 } };
|
|
94
|
+
}
|
|
95
|
+
// ── body / helpers ────────────────────────────────────────────────────────────────────────────
|
|
96
|
+
function parseBody(body) {
|
|
97
|
+
if (!body)
|
|
98
|
+
return undefined;
|
|
99
|
+
try {
|
|
100
|
+
return JSON.parse(body);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function parseObjectBody(body) {
|
|
107
|
+
const v = parseBody(body);
|
|
108
|
+
return v && typeof v === 'object' && !Array.isArray(v) ? v : {};
|
|
109
|
+
}
|
|
110
|
+
function nowIso(occurredAt) {
|
|
111
|
+
return occurredAt ?? new Date().toISOString();
|
|
112
|
+
}
|
|
113
|
+
// ── kernel subject ids (type-prefixed; D1) ──────────────────────────────────────────────────
|
|
114
|
+
function indexKey(name) {
|
|
115
|
+
return `index:${name}`;
|
|
116
|
+
}
|
|
117
|
+
function recordKey(index, objectID) {
|
|
118
|
+
return `record:${index}::${objectID}`;
|
|
119
|
+
}
|
|
120
|
+
function synonymKey(index, id) {
|
|
121
|
+
return `synonym:${index}::${id}`;
|
|
122
|
+
}
|
|
123
|
+
function settingsKey(index) {
|
|
124
|
+
return `settings:${index}`;
|
|
125
|
+
}
|
|
126
|
+
function parseRecordSuffix(suffix) {
|
|
127
|
+
const parts = suffix.split('::');
|
|
128
|
+
if (parts.length !== 2)
|
|
129
|
+
return undefined;
|
|
130
|
+
return { index: parts[0], objectID: parts[1] };
|
|
131
|
+
}
|
|
132
|
+
function parseSynonymSuffix(suffix) {
|
|
133
|
+
const parts = suffix.split('::');
|
|
134
|
+
if (parts.length !== 2)
|
|
135
|
+
return undefined;
|
|
136
|
+
return { index: parts[0], id: parts[1] };
|
|
137
|
+
}
|
|
138
|
+
function rowsOfType(type, root) {
|
|
139
|
+
const prefix = `${type}:`;
|
|
140
|
+
return projectResources(SERVICE, root).filter((r) => r.type === type && r.id.startsWith(prefix) && r._deleted !== true);
|
|
141
|
+
}
|
|
142
|
+
function getRowById(type, subjectId, root) {
|
|
143
|
+
return projectResources(SERVICE, root).find((r) => r.type === type && r.id === subjectId && r._deleted !== true);
|
|
144
|
+
}
|
|
145
|
+
/** Raw row lookup, WITHOUT filtering `_deleted` — used only to discover every field name the
|
|
146
|
+
* kernel has ever merged for a subject (see `writeAddOrReplace`'s header note on why a true
|
|
147
|
+
* "replace" needs this). */
|
|
148
|
+
function getRawRowById(type, subjectId, root) {
|
|
149
|
+
return projectResources(SERVICE, root).find((r) => r.type === type && r.id === subjectId);
|
|
150
|
+
}
|
|
151
|
+
/** The kernel's action log is FIELD-MERGE, not whole-record-replace (`shadow.ts`'s `applyFields`
|
|
152
|
+
* only ever ADDS/OVERWRITES the fields present in a given write — there is no way to remove a
|
|
153
|
+
* field from the merged shadow except writing it as `null`, which every reader below strips back
|
|
154
|
+
* out). `undefined` is silently skipped by the kernel (not a valid erase signal); `null` is a
|
|
155
|
+
* genuine value the kernel stores and merges, so this pack repurposes it as the erase sentinel.
|
|
156
|
+
* Also strips the kernel's OWN meta fields (`type`/`id`/`updatedAt` always ride along on every
|
|
157
|
+
* `projectResources` row; `_deleted` is this pack's own soft-delete/undelete marker) — a caller-
|
|
158
|
+
* supplied field literally named `updatedAt` would collide, an accepted, documented limitation
|
|
159
|
+
* (mirrors every other pack's kernel-row convention; see pinecone-twin.ts's view functions,
|
|
160
|
+
* which hand-pick fields for the same reason). */
|
|
161
|
+
const INTERNAL_ROW_FIELDS = new Set(['type', 'id', 'updatedAt', '_deleted']);
|
|
162
|
+
function publicFields(row) {
|
|
163
|
+
const out = {};
|
|
164
|
+
for (const [k, v] of Object.entries(row)) {
|
|
165
|
+
if (INTERNAL_ROW_FIELDS.has(k) || v === null)
|
|
166
|
+
continue;
|
|
167
|
+
out[k] = v;
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
async function applyWrite(subjectType, subjectId, fields, operation, req) {
|
|
172
|
+
const { resource } = await applyTwinWrite(SERVICE, { operation, subjectType, subjectId, fields, ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}), actor: { kind: 'agent' } }, req.root);
|
|
173
|
+
return resource;
|
|
174
|
+
}
|
|
175
|
+
/** Ensure `index:<name>` exists (real Algolia mints an index implicitly on first write — no
|
|
176
|
+
* explicit create-index endpoint, matches the real product). */
|
|
177
|
+
async function ensureIndex(name, req) {
|
|
178
|
+
if (getRowById('index', indexKey(name), req.root))
|
|
179
|
+
return;
|
|
180
|
+
await applyWrite('index', indexKey(name), { name, createdAt: nowIso(req.occurredAt) }, 'index.touch', req);
|
|
181
|
+
}
|
|
182
|
+
function recordsIn(index, root) {
|
|
183
|
+
const prefix = `record:${index}::`;
|
|
184
|
+
return rowsOfType('record', root)
|
|
185
|
+
.filter((r) => r.id.startsWith(prefix))
|
|
186
|
+
.map((r) => {
|
|
187
|
+
const parsed = parseRecordSuffix(r.id.slice('record:'.length));
|
|
188
|
+
return { ...publicFields(r), objectID: parsed.objectID };
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
// Algolia's synonym schema has its OWN field literally called `type` (`"synonym"` |
|
|
192
|
+
// `"oneWaySynonym"` | ...) — a genuine collision with the kernel's reserved row-meta field of
|
|
193
|
+
// the SAME name (`projectResources`'s own `META` set silently drops any stored field named
|
|
194
|
+
// `type`/`id`/`updatedAt` — see `publicFields`'s header note). Stored under `synonymType`
|
|
195
|
+
// internally; renamed back to `type` on every read.
|
|
196
|
+
function toStoredSynonymFields(s) {
|
|
197
|
+
const { objectID: _drop, type, ...rest } = s;
|
|
198
|
+
return { ...rest, ...(type !== undefined ? { synonymType: type } : {}) };
|
|
199
|
+
}
|
|
200
|
+
function fromStoredSynonymFields(fields) {
|
|
201
|
+
const { synonymType, ...rest } = fields;
|
|
202
|
+
return { ...rest, ...(synonymType !== undefined ? { type: synonymType } : {}) };
|
|
203
|
+
}
|
|
204
|
+
function synonymsIn(index, root) {
|
|
205
|
+
const prefix = `synonym:${index}::`;
|
|
206
|
+
return rowsOfType('synonym', root)
|
|
207
|
+
.filter((r) => r.id.startsWith(prefix))
|
|
208
|
+
.map((r) => {
|
|
209
|
+
const parsed = parseSynonymSuffix(r.id.slice('synonym:'.length));
|
|
210
|
+
return { ...fromStoredSynonymFields(publicFields(r)), objectID: parsed.id };
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
function readSettings(index, root) {
|
|
214
|
+
const row = getRowById('settings', settingsKey(index), root);
|
|
215
|
+
return {
|
|
216
|
+
searchableAttributes: row?.searchableAttributes ?? [],
|
|
217
|
+
attributesForFaceting: row?.attributesForFaceting ?? [],
|
|
218
|
+
// This default is the vendor's documented stock ranking-criteria list (returned faithfully so
|
|
219
|
+
// reads/round-trips match real Algolia); it is NOT the pack's modeled ranking subset —
|
|
220
|
+
// `rankRecords` (algolia-search.ts) ignores this array entirely and always ranks by its own
|
|
221
|
+
// faithful-subset criteria order, custom ranking excepted.
|
|
222
|
+
ranking: row?.ranking ?? ['typo', 'geo', 'words', 'filters', 'proximity', 'attribute', 'exact', 'custom'],
|
|
223
|
+
customRanking: row?.customRanking ?? [],
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
function parseCustomRanking(customRanking) {
|
|
227
|
+
const rules = [];
|
|
228
|
+
for (const raw of customRanking) {
|
|
229
|
+
const m = /^(asc|desc)\(([^)]+)\)$/.exec(raw.trim());
|
|
230
|
+
if (m)
|
|
231
|
+
rules.push({ direction: m[1], attribute: m[2] });
|
|
232
|
+
}
|
|
233
|
+
return rules;
|
|
234
|
+
}
|
|
235
|
+
function recordView(r) {
|
|
236
|
+
const parsed = parseRecordSuffix(r.id.slice('record:'.length));
|
|
237
|
+
return { ...publicFields(r), objectID: parsed.objectID };
|
|
238
|
+
}
|
|
239
|
+
// ── record write primitives (shared by direct endpoints AND the /batch grammar, ⚠5) ─────────
|
|
240
|
+
// R9 (serve-path determinism) AT THE RESOURCE LEVEL: the minted id is a function of the WRITE —
|
|
241
|
+
// index + the world instant the request occurred at + the ordinal this record takes in the index —
|
|
242
|
+
// and of NOTHING ELSE. It deliberately does NOT hash the world `root`: the root is a filesystem
|
|
243
|
+
// path, so folding it in made the served objectID a function of WHERE the world happens to live,
|
|
244
|
+
// and two identical worlds under different directories served different ids (found 2026-09-02 by
|
|
245
|
+
// the R9 replay sweep, which reads a minted id back through `GET /1/indexes/:name/:objectID`).
|
|
246
|
+
// `salt` is the count of records in the index BEFORE the insert, which is what keeps two adds
|
|
247
|
+
// landing in the SAME millisecond distinct.
|
|
248
|
+
function mintObjectId(index, occurredAt, salt) {
|
|
249
|
+
return createHash('sha256').update(`algolia-twin-objectid:${index}:${occurredAt}:${salt}`).digest('hex').slice(0, 12);
|
|
250
|
+
}
|
|
251
|
+
/** Add-or-REPLACE the whole record: any field the PREVIOUS write set that is absent from the new
|
|
252
|
+
* body is explicitly nulled out (the kernel's field-merge model has no other way to make a field
|
|
253
|
+
* disappear — see `publicFields`'s header note). Looks at the RAW historical row (not the
|
|
254
|
+
* filtered current one) so a replace after a delete-then-recreate still starts genuinely fresh. */
|
|
255
|
+
async function writeAddOrReplace(index, objectID, body, req) {
|
|
256
|
+
await ensureIndex(index, req);
|
|
257
|
+
const raw = getRawRowById('record', recordKey(index, objectID), req.root);
|
|
258
|
+
const { objectID: _drop, ...fields } = body;
|
|
259
|
+
const erase = {};
|
|
260
|
+
if (raw) {
|
|
261
|
+
const { type: _t, id: _id, _deleted: _d, ...oldFields } = raw;
|
|
262
|
+
for (const k of Object.keys(oldFields))
|
|
263
|
+
if (!(k in fields))
|
|
264
|
+
erase[k] = null;
|
|
265
|
+
}
|
|
266
|
+
await applyWrite('record', recordKey(index, objectID), { ...erase, ...fields, _deleted: false }, 'record.add_replace', req);
|
|
267
|
+
}
|
|
268
|
+
async function writePartialUpdate(index, objectID, patch, req, createIfNotExists) {
|
|
269
|
+
const existing = getRowById('record', recordKey(index, objectID), req.root);
|
|
270
|
+
if (!existing && !createIfNotExists)
|
|
271
|
+
return 'not_found';
|
|
272
|
+
await ensureIndex(index, req);
|
|
273
|
+
const { objectID: _drop, ...patchFields } = patch;
|
|
274
|
+
// A genuine MERGE, unlike writeAddOrReplace: the kernel's own field-merge (shadow.ts's
|
|
275
|
+
// applyFields) already does exactly this on top of whatever is currently stored, so writing
|
|
276
|
+
// just the patch (no manual `{...existing, ...patch}` pre-merge) is both correct and simpler;
|
|
277
|
+
// `_deleted: false` covers the createIfNotExists-on-a-previously-deleted-id edge case.
|
|
278
|
+
await applyWrite('record', recordKey(index, objectID), { ...patchFields, _deleted: false }, 'record.partial_update', req);
|
|
279
|
+
return 'ok';
|
|
280
|
+
}
|
|
281
|
+
async function writeDelete(index, objectID, req) {
|
|
282
|
+
await applyWrite('record', recordKey(index, objectID), { _deleted: true }, 'record.delete', req);
|
|
283
|
+
}
|
|
284
|
+
// ── route handler ────────────────────────────────────────────────────────────────────────────
|
|
285
|
+
export async function handleAlgoliaTwinRequest(req) {
|
|
286
|
+
const method = req.method.toUpperCase();
|
|
287
|
+
const [rawPath, rawQuery] = req.path.split('?');
|
|
288
|
+
const path = (rawPath ?? '/').replace(/\/+$/, '') || '/';
|
|
289
|
+
const query = new URLSearchParams(rawQuery ?? '');
|
|
290
|
+
const seg = path.replace(/^\/+/, '').split('/').filter(Boolean);
|
|
291
|
+
const idAt = (i) => decodeURIComponent(seg[i] ?? '');
|
|
292
|
+
routeAlgoliaSurface({ host: req.host, path }); // host-tolerance is unconditional — see file header
|
|
293
|
+
if (path === '/' || seg.length === 0) {
|
|
294
|
+
return { status: 200, body: { service: 'algolia', object: 'twin' } };
|
|
295
|
+
}
|
|
296
|
+
if (seg[0] !== '1' || seg[1] !== 'indexes') {
|
|
297
|
+
return algoliaError(404, `Route not found: ${method} ${path}`);
|
|
298
|
+
}
|
|
299
|
+
// ── GET /1/indexes — list ────────────────────────────────────────────────────────────────
|
|
300
|
+
if (seg.length === 2 && method === 'GET') {
|
|
301
|
+
const items = rowsOfType('index', req.root).map((r) => ({
|
|
302
|
+
name: r.name ?? '',
|
|
303
|
+
entries: recordsIn(r.name, req.root).length,
|
|
304
|
+
createdAt: r.createdAt ?? nowIso(req.occurredAt),
|
|
305
|
+
updatedAt: r.createdAt ?? nowIso(req.occurredAt),
|
|
306
|
+
}));
|
|
307
|
+
return { status: 200, body: { items, nbPages: 1 } };
|
|
308
|
+
}
|
|
309
|
+
const indexName = idAt(2);
|
|
310
|
+
// ── DELETE /1/indexes/:name — delete index + its records/settings/synonyms ─────────────────
|
|
311
|
+
if (seg.length === 3 && method === 'DELETE') {
|
|
312
|
+
if (req.readOnly)
|
|
313
|
+
return readOnlyRejection();
|
|
314
|
+
for (const r of recordsIn(indexName, req.root))
|
|
315
|
+
await writeDelete(indexName, r.objectID, req);
|
|
316
|
+
for (const s of synonymsIn(indexName, req.root))
|
|
317
|
+
await applyWrite('synonym', synonymKey(indexName, s.objectID), { _deleted: true }, 'synonym.delete', req);
|
|
318
|
+
await applyWrite('index', indexKey(indexName), { _deleted: true }, 'index.delete', req);
|
|
319
|
+
return { status: 200, body: { taskID: 0, deletedAt: nowIso(req.occurredAt) } };
|
|
320
|
+
}
|
|
321
|
+
// ── POST /1/indexes/:name — add object, auto objectID ───────────────────────────────────
|
|
322
|
+
if (seg.length === 3 && method === 'POST') {
|
|
323
|
+
if (req.readOnly)
|
|
324
|
+
return readOnlyRejection();
|
|
325
|
+
const body = parseObjectBody(req.body);
|
|
326
|
+
const occurredAt = nowIso(req.occurredAt);
|
|
327
|
+
const objectID = typeof body.objectID === 'string' && body.objectID.length > 0 ? body.objectID : mintObjectId(indexName, occurredAt, recordsIn(indexName, req.root).length);
|
|
328
|
+
await writeAddOrReplace(indexName, objectID, { ...body, objectID }, req);
|
|
329
|
+
// Algolia's write endpoints conventionally return 200, not 201 (doc-UNVERIFIED — not
|
|
330
|
+
// strictly REST-resource-creation-shaped; modeled on the well-known convention).
|
|
331
|
+
return { status: 200, body: { objectID, taskID: 0, createdAt: occurredAt } };
|
|
332
|
+
}
|
|
333
|
+
// ── /1/indexes/:name/clear|batch|query|settings|synonyms ────────────────────────────────
|
|
334
|
+
const sub = seg.length >= 4 ? seg[3] : undefined;
|
|
335
|
+
if (seg.length === 4 && sub === 'clear' && method === 'POST') {
|
|
336
|
+
if (req.readOnly)
|
|
337
|
+
return readOnlyRejection();
|
|
338
|
+
if (!getRowById('index', indexKey(indexName), req.root))
|
|
339
|
+
return algoliaError(404, `Index ${indexName} does not exist`);
|
|
340
|
+
for (const r of recordsIn(indexName, req.root))
|
|
341
|
+
await writeDelete(indexName, r.objectID, req);
|
|
342
|
+
return { status: 200, body: { taskID: 0, updatedAt: nowIso(req.occurredAt) } };
|
|
343
|
+
}
|
|
344
|
+
if (seg.length === 4 && sub === 'batch' && method === 'POST') {
|
|
345
|
+
if (req.readOnly)
|
|
346
|
+
return readOnlyRejection();
|
|
347
|
+
const body = parseObjectBody(req.body);
|
|
348
|
+
const requests = Array.isArray(body.requests) ? body.requests : [];
|
|
349
|
+
const occurredAt = nowIso(req.occurredAt);
|
|
350
|
+
const objectIDs = [];
|
|
351
|
+
for (let i = 0; i < requests.length; i++) {
|
|
352
|
+
const action = requests[i].action;
|
|
353
|
+
const recBody = requests[i].body ?? {};
|
|
354
|
+
if (action === 'addObject') {
|
|
355
|
+
const objectID = typeof recBody.objectID === 'string' && recBody.objectID.length > 0 ? recBody.objectID : mintObjectId(indexName, occurredAt, recordsIn(indexName, req.root).length + i);
|
|
356
|
+
await writeAddOrReplace(indexName, objectID, { ...recBody, objectID }, req);
|
|
357
|
+
objectIDs.push(objectID);
|
|
358
|
+
}
|
|
359
|
+
else if (action === 'updateObject') {
|
|
360
|
+
const objectID = typeof recBody.objectID === 'string' ? recBody.objectID : '';
|
|
361
|
+
if (!objectID)
|
|
362
|
+
return algoliaError(400, 'updateObject requires an objectID in the request body');
|
|
363
|
+
await writeAddOrReplace(indexName, objectID, recBody, req);
|
|
364
|
+
objectIDs.push(objectID);
|
|
365
|
+
}
|
|
366
|
+
else if (action === 'partialUpdateObject' || action === 'partialUpdateObjectNoCreate') {
|
|
367
|
+
const objectID = typeof recBody.objectID === 'string' ? recBody.objectID : '';
|
|
368
|
+
if (!objectID)
|
|
369
|
+
return algoliaError(400, `${action} requires an objectID in the request body`);
|
|
370
|
+
const outcome = await writePartialUpdate(indexName, objectID, recBody, req, action === 'partialUpdateObject');
|
|
371
|
+
if (outcome === 'not_found')
|
|
372
|
+
return algoliaError(404, `Object not found - Received createIfNotExist=false and object ${objectID} does not exist yet`);
|
|
373
|
+
objectIDs.push(objectID);
|
|
374
|
+
}
|
|
375
|
+
else if (action === 'deleteObject') {
|
|
376
|
+
const objectID = typeof recBody.objectID === 'string' ? recBody.objectID : '';
|
|
377
|
+
if (!objectID)
|
|
378
|
+
return algoliaError(400, 'deleteObject requires an objectID in the request body');
|
|
379
|
+
await writeDelete(indexName, objectID, req);
|
|
380
|
+
objectIDs.push(objectID);
|
|
381
|
+
}
|
|
382
|
+
else {
|
|
383
|
+
return algoliaError(400, `Invalid batch action: ${String(action)}`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return { status: 200, body: { objectIDs, taskID: 0 } };
|
|
387
|
+
}
|
|
388
|
+
if (seg.length === 4 && sub === 'query' && method === 'POST') {
|
|
389
|
+
return runSearch(indexName, parseObjectBody(req.body), req);
|
|
390
|
+
}
|
|
391
|
+
if (seg.length === 4 && sub === 'settings' && method === 'GET') {
|
|
392
|
+
return { status: 200, body: readSettings(indexName, req.root) };
|
|
393
|
+
}
|
|
394
|
+
if (seg.length === 4 && sub === 'settings' && method === 'PUT') {
|
|
395
|
+
if (req.readOnly)
|
|
396
|
+
return readOnlyRejection();
|
|
397
|
+
const patch = parseObjectBody(req.body);
|
|
398
|
+
const existing = readSettings(indexName, req.root);
|
|
399
|
+
await applyWrite('settings', settingsKey(indexName), { ...existing, ...patch }, 'settings.set', req);
|
|
400
|
+
return { status: 200, body: { taskID: 0, updatedAt: nowIso(req.occurredAt) } };
|
|
401
|
+
}
|
|
402
|
+
// ── synonyms ──────────────────────────────────────────────────────────────────────────────
|
|
403
|
+
if (sub === 'synonyms') {
|
|
404
|
+
if (seg.length === 5 && seg[4] === 'batch' && method === 'POST') {
|
|
405
|
+
if (req.readOnly)
|
|
406
|
+
return readOnlyRejection();
|
|
407
|
+
const arr = parseBody(req.body);
|
|
408
|
+
const synonyms = Array.isArray(arr) ? arr : [];
|
|
409
|
+
for (const s of synonyms) {
|
|
410
|
+
const objectID = typeof s.objectID === 'string' ? s.objectID : '';
|
|
411
|
+
if (!objectID)
|
|
412
|
+
return algoliaError(400, 'each synonym requires an objectID');
|
|
413
|
+
await ensureIndex(indexName, req);
|
|
414
|
+
await applyWrite('synonym', synonymKey(indexName, objectID), toStoredSynonymFields(s), 'synonym.save', req);
|
|
415
|
+
}
|
|
416
|
+
return { status: 200, body: { updatedAt: nowIso(req.occurredAt), taskID: 0 } };
|
|
417
|
+
}
|
|
418
|
+
if (seg.length === 5 && method === 'GET') {
|
|
419
|
+
const objectID = idAt(4);
|
|
420
|
+
const row = getRowById('synonym', synonymKey(indexName, objectID), req.root);
|
|
421
|
+
if (!row)
|
|
422
|
+
return algoliaError(404, `ObjectID does not exist`);
|
|
423
|
+
return { status: 200, body: { ...fromStoredSynonymFields(publicFields(row)), objectID } };
|
|
424
|
+
}
|
|
425
|
+
if (seg.length === 5 && method === 'DELETE') {
|
|
426
|
+
if (req.readOnly)
|
|
427
|
+
return readOnlyRejection();
|
|
428
|
+
const objectID = idAt(4);
|
|
429
|
+
await applyWrite('synonym', synonymKey(indexName, objectID), { _deleted: true }, 'synonym.delete', req);
|
|
430
|
+
return { status: 200, body: { deletedAt: nowIso(req.occurredAt), taskID: 0 } };
|
|
431
|
+
}
|
|
432
|
+
return algoliaError(404, `Route not found: ${method} ${path}`);
|
|
433
|
+
}
|
|
434
|
+
// ── record direct endpoints: GET/PUT/DELETE /1/indexes/:name/:objectID (+ /partial) ───────
|
|
435
|
+
if (seg.length === 4) {
|
|
436
|
+
const objectID = idAt(3);
|
|
437
|
+
if (method === 'GET') {
|
|
438
|
+
const row = getRowById('record', recordKey(indexName, objectID), req.root);
|
|
439
|
+
if (!row)
|
|
440
|
+
return algoliaError(404, `ObjectID does not exist`);
|
|
441
|
+
return { status: 200, body: recordView(row) };
|
|
442
|
+
}
|
|
443
|
+
if (method === 'PUT') {
|
|
444
|
+
if (req.readOnly)
|
|
445
|
+
return readOnlyRejection();
|
|
446
|
+
const body = parseObjectBody(req.body);
|
|
447
|
+
await writeAddOrReplace(indexName, objectID, { ...body, objectID }, req);
|
|
448
|
+
return { status: 200, body: { objectID, taskID: 0, updatedAt: nowIso(req.occurredAt) } };
|
|
449
|
+
}
|
|
450
|
+
if (method === 'DELETE') {
|
|
451
|
+
if (req.readOnly)
|
|
452
|
+
return readOnlyRejection();
|
|
453
|
+
// Delete is idempotent — deleting an already-absent objectID is still a 200, mirroring the
|
|
454
|
+
// real product (not a fabricated success: nothing false is claimed, the end state is
|
|
455
|
+
// genuinely "absent" either way).
|
|
456
|
+
await writeDelete(indexName, objectID, req);
|
|
457
|
+
return { status: 200, body: { deletedAt: nowIso(req.occurredAt), taskID: 0 } };
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
if (seg.length === 5 && seg[4] === 'partial' && method === 'POST') {
|
|
461
|
+
if (req.readOnly)
|
|
462
|
+
return readOnlyRejection();
|
|
463
|
+
const objectID = idAt(3);
|
|
464
|
+
const body = parseObjectBody(req.body);
|
|
465
|
+
// Direct endpoint default: createIfNotExists defaults to TRUE when the query param is
|
|
466
|
+
// omitted (the well-known, widely-published REST-endpoint default — doc-UNVERIFIED not
|
|
467
|
+
// live-fetched, distinct from the JS SDK v4 client's own more-conservative default of
|
|
468
|
+
// `false`/NoCreate when its OWN option is omitted, confirmed live via probe — see file
|
|
469
|
+
// header and ⚠4/spec-sources.json).
|
|
470
|
+
const createIfNotExists = query.get('createIfNotExists') === 'false' ? false : true;
|
|
471
|
+
const outcome = await writePartialUpdate(indexName, objectID, body, req, createIfNotExists);
|
|
472
|
+
if (outcome === 'not_found')
|
|
473
|
+
return algoliaError(404, `Object not found - Received createIfNotExist=false and object ${objectID} does not exist yet`);
|
|
474
|
+
return { status: 200, body: { objectID, taskID: 0, updatedAt: nowIso(req.occurredAt) } };
|
|
475
|
+
}
|
|
476
|
+
return algoliaError(404, `Route not found: ${method} ${path}`);
|
|
477
|
+
}
|
|
478
|
+
function runSearch(indexName, body, req) {
|
|
479
|
+
const indexRow = getRowById('index', indexKey(indexName), req.root);
|
|
480
|
+
if (!indexRow)
|
|
481
|
+
return algoliaError(404, `Index ${indexName} does not exist`);
|
|
482
|
+
const settings = readSettings(indexName, req.root);
|
|
483
|
+
const query = typeof body.query === 'string' ? body.query : '';
|
|
484
|
+
const page = typeof body.page === 'number' ? body.page : 0;
|
|
485
|
+
const hitsPerPage = typeof body.hitsPerPage === 'number' ? body.hitsPerPage : 20;
|
|
486
|
+
const filters = typeof body.filters === 'string' ? body.filters : undefined;
|
|
487
|
+
const facetFilters = body.facetFilters ?? undefined;
|
|
488
|
+
const attributesToRetrieve = Array.isArray(body.attributesToRetrieve) ? body.attributesToRetrieve : undefined;
|
|
489
|
+
const requestedFacets = Array.isArray(body.facets) ? body.facets : undefined;
|
|
490
|
+
const allRecords = recordsIn(indexName, req.root);
|
|
491
|
+
const synonymGroups = buildSynonymGroups(synonymsIn(indexName, req.root));
|
|
492
|
+
const preFiltered = allRecords.filter((r) => matchesAlgoliaFilters(r, filters) && matchesFacetFilters(r, facetFilters));
|
|
493
|
+
const customRanking = parseCustomRanking(settings.customRanking);
|
|
494
|
+
const ranked = rankRecords(preFiltered, query, {
|
|
495
|
+
searchableAttributes: settings.searchableAttributes,
|
|
496
|
+
customRanking,
|
|
497
|
+
synonymGroups,
|
|
498
|
+
});
|
|
499
|
+
const nbHits = ranked.length;
|
|
500
|
+
const hitsPerPageEff = Math.max(1, hitsPerPage);
|
|
501
|
+
const nbPages = Math.max(1, Math.ceil(nbHits / hitsPerPageEff));
|
|
502
|
+
const pageHits = ranked.slice(page * hitsPerPageEff, page * hitsPerPageEff + hitsPerPageEff);
|
|
503
|
+
const project = (record) => {
|
|
504
|
+
if (!attributesToRetrieve || attributesToRetrieve.includes('*'))
|
|
505
|
+
return { ...record, objectID: record.objectID };
|
|
506
|
+
const out = { objectID: record.objectID };
|
|
507
|
+
for (const attr of attributesToRetrieve)
|
|
508
|
+
if (attr in record)
|
|
509
|
+
out[attr] = record[attr];
|
|
510
|
+
return out;
|
|
511
|
+
};
|
|
512
|
+
const facetAttrs = settings.attributesForFaceting.filter((a) => !requestedFacets || requestedFacets.includes('*') || requestedFacets.includes(a));
|
|
513
|
+
const responseBody = {
|
|
514
|
+
hits: pageHits.map((h) => project(h.record)),
|
|
515
|
+
nbHits,
|
|
516
|
+
page,
|
|
517
|
+
nbPages,
|
|
518
|
+
hitsPerPage: hitsPerPageEff,
|
|
519
|
+
processingTimeMS: 0,
|
|
520
|
+
query,
|
|
521
|
+
params: '',
|
|
522
|
+
};
|
|
523
|
+
if (requestedFacets && requestedFacets.length > 0) {
|
|
524
|
+
responseBody.facets = computeFacetCounts(preFiltered, facetAttrs);
|
|
525
|
+
}
|
|
526
|
+
return { status: 200, body: responseBody };
|
|
527
|
+
}
|
|
528
|
+
export function algoliaTwinSnapshot() {
|
|
529
|
+
return {
|
|
530
|
+
resourceTypes: ALGOLIA_RESOURCE_TYPES,
|
|
531
|
+
implementedEndpoints: [
|
|
532
|
+
'GET /1/indexes', 'DELETE /1/indexes/:name', 'POST /1/indexes/:name', 'POST /1/indexes/:name/clear',
|
|
533
|
+
'POST /1/indexes/:name/batch', 'POST /1/indexes/:name/query',
|
|
534
|
+
'GET /1/indexes/:name/settings', 'PUT /1/indexes/:name/settings',
|
|
535
|
+
'POST /1/indexes/:name/synonyms/batch', 'GET /1/indexes/:name/synonyms/:objectID', 'DELETE /1/indexes/:name/synonyms/:objectID',
|
|
536
|
+
'GET /1/indexes/:name/:objectID', 'PUT /1/indexes/:name/:objectID', 'DELETE /1/indexes/:name/:objectID',
|
|
537
|
+
'POST /1/indexes/:name/:objectID/partial',
|
|
538
|
+
],
|
|
539
|
+
};
|
|
540
|
+
}
|
package/dist/src/cli.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
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.js";
|
|
8
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
9
|
+
const port = Number(optionValue(rest, '--port', String(process.env.PORT ?? '0'))) || undefined;
|
|
10
|
+
const root = optionValue(rest, '--root') || undefined;
|
|
11
|
+
const readOnly = hasFlag(rest, '--read-only'); // a twin accepts writes unless started read-only
|
|
12
|
+
if (cmd === 'serve' || cmd === undefined) {
|
|
13
|
+
const s = await createAlgoliaTwinServer({ readOnly, ...(root ? { root } : {}), ...(port ? { port } : {}) });
|
|
14
|
+
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`);
|
|
15
|
+
await keepProcessAlive();
|
|
16
|
+
}
|
|
17
|
+
else if (cmd === 'conformance') {
|
|
18
|
+
const { checkAlgoliaConformance } = await import("./algolia-conformance.js");
|
|
19
|
+
const report = checkAlgoliaConformance();
|
|
20
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
21
|
+
if (!report.ok)
|
|
22
|
+
process.exitCode = 1;
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
process.stdout.write('Usage: world-algolia serve|conformance [--port N] [--root DIR] [--read-only]\n');
|
|
26
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { handleAlgoliaTwinRequest, routeAlgoliaSurface, mintHost, recoverAppIdFromHost, algoliaTwinSnapshot, ALGOLIA_RESOURCE_TYPES, } from './algolia-twin.js';
|
|
2
|
+
export type { AlgoliaRequest, AlgoliaResponse, AlgoliaSurface, AlgoliaResourceType, AlgoliaTwinSnapshot, } from './algolia-twin.js';
|
|
3
|
+
export { createAlgoliaTwinFetch, createAlgoliaTwinServer, type AlgoliaTwinFetchOptions } from './algolia-server.js';
|
|
4
|
+
export { mapIndex, mapRecord, pullAlgoliaIndexes, pullAlgoliaRecords, syncAlgoliaFromReal, } from './algolia-connector.js';
|
|
5
|
+
export type { AlgoliaLikeClient, AlgoliaRealIndex, AlgoliaRealRecord, AlgoliaBudgetedOptions } from './algolia-connector.js';
|
|
6
|
+
export { ALGOLIA_BUDGETED_METHODS, ALGOLIA_BUDGET_CEILING, ALGOLIA_BUDGET_MAX_RETRY_AFTER_S, ALGOLIA_BUDGET_WINDOW_MS, ALGOLIA_CALL_WEIGHTS, ALGOLIA_RATE_BUDGET, AlgoliaBudget, AlgoliaBudgetError, algoliaBudgetPath, algoliaCallWeight, algoliaClientBudget, guardAlgoliaClient, } from './algolia-budget.js';
|
|
7
|
+
export type { AlgoliaBudgetErrorKind, AlgoliaBudgetOptions, AlgoliaBudgetReservation, AlgoliaBudgetSnapshot } from './algolia-budget.js';
|
|
8
|
+
export { matchesAlgoliaFilters, matchesFacetFilters, computeFacetCounts } from './algolia-filter.js';
|
|
9
|
+
export type { FacetFilters, FacetFilterClause } from './algolia-filter.js';
|
|
10
|
+
export { tokenize, damerauLevenshtein, rankRecords, buildSynonymGroups } from './algolia-search.js';
|
|
11
|
+
export type { SearchRecord, RankedHit, CustomRankingRule } from './algolia-search.js';
|
|
12
|
+
import type { TwinPack } from '@volter/world-core';
|
|
13
|
+
export declare const pack: TwinPack;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// @volter/twin-algolia — the Algolia (algolia.com) hosted-search API twin, built on the shared
|
|
2
|
+
// @volter/world-core kernel. Single-plane REST transport: every route lives under
|
|
3
|
+
// `/1/indexes/{indexName}/...` (index list/delete/clear, record CRUD, REAL search — tokenize +
|
|
4
|
+
// Damerau-Levenshtein typo tolerance + a faithful-subset ranking + REAL filter/facetFilters
|
|
5
|
+
// evaluation + exact facet-count aggregation + customRanking tie-breaks + basic bidirectional
|
|
6
|
+
// synonym expansion, settings get/set). Host-TOLERANT (not host-SPLIT, unlike pinecone): every
|
|
7
|
+
// real Algolia host shape ({appId}.algolia.net / {appId}-dsn.algolia.net /
|
|
8
|
+
// {appId}-N.algolianet.com) routes to the SAME single plane, since Algolia's REST paths already
|
|
9
|
+
// carry the index name — no host-based dispatch is needed (see algolia-twin.ts's header). State
|
|
10
|
+
// lives entirely in the kernel action log (no side-store). API-first vendor: no mirror, no UI
|
|
11
|
+
// capabilities (ui-scope.json).
|
|
12
|
+
//
|
|
13
|
+
// THE SEARCH ENGINE (README ## Coverage has the full reasoning): this twin's search is a
|
|
14
|
+
// faithful, deterministic SUBSET of the documented tie-break criteria, not a lesser stub;
|
|
15
|
+
// widening it is `algolia.search.ranking_criteria_coverage` (todo). Writes apply synchronously
|
|
16
|
+
// to one kernel root. Tokenization is deterministic ASCII/Unicode word-splitting today, with
|
|
17
|
+
// stemming/plurals/CJK segmentation filed as `algolia.search.language_processing`. (Conformance/capability tooling lives in
|
|
18
|
+
// @volter/world-tooling, a dev dependency — NOT shipped.)
|
|
19
|
+
export { handleAlgoliaTwinRequest, routeAlgoliaSurface, mintHost, recoverAppIdFromHost, algoliaTwinSnapshot, ALGOLIA_RESOURCE_TYPES, } from "./algolia-twin.js";
|
|
20
|
+
export { createAlgoliaTwinFetch, createAlgoliaTwinServer } from "./algolia-server.js";
|
|
21
|
+
export { mapIndex, mapRecord, pullAlgoliaIndexes, pullAlgoliaRecords, syncAlgoliaFromReal, } from "./algolia-connector.js";
|
|
22
|
+
// The client-side rate budget — the fail-closed backstop every live Algolia call goes through. The
|
|
23
|
+
// MECHANISM is the kernel's shared, vendor-agnostic `RateBudget`; what lives here is Algolia's
|
|
24
|
+
// DECLARATION (window/ceiling/per-method weights) plus `guardAlgoliaClient`, the choke point the
|
|
25
|
+
// connector entrypoints apply unconditionally. Exported so an operator can inspect spend
|
|
26
|
+
// (`snapshot`) and a caller can catch `AlgoliaBudgetError` by type; there is deliberately no export
|
|
27
|
+
// that disables the guard.
|
|
28
|
+
export { ALGOLIA_BUDGETED_METHODS, ALGOLIA_BUDGET_CEILING, ALGOLIA_BUDGET_MAX_RETRY_AFTER_S, ALGOLIA_BUDGET_WINDOW_MS, ALGOLIA_CALL_WEIGHTS, ALGOLIA_RATE_BUDGET, AlgoliaBudget, AlgoliaBudgetError, algoliaBudgetPath, algoliaCallWeight, algoliaClientBudget, guardAlgoliaClient, } from "./algolia-budget.js";
|
|
29
|
+
export { matchesAlgoliaFilters, matchesFacetFilters, computeFacetCounts } from "./algolia-filter.js";
|
|
30
|
+
export { tokenize, damerauLevenshtein, rankRecords, buildSynonymGroups } from "./algolia-search.js";
|
|
31
|
+
import { ALGOLIA_RATE_BUDGET as RATE_BUDGET } from "./algolia-budget.js";
|
|
32
|
+
export const pack = {
|
|
33
|
+
vendor: 'algolia',
|
|
34
|
+
// The SAME object algolia-budget.ts declares at module load — one source of truth, so registering
|
|
35
|
+
// the pack and importing the connector can never arm two different ceilings.
|
|
36
|
+
rateBudget: RATE_BUDGET,
|
|
37
|
+
transport: 'rest',
|
|
38
|
+
archetype: 'crud',
|
|
39
|
+
bin: 'world-algolia',
|
|
40
|
+
resources: ['index', 'record', 'synonym', 'settings'],
|
|
41
|
+
specSource: 'algolia-conformance.ts (endpoint/resource inventory grounded LIVE against the installed algoliasearch SDK, driven end-to-end with a throwaway local server before this handler was written — see spec-sources.json)',
|
|
42
|
+
description: 'Algolia hosted-search twin — index list/delete/clear, record CRUD (add/replace/partial/delete/batch), REAL tokenize+typo+ranking search engine with REAL filter/facetFilters/facet-count evaluation, customRanking, basic synonym expansion, settings get/set, host-tolerant router. Kernel-backed. No mirror (API-first vendor).',
|
|
43
|
+
// Adoption, all in the pack's one home (descriptor-first back-migration, adding-a-twin.md §3, 2026-08-31; the bare
|
|
44
|
+
// `algolia` stem and the `algoliasearch` SDK name moved off the central maps unchanged). Stems:
|
|
45
|
+
// the bare ALGOLIA_* shape and the ALGOLIA_WRITE_* write-key half real apps split out.
|
|
46
|
+
adoption: {
|
|
47
|
+
// Algolia's own Python client - same distribution name as the npm one, different registry.
|
|
48
|
+
pypi: ['algoliasearch'],
|
|
49
|
+
sdks: ['algoliasearch'], envStems: ['ALGOLIA', 'ALGOLIAWRITE'],
|
|
50
|
+
},
|
|
51
|
+
// No `browserRouting`: unlike every other pack (which has ONE fixed absolute API host to strip
|
|
52
|
+
// for the zero-edit dev proxy — even supabase's per-PROJECT data API omits it in favor of its
|
|
53
|
+
// static management-API host), Algolia has NO fixed host at all — every real host is
|
|
54
|
+
// `{appId}.algolia.net`-shaped, with the caller's OWN appId baked directly into the domain. A
|
|
55
|
+
// literal `{appId}` placeholder is not a stripeable absolute host, so `browserRouting` is
|
|
56
|
+
// correctly omitted here rather than populated with a non-matching placeholder.
|
|
57
|
+
// INTERCEPTION RULING — hostsNone, the pack's own home for it: per-app-ID host scheme
|
|
58
|
+
// ({appId}.algolia.net / {appId}-dsn.algolia.net / {appId}-N.algolianet.com, see the pack
|
|
59
|
+
// README §Host tolerance) — no fixed host list to match; the host-tolerant twin is wired by
|
|
60
|
+
// explicit client host config.
|
|
61
|
+
hostsNone: "per-app-ID host scheme ({appId}.algolia.net / {appId}-dsn.algolia.net / {appId}-N.algolianet.com, see the pack README §Host tolerance) — no fixed host list to match; the host-tolerant twin is wired by explicit client host config",
|
|
62
|
+
};
|