@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,125 @@
|
|
|
1
|
+
function parseFacetToken(token) {
|
|
2
|
+
const idx = token.indexOf(':');
|
|
3
|
+
if (idx < 0)
|
|
4
|
+
return undefined;
|
|
5
|
+
const field = token.slice(0, idx).trim();
|
|
6
|
+
let value = token.slice(idx + 1).trim();
|
|
7
|
+
let negate = false;
|
|
8
|
+
if (value.startsWith('-')) {
|
|
9
|
+
negate = true;
|
|
10
|
+
value = value.slice(1);
|
|
11
|
+
}
|
|
12
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
13
|
+
value = value.slice(1, -1);
|
|
14
|
+
}
|
|
15
|
+
return { field, value, negate };
|
|
16
|
+
}
|
|
17
|
+
function facetValueMatches(recordValue, wanted) {
|
|
18
|
+
if (Array.isArray(recordValue))
|
|
19
|
+
return recordValue.some((v) => String(v) === wanted);
|
|
20
|
+
if (recordValue === undefined || recordValue === null)
|
|
21
|
+
return false;
|
|
22
|
+
return String(recordValue) === wanted;
|
|
23
|
+
}
|
|
24
|
+
/** Evaluate a single `facetFilters` clause (a bare "field:value" string, or an OR-array of them)
|
|
25
|
+
* against a record. An absent/empty facetFilters matches everything. */
|
|
26
|
+
export function matchesFacetFilters(record, facetFilters) {
|
|
27
|
+
if (!facetFilters || facetFilters.length === 0)
|
|
28
|
+
return true;
|
|
29
|
+
for (const clause of facetFilters) {
|
|
30
|
+
const items = Array.isArray(clause) ? clause : [clause];
|
|
31
|
+
// outer array element: AND with the rest; an array-of-strings ELEMENT is itself an OR group.
|
|
32
|
+
const orMatch = items.some((token) => {
|
|
33
|
+
const parsed = parseFacetToken(token);
|
|
34
|
+
if (!parsed)
|
|
35
|
+
return false;
|
|
36
|
+
const matched = facetValueMatches(record[parsed.field], parsed.value);
|
|
37
|
+
return parsed.negate ? !matched : matched;
|
|
38
|
+
});
|
|
39
|
+
if (!orMatch)
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
const COMPARE_RE = /^(\S+)\s*(>=|<=|!=|>|<|=)\s*(-?\d+(?:\.\d+)?)$/;
|
|
45
|
+
function parseClause(raw) {
|
|
46
|
+
const trimmed = raw.trim();
|
|
47
|
+
if (trimmed.length === 0)
|
|
48
|
+
return undefined;
|
|
49
|
+
let text = trimmed;
|
|
50
|
+
let negate = false;
|
|
51
|
+
if (text.startsWith('NOT ')) {
|
|
52
|
+
negate = true;
|
|
53
|
+
text = text.slice(4).trim();
|
|
54
|
+
}
|
|
55
|
+
const cmp = COMPARE_RE.exec(text);
|
|
56
|
+
if (cmp) {
|
|
57
|
+
const [, field, op, num] = cmp;
|
|
58
|
+
return { kind: 'compare', c: { field: field, op: (negate ? negateOp(op) : op), value: Number(num) } };
|
|
59
|
+
}
|
|
60
|
+
const facet = parseFacetToken(text);
|
|
61
|
+
if (facet)
|
|
62
|
+
return { kind: 'facet', c: { field: facet.field, value: facet.value, negate: negate !== facet.negate } };
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
function negateOp(op) {
|
|
66
|
+
const table = { '>': '<=', '>=': '<', '<': '>=', '<=': '>', '=': '!=', '!=': '=' };
|
|
67
|
+
return table[op];
|
|
68
|
+
}
|
|
69
|
+
function evalClause(record, clause) {
|
|
70
|
+
if (clause.kind === 'facet') {
|
|
71
|
+
const matched = facetValueMatches(record[clause.c.field], clause.c.value);
|
|
72
|
+
return clause.c.negate ? !matched : matched;
|
|
73
|
+
}
|
|
74
|
+
const raw = record[clause.c.field];
|
|
75
|
+
if (typeof raw !== 'number')
|
|
76
|
+
return false;
|
|
77
|
+
switch (clause.c.op) {
|
|
78
|
+
case '>':
|
|
79
|
+
return raw > clause.c.value;
|
|
80
|
+
case '>=':
|
|
81
|
+
return raw >= clause.c.value;
|
|
82
|
+
case '<':
|
|
83
|
+
return raw < clause.c.value;
|
|
84
|
+
case '<=':
|
|
85
|
+
return raw <= clause.c.value;
|
|
86
|
+
case '=':
|
|
87
|
+
return raw === clause.c.value;
|
|
88
|
+
case '!=':
|
|
89
|
+
return raw !== clause.c.value;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/** Evaluate an Algolia `filters` STRING against a stored record: AND binds tighter than OR,
|
|
93
|
+
* evaluated as OR-of-AND-groups (no parenthesized precedence override — v1 honest cut). An
|
|
94
|
+
* absent/empty/whitespace-only filter string matches everything. */
|
|
95
|
+
export function matchesAlgoliaFilters(record, filters) {
|
|
96
|
+
if (!filters || filters.trim().length === 0)
|
|
97
|
+
return true;
|
|
98
|
+
const orGroups = filters.split(/\s+OR\s+/);
|
|
99
|
+
return orGroups.some((group) => group
|
|
100
|
+
.split(/\s+AND\s+/)
|
|
101
|
+
.map((raw) => parseClause(raw))
|
|
102
|
+
.every((clause) => (clause ? evalClause(record, clause) : true)));
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* REAL facet-count aggregation over an already-matched-and-filtered record set (conjunctive —
|
|
106
|
+
* counts reflect the final hit set, not a per-facet disjunctive relaxation — disjunctive facet
|
|
107
|
+
* counting is `facets.disjunctive_facets`, todo). Multi-value (array) facet attributes count
|
|
108
|
+
* each element once per record — a genuine tally, not a name check.
|
|
109
|
+
*/
|
|
110
|
+
export function computeFacetCounts(records, facetAttributes) {
|
|
111
|
+
const out = {};
|
|
112
|
+
for (const attr of facetAttributes) {
|
|
113
|
+
const counts = {};
|
|
114
|
+
for (const r of records) {
|
|
115
|
+
const v = r[attr];
|
|
116
|
+
const values = Array.isArray(v) ? v : v === undefined || v === null ? [] : [v];
|
|
117
|
+
for (const one of values) {
|
|
118
|
+
const key = String(one);
|
|
119
|
+
counts[key] = (counts[key] ?? 0) + 1;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
out[attr] = counts;
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export type CustomRankingRule = {
|
|
2
|
+
attribute: string;
|
|
3
|
+
direction: 'asc' | 'desc';
|
|
4
|
+
};
|
|
5
|
+
export type SearchRecord = Record<string, unknown> & {
|
|
6
|
+
objectID: string;
|
|
7
|
+
};
|
|
8
|
+
/** Deterministic ASCII/Unicode tokenizer: lowercase, split on runs of non letter/number
|
|
9
|
+
* characters. This is the line `algolia.search.language_processing` (todo) will move: no
|
|
10
|
+
* stemming/plural-folding/CJK segmentation/language normalization yet — just a real,
|
|
11
|
+
* deterministic word-boundary split. */
|
|
12
|
+
export declare function tokenize(text: string): string[];
|
|
13
|
+
/** Real Damerau-Levenshtein edit distance (insertions/deletions/substitutions/adjacent
|
|
14
|
+
* transpositions) — a genuine DP table, not an approximation. */
|
|
15
|
+
export declare function damerauLevenshtein(a: string, b: string): number;
|
|
16
|
+
/** All values a group of interchangeable synonym words maps to each other, lowercase. A word's
|
|
17
|
+
* group includes itself. Basic bidirectional `type:"synonym"` groups only — one-way/altCorrection/
|
|
18
|
+
* placeholder synonyms are `search.optional_words`-adjacent todos (synonyms.one_way etc). */
|
|
19
|
+
export declare function buildSynonymGroups(synonymRecords: ReadonlyArray<{
|
|
20
|
+
type?: string;
|
|
21
|
+
synonyms?: readonly string[];
|
|
22
|
+
}>): string[][];
|
|
23
|
+
export type RankedHit<T extends SearchRecord = SearchRecord> = {
|
|
24
|
+
record: T;
|
|
25
|
+
typoTotal: number;
|
|
26
|
+
attributeIndex: number;
|
|
27
|
+
proximity: number;
|
|
28
|
+
exactCount: number;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Match + rank records against a query — the pack's signature strong-`done`. Returns every
|
|
32
|
+
* MATCHING record, best-first, per the faithful-subset tie-break chain: typo -> attribute
|
|
33
|
+
* (earlier configured attribute wins) -> proximity -> exact -> customRanking -> objectID
|
|
34
|
+
* (final deterministic tie-break). An empty/whitespace query matches every record (real
|
|
35
|
+
* Algolia's browse-all-via-empty-query convention) with every criterion tied, so ordering then
|
|
36
|
+
* falls straight to customRanking / objectID.
|
|
37
|
+
*/
|
|
38
|
+
export declare function rankRecords<T extends SearchRecord>(records: readonly T[], query: string, opts?: {
|
|
39
|
+
searchableAttributes?: readonly string[];
|
|
40
|
+
customRanking?: readonly CustomRankingRule[];
|
|
41
|
+
synonymGroups?: readonly string[][];
|
|
42
|
+
}): RankedHit<T>[];
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/** Deterministic ASCII/Unicode tokenizer: lowercase, split on runs of non letter/number
|
|
2
|
+
* characters. This is the line `algolia.search.language_processing` (todo) will move: no
|
|
3
|
+
* stemming/plural-folding/CJK segmentation/language normalization yet — just a real,
|
|
4
|
+
* deterministic word-boundary split. */
|
|
5
|
+
export function tokenize(text) {
|
|
6
|
+
return text.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? [];
|
|
7
|
+
}
|
|
8
|
+
/** Real Damerau-Levenshtein edit distance (insertions/deletions/substitutions/adjacent
|
|
9
|
+
* transpositions) — a genuine DP table, not an approximation. */
|
|
10
|
+
export function damerauLevenshtein(a, b) {
|
|
11
|
+
const al = a.length;
|
|
12
|
+
const bl = b.length;
|
|
13
|
+
if (al === 0)
|
|
14
|
+
return bl;
|
|
15
|
+
if (bl === 0)
|
|
16
|
+
return al;
|
|
17
|
+
const d = Array.from({ length: al + 1 }, () => new Array(bl + 1).fill(0));
|
|
18
|
+
for (let i = 0; i <= al; i++)
|
|
19
|
+
d[i][0] = i;
|
|
20
|
+
for (let j = 0; j <= bl; j++)
|
|
21
|
+
d[0][j] = j;
|
|
22
|
+
for (let i = 1; i <= al; i++) {
|
|
23
|
+
for (let j = 1; j <= bl; j++) {
|
|
24
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
25
|
+
let v = Math.min(d[i - 1][j] + 1, // deletion
|
|
26
|
+
d[i][j - 1] + 1, // insertion
|
|
27
|
+
d[i - 1][j - 1] + cost);
|
|
28
|
+
if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
|
|
29
|
+
v = Math.min(v, d[i - 2][j - 2] + cost); // adjacent transposition
|
|
30
|
+
}
|
|
31
|
+
d[i][j] = v;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return d[al][bl];
|
|
35
|
+
}
|
|
36
|
+
const MAX_TYPOS = 2;
|
|
37
|
+
/** All values a group of interchangeable synonym words maps to each other, lowercase. A word's
|
|
38
|
+
* group includes itself. Basic bidirectional `type:"synonym"` groups only — one-way/altCorrection/
|
|
39
|
+
* placeholder synonyms are `search.optional_words`-adjacent todos (synonyms.one_way etc). */
|
|
40
|
+
export function buildSynonymGroups(synonymRecords) {
|
|
41
|
+
const groups = [];
|
|
42
|
+
for (const s of synonymRecords) {
|
|
43
|
+
if (s.type !== 'synonym' || !Array.isArray(s.synonyms) || s.synonyms.length < 2)
|
|
44
|
+
continue;
|
|
45
|
+
groups.push(s.synonyms.map((w) => w.toLowerCase()));
|
|
46
|
+
}
|
|
47
|
+
return groups;
|
|
48
|
+
}
|
|
49
|
+
function sameSynonymGroup(a, b, groups) {
|
|
50
|
+
for (const g of groups)
|
|
51
|
+
if (g.includes(a) && g.includes(b))
|
|
52
|
+
return true;
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
/** Distance between a query word and a candidate token: 0 for exact or same-synonym-group,
|
|
56
|
+
* else the Damerau-Levenshtein edit distance capped at MAX_TYPOS, else `undefined` (no match
|
|
57
|
+
* at all — the record does not satisfy this query word via this token). */
|
|
58
|
+
function wordDistance(queryWord, token, synonymGroups) {
|
|
59
|
+
if (queryWord === token)
|
|
60
|
+
return 0;
|
|
61
|
+
if (sameSynonymGroup(queryWord, token, synonymGroups))
|
|
62
|
+
return 0;
|
|
63
|
+
const d = damerauLevenshtein(queryWord, token);
|
|
64
|
+
return d <= MAX_TYPOS ? d : undefined;
|
|
65
|
+
}
|
|
66
|
+
/** Flatten one attribute's stored value into a token list (string, array-of-strings/values, or
|
|
67
|
+
* a scalar stringified) — non-string/array/number/boolean values (objects, null, undefined)
|
|
68
|
+
* contribute no tokens. */
|
|
69
|
+
function tokensForAttributeValue(value) {
|
|
70
|
+
if (typeof value === 'string')
|
|
71
|
+
return tokenize(value);
|
|
72
|
+
if (typeof value === 'number' || typeof value === 'boolean')
|
|
73
|
+
return tokenize(String(value));
|
|
74
|
+
if (Array.isArray(value))
|
|
75
|
+
return value.flatMap((v) => tokensForAttributeValue(v));
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
78
|
+
/** Try to match every query word within ONE attribute's token list; undefined if any query word
|
|
79
|
+
* finds no token in this attribute within MAX_TYPOS. */
|
|
80
|
+
function matchWithinAttribute(tokens, queryWords, synonymGroups, attributeIndex) {
|
|
81
|
+
if (tokens.length === 0)
|
|
82
|
+
return undefined;
|
|
83
|
+
const positions = [];
|
|
84
|
+
let typoTotal = 0;
|
|
85
|
+
let exactCount = 0;
|
|
86
|
+
for (const qw of queryWords) {
|
|
87
|
+
let best;
|
|
88
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
89
|
+
const d = wordDistance(qw, tokens[i], synonymGroups);
|
|
90
|
+
if (d !== undefined && (!best || d < best.typos))
|
|
91
|
+
best = { idx: i, typos: d };
|
|
92
|
+
}
|
|
93
|
+
if (!best)
|
|
94
|
+
return undefined;
|
|
95
|
+
positions.push(best.idx);
|
|
96
|
+
typoTotal += best.typos;
|
|
97
|
+
if (best.typos === 0)
|
|
98
|
+
exactCount++;
|
|
99
|
+
}
|
|
100
|
+
const proximity = positions.length > 0 ? Math.max(...positions) - Math.min(...positions) : 0;
|
|
101
|
+
return { attributeIndex, typoTotal, exactCount, proximity };
|
|
102
|
+
}
|
|
103
|
+
/** Default searchable-attribute order when settings.searchableAttributes is unset: every
|
|
104
|
+
* top-level field except objectID, in a deterministic sorted order. Real Algolia treats every
|
|
105
|
+
* attribute as equally searchable when unconfigured (no attribute-priority distinction) — this
|
|
106
|
+
* twin's sorted-key stand-in only matters for tie-breaking when the caller HAS configured an
|
|
107
|
+
* explicit order (search.attributes_to_retrieve / settings.searchable_attributes_affects_search). */
|
|
108
|
+
function defaultSearchableAttributes(records) {
|
|
109
|
+
const keys = new Set();
|
|
110
|
+
for (const r of records)
|
|
111
|
+
for (const k of Object.keys(r))
|
|
112
|
+
if (k !== 'objectID')
|
|
113
|
+
keys.add(k);
|
|
114
|
+
return [...keys].sort();
|
|
115
|
+
}
|
|
116
|
+
function customRankingCompare(a, b, rules) {
|
|
117
|
+
for (const rule of rules) {
|
|
118
|
+
const av = a[rule.attribute];
|
|
119
|
+
const bv = b[rule.attribute];
|
|
120
|
+
const an = typeof av === 'number' ? av : Number.NEGATIVE_INFINITY;
|
|
121
|
+
const bn = typeof bv === 'number' ? bv : Number.NEGATIVE_INFINITY;
|
|
122
|
+
if (an === bn)
|
|
123
|
+
continue;
|
|
124
|
+
return rule.direction === 'desc' ? bn - an : an - bn;
|
|
125
|
+
}
|
|
126
|
+
return 0;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Match + rank records against a query — the pack's signature strong-`done`. Returns every
|
|
130
|
+
* MATCHING record, best-first, per the faithful-subset tie-break chain: typo -> attribute
|
|
131
|
+
* (earlier configured attribute wins) -> proximity -> exact -> customRanking -> objectID
|
|
132
|
+
* (final deterministic tie-break). An empty/whitespace query matches every record (real
|
|
133
|
+
* Algolia's browse-all-via-empty-query convention) with every criterion tied, so ordering then
|
|
134
|
+
* falls straight to customRanking / objectID.
|
|
135
|
+
*/
|
|
136
|
+
export function rankRecords(records, query, opts = {}) {
|
|
137
|
+
const attrs = opts.searchableAttributes && opts.searchableAttributes.length > 0 ? opts.searchableAttributes : defaultSearchableAttributes(records);
|
|
138
|
+
const customRanking = opts.customRanking ?? [];
|
|
139
|
+
const synonymGroups = opts.synonymGroups ?? [];
|
|
140
|
+
const queryWords = tokenize(query);
|
|
141
|
+
const hits = [];
|
|
142
|
+
for (const record of records) {
|
|
143
|
+
if (queryWords.length === 0) {
|
|
144
|
+
hits.push({ record, typoTotal: 0, attributeIndex: 0, proximity: 0, exactCount: 0 });
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
let best;
|
|
148
|
+
for (let i = 0; i < attrs.length; i++) {
|
|
149
|
+
const tokens = tokensForAttributeValue(record[attrs[i]]);
|
|
150
|
+
const m = matchWithinAttribute(tokens, queryWords, synonymGroups, i);
|
|
151
|
+
if (!m)
|
|
152
|
+
continue;
|
|
153
|
+
if (!best || m.typoTotal < best.typoTotal || (m.typoTotal === best.typoTotal && m.attributeIndex < best.attributeIndex))
|
|
154
|
+
best = m;
|
|
155
|
+
}
|
|
156
|
+
if (best)
|
|
157
|
+
hits.push({ record, typoTotal: best.typoTotal, attributeIndex: best.attributeIndex, proximity: best.proximity, exactCount: best.exactCount });
|
|
158
|
+
}
|
|
159
|
+
hits.sort((a, b) => {
|
|
160
|
+
if (a.typoTotal !== b.typoTotal)
|
|
161
|
+
return a.typoTotal - b.typoTotal;
|
|
162
|
+
if (a.attributeIndex !== b.attributeIndex)
|
|
163
|
+
return a.attributeIndex - b.attributeIndex;
|
|
164
|
+
if (a.proximity !== b.proximity)
|
|
165
|
+
return a.proximity - b.proximity;
|
|
166
|
+
if (a.exactCount !== b.exactCount)
|
|
167
|
+
return b.exactCount - a.exactCount;
|
|
168
|
+
const custom = customRankingCompare(a.record, b.record, customRanking);
|
|
169
|
+
if (custom !== 0)
|
|
170
|
+
return custom;
|
|
171
|
+
return a.record.objectID < b.record.objectID ? -1 : a.record.objectID > b.record.objectID ? 1 : 0;
|
|
172
|
+
});
|
|
173
|
+
return hits;
|
|
174
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Options every Algolia-twin HTTP surface needs, independent of who owns the socket. */
|
|
2
|
+
export interface AlgoliaTwinFetchOptions {
|
|
3
|
+
root?: string;
|
|
4
|
+
readOnly?: boolean;
|
|
5
|
+
}
|
|
6
|
+
export declare function createAlgoliaTwinFetch(options?: AlgoliaTwinFetchOptions): (request: Request) => Promise<Response>;
|
|
7
|
+
export declare function createAlgoliaTwinServer(options?: {
|
|
8
|
+
root?: string;
|
|
9
|
+
port?: number;
|
|
10
|
+
readOnly?: boolean;
|
|
11
|
+
}): Promise<{
|
|
12
|
+
port: number;
|
|
13
|
+
stop: () => void;
|
|
14
|
+
}>;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Algolia twin HTTP server — ONE process serving every host shape a real `algoliasearch` SDK (or
|
|
2
|
+
// raw REST caller) might target, so an unmodified SDK works end-to-end against a single local
|
|
3
|
+
// process. Unlike pinecone's control/data HOST split, Algolia's REST paths already carry the
|
|
4
|
+
// index name (`/1/indexes/{indexName}/...`), so this server does zero host-based dispatch — the
|
|
5
|
+
// literal HTTP `Host` header (or, for the SDK-integration test's proxy-rewrite pattern, an
|
|
6
|
+
// `x-algolia-target-host` stash) is threaded through purely for `routeAlgoliaSurface`'s host-
|
|
7
|
+
// tolerance proof (see algolia-twin.ts's header); routing itself is 100% path-driven.
|
|
8
|
+
//
|
|
9
|
+
// Writable by default; pass readOnly to reject writes (per-operation — see algolia-twin.ts).
|
|
10
|
+
//
|
|
11
|
+
// FETCH-FIRST (runtime contract R12b): the serve path is the plain fetch below, built from the
|
|
12
|
+
// kernel's ONE adaptation (`createTwinFetchFromHandler`) with the host threading as its per-request
|
|
13
|
+
// `extras`; the server is one line of Bun.serve around that same closure.
|
|
14
|
+
import { serveHttp } from '@volter/world-core';
|
|
15
|
+
import { handleAlgoliaTwinRequest } from "./algolia-twin.js";
|
|
16
|
+
import { createTwinFetchFromHandler, statefulTwinManifest } from '@volter/world-core';
|
|
17
|
+
export function createAlgoliaTwinFetch(options = {}) {
|
|
18
|
+
return createTwinFetchFromHandler(handleAlgoliaTwinRequest, {
|
|
19
|
+
...options,
|
|
20
|
+
manifest: statefulTwinManifest({ vendor: 'algolia', twinOf: 'Algolia hosted search', stores: 'indexes and their records (CRUD + batch), searched by a real local engine' }),
|
|
21
|
+
// `Headers` iteration already lower-cases every name, so the adapter's header map matches the
|
|
22
|
+
// one this server used to build by hand.
|
|
23
|
+
extras: (request, url) => ({
|
|
24
|
+
host: request.headers.get('x-algolia-target-host') || request.headers.get('host') || url.host,
|
|
25
|
+
}),
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
export async function createAlgoliaTwinServer(options = {}) {
|
|
29
|
+
const server = await serveHttp({
|
|
30
|
+
port: options.port ?? 0,
|
|
31
|
+
idleTimeout: 60,
|
|
32
|
+
fetch: createAlgoliaTwinFetch(options),
|
|
33
|
+
});
|
|
34
|
+
return { port: server.port ?? options.port ?? 0, stop: () => server.stop(true) };
|
|
35
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export type AlgoliaRequest = {
|
|
2
|
+
method: string;
|
|
3
|
+
path: string;
|
|
4
|
+
body?: string;
|
|
5
|
+
headers?: Record<string, string>;
|
|
6
|
+
/** Effective request host (real Host header, or the SDK-integration-test's target host). Fed
|
|
7
|
+
* through routeAlgoliaSurface/recoverAppIdFromHost purely for host-tolerance proof. */
|
|
8
|
+
host?: string;
|
|
9
|
+
/** Explicit appId override — the twin's OWN verifies use this directly (spec §6's documented
|
|
10
|
+
* fallback, mirrors pinecone's `index` field) instead of round-tripping through a host. */
|
|
11
|
+
appId?: string;
|
|
12
|
+
occurredAt?: string;
|
|
13
|
+
root?: string;
|
|
14
|
+
readOnly?: boolean;
|
|
15
|
+
};
|
|
16
|
+
export type AlgoliaResponse = {
|
|
17
|
+
status: number;
|
|
18
|
+
body: unknown;
|
|
19
|
+
headers?: Record<string, string>;
|
|
20
|
+
};
|
|
21
|
+
export declare const ALGOLIA_RESOURCE_TYPES: readonly ["index", "record", "synonym"];
|
|
22
|
+
export type AlgoliaResourceType = typeof ALGOLIA_RESOURCE_TYPES[number];
|
|
23
|
+
export type AlgoliaSurface = 'api';
|
|
24
|
+
/** Canonical WRITE-host form for an appId — real Algolia hosts literally embed the appId as a
|
|
25
|
+
* losslessly-recoverable prefix (unlike pinecone's synthetic per-index hash host), so no hash
|
|
26
|
+
* is needed; `root` is accepted for signature symmetry with pinecone's mintHost but does not
|
|
27
|
+
* affect the output (documented, not a bug — there is nothing per-root to disambiguate: the
|
|
28
|
+
* appId already IS the whole identity a host shape encodes). */
|
|
29
|
+
export declare function mintHost(appId: string, _root?: string): string;
|
|
30
|
+
/** Recover an appId from ANY of the three real host shapes (or a bare Host: port-suffixed
|
|
31
|
+
* variant), tolerating whichever one a caller presents. Returns undefined for an unrecognized
|
|
32
|
+
* shape. */
|
|
33
|
+
export declare function recoverAppIdFromHost(host: string | undefined): string | undefined;
|
|
34
|
+
/** Accepts EVERY real host shape (and no host at all) — always resolves to the single `'api'`
|
|
35
|
+
* surface; actual dispatch is by PATH alone (see file header). */
|
|
36
|
+
export declare function routeAlgoliaSurface(_req: {
|
|
37
|
+
host?: string;
|
|
38
|
+
path: string;
|
|
39
|
+
}): AlgoliaSurface;
|
|
40
|
+
export declare function handleAlgoliaTwinRequest(req: AlgoliaRequest): Promise<AlgoliaResponse>;
|
|
41
|
+
export type AlgoliaTwinSnapshot = {
|
|
42
|
+
resourceTypes: readonly AlgoliaResourceType[];
|
|
43
|
+
implementedEndpoints: readonly string[];
|
|
44
|
+
};
|
|
45
|
+
export declare function algoliaTwinSnapshot(): AlgoliaTwinSnapshot;
|