@volter/twin-pinecone 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 +57 -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 +50 -0
- package/dist/src/pinecone-budget.d.ts +85 -0
- package/dist/src/pinecone-budget.js +411 -0
- package/dist/src/pinecone-capabilities.d.ts +4 -0
- package/dist/src/pinecone-capabilities.js +430 -0
- package/dist/src/pinecone-conformance.d.ts +7 -0
- package/dist/src/pinecone-conformance.js +32 -0
- package/dist/src/pinecone-connector.d.ts +45 -0
- package/dist/src/pinecone-connector.js +96 -0
- package/dist/src/pinecone-filter.d.ts +3 -0
- package/dist/src/pinecone-filter.js +69 -0
- package/dist/src/pinecone-server.d.ts +14 -0
- package/dist/src/pinecone-server.js +67 -0
- package/dist/src/pinecone-similarity.d.ts +15 -0
- package/dist/src/pinecone-similarity.js +48 -0
- package/dist/src/pinecone-twin.d.ts +52 -0
- package/dist/src/pinecone-twin.js +491 -0
- package/package.json +51 -0
- package/src/cli.ts +25 -0
- package/src/index.ts +91 -0
- package/src/pinecone-budget.ts +457 -0
- package/src/pinecone-capabilities.ts +486 -0
- package/src/pinecone-conformance.ts +37 -0
- package/src/pinecone-connector.ts +130 -0
- package/src/pinecone-filter.ts +84 -0
- package/src/pinecone-server.ts +75 -0
- package/src/pinecone-similarity.ts +71 -0
- package/src/pinecone-twin.ts +513 -0
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
// Pinecone API twin REQUEST HANDLER — a v1 slice of the Pinecone vector-database surface, backed
|
|
2
|
+
// by the event/action-log kernel (@volter/world-core). Contract:
|
|
3
|
+
// handlePineconeTwinRequest({ method, path, body, host?, index?, root, readOnly }) ->
|
|
4
|
+
// { status, body, headers }
|
|
5
|
+
//
|
|
6
|
+
// SOURCE OF TRUTH: authored against the real, first-party `@pinecone-database/pinecone` npm
|
|
7
|
+
// package's OWN generated TypeScript-fetch client (the SDK's `dist/pinecone-generated-ts-fetch/
|
|
8
|
+
// db_control` and `db_data` model files were fetched — via `npm pack`, an allowed unauthenticated
|
|
9
|
+
// read — and read directly during this build; see spec-sources.json) — every route path, JSON
|
|
10
|
+
// field name (all camelCase on the wire: `topK`, `includeValues`, `includeMetadata`,
|
|
11
|
+
// `sparseValues`, `upsertedCount`, ...), and the two-plane error-envelope split below is grounded
|
|
12
|
+
// against that generated client, NOT guessed. A handful of items the generated client doesn't
|
|
13
|
+
// settle (exact index create/delete status codes, euclidean's exact wire score, query-by-id
|
|
14
|
+
// self-inclusion, control-plane error shape) were additionally checked against Pinecone's public
|
|
15
|
+
// docs (docs.pinecone.io, unauthenticated GET) and are annotated doc-UNVERIFIED inline where the
|
|
16
|
+
// docs didn't resolve them — see spec-sources.json's `pinecone` entry for the full grounded-vs-
|
|
17
|
+
// doc-UNVERIFIED breakdown.
|
|
18
|
+
//
|
|
19
|
+
// HOST-SPLIT (control vs data plane), grounded from the SDK: `new Pinecone({ controllerHostUrl
|
|
20
|
+
// })` targets the CONTROL plane (index/collection management — `/indexes`, `/collections`); a
|
|
21
|
+
// per-index DATA plane host is minted at index-create time (`IndexModel.host`) and targeted via
|
|
22
|
+
// `pc.index({ name, host })` (verified in the SDK's own `data/index.js`: an explicit `host`
|
|
23
|
+
// bypasses the SDK's own `IndexHostSingleton.getHostUrl` describeIndex lookup entirely — the
|
|
24
|
+
// literal doc-comment example is `const index = pc.index({ host: indexModel.host })`). This twin
|
|
25
|
+
// mints a real (if synthetic-looking) per-index hostname (`mintHost`) and a single request router
|
|
26
|
+
// (`routePineconeSurface`) decides control-vs-data purely from the effective request host (or, as
|
|
27
|
+
// a path fallback, whether the path starts with `/indexes` or `/collections`) — see
|
|
28
|
+
// pinecone-server.ts's header comment for how an unmodified SDK's requests, which the SDK's
|
|
29
|
+
// `normalizeUrl` will always try to dial as a real DNS host, are routed to this ONE local
|
|
30
|
+
// twin process without ever touching the real network (a `fetchApi` override, not DNS).
|
|
31
|
+
//
|
|
32
|
+
// State lives ENTIRELY in the kernel action log: writes go through `applyTwinWrite`, reads are
|
|
33
|
+
// the projection (`projectResources`). There is NO Map/array side-store — the projection over the
|
|
34
|
+
// append-only log is the single source of truth (D1). No real Pinecone is ever contacted.
|
|
35
|
+
//
|
|
36
|
+
// WHAT THIS TWIN DOES (D2): the query is REAL brute-force exact top-K (pinecone-similarity.ts), a
|
|
37
|
+
// faithful SUPERSET of Pinecone's approximate-NN recall, not a lesser stub. Indexes are marked
|
|
38
|
+
// `ready` instantly and `indexFullness` is always 0. The integrated-embedding surface
|
|
39
|
+
// (`createIndexForModel`, `/records/.../upsert|search`) is filed as
|
|
40
|
+
// `pinecone.records.integrated_surface`; callers supply their own vectors today.
|
|
41
|
+
//
|
|
42
|
+
// Unmodeled route/op returns Pinecone's real error envelope for that plane (D2 — never a
|
|
43
|
+
// fabricated success). readOnly rejects WRITES with 405 — gated per-operation (not by HTTP verb:
|
|
44
|
+
// `POST /query` and `POST /describe_index_stats` are read-shaped POSTs and must stay allowed in
|
|
45
|
+
// readOnly mode, mirroring how this repo's POST-transport GraphQL packs gate readOnly per-
|
|
46
|
+
// mutation rather than by HTTP method — see linear-state.ts).
|
|
47
|
+
import { createHash } from 'node:crypto';
|
|
48
|
+
import { applyTwinWrite, projectResources } from '@volter/world-core';
|
|
49
|
+
import { matchesFilter, type PineconeFilter } from './pinecone-filter.ts';
|
|
50
|
+
import { scoreVector, sortByScore, type PineconeMetric } from './pinecone-similarity.ts';
|
|
51
|
+
|
|
52
|
+
const SERVICE = 'pinecone';
|
|
53
|
+
const DEFAULT_NAMESPACE = '';
|
|
54
|
+
|
|
55
|
+
export type PineconeRequest = {
|
|
56
|
+
method: string;
|
|
57
|
+
path: string;
|
|
58
|
+
body?: string;
|
|
59
|
+
headers?: Record<string, string>;
|
|
60
|
+
/** Effective request host (real Host header, or the SDK-integration-test's stashed original
|
|
61
|
+
* target host — see pinecone-server.ts). Drives routePineconeSurface + data-plane index
|
|
62
|
+
* recovery. */
|
|
63
|
+
host?: string;
|
|
64
|
+
/** Explicit data-plane index-name override — the twin's OWN verifies use this directly instead
|
|
65
|
+
* of round-tripping a minted host through recoverIndexFromHost (spec §6's documented fallback). */
|
|
66
|
+
index?: string;
|
|
67
|
+
occurredAt?: string;
|
|
68
|
+
root?: string;
|
|
69
|
+
readOnly?: boolean;
|
|
70
|
+
};
|
|
71
|
+
export type PineconeResponse = { status: number; body: unknown; headers?: Record<string, string> };
|
|
72
|
+
|
|
73
|
+
export const PINECONE_RESOURCE_TYPES = ['index', 'collection', 'vector'] as const;
|
|
74
|
+
export type PineconeResourceType = typeof PINECONE_RESOURCE_TYPES[number];
|
|
75
|
+
|
|
76
|
+
// ── host-split router ──────────────────────────────────────────────────────────────────────
|
|
77
|
+
const DATA_HOST_SUFFIX = '.svc.twin.pinecone.io';
|
|
78
|
+
|
|
79
|
+
/** Deterministic per-INDEX synthetic data-plane host — stable across calls, never a real
|
|
80
|
+
* resolvable DNS name (this twin is only ever reached via pinecone-server.ts's fetchApi-level
|
|
81
|
+
* redirect, so real-network resolvability is never required — see file header).
|
|
82
|
+
*
|
|
83
|
+
* R9 (serve-path determinism) AT THE RESOURCE LEVEL: the minted host is a function of the INDEX
|
|
84
|
+
* NAME and of nothing else. It deliberately does NOT hash the world `root`: the root is a
|
|
85
|
+
* filesystem PATH, so folding it in made `IndexModel.host` — a field every list/describe serves
|
|
86
|
+
* and every SDK dials — a function of WHERE the world happens to live, and two identical worlds
|
|
87
|
+
* under different directories served different hosts (found 2026-09-02 by the R9 replay sweep,
|
|
88
|
+
* the algolia `mintObjectId` defect in its second form). Uniqueness per root was never the
|
|
89
|
+
* property that mattered: a host only ever has to round-trip back to its index name through
|
|
90
|
+
* `recoverIndexFromHost` within ONE world, and index names are already unique within one. */
|
|
91
|
+
export function mintHost(indexName: string): string {
|
|
92
|
+
const h = createHash('sha256').update(`pinecone-twin-host:${indexName}`).digest('hex').slice(0, 8);
|
|
93
|
+
return `${indexName}-${h}${DATA_HOST_SUFFIX}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Recover an index name from a minted data-plane host (strip scheme/port, then the known
|
|
97
|
+
* suffix and trailing `-<hash8>`). Returns undefined for the control host or an unrecognized one. */
|
|
98
|
+
export function recoverIndexFromHost(host: string | undefined): string | undefined {
|
|
99
|
+
if (!host) return undefined;
|
|
100
|
+
const bare = host.replace(/^https?:\/\//, '').split(':')[0]!;
|
|
101
|
+
if (!bare.endsWith(DATA_HOST_SUFFIX)) return undefined;
|
|
102
|
+
const stem = bare.slice(0, -DATA_HOST_SUFFIX.length);
|
|
103
|
+
const m = stem.match(/^(.*)-([0-9a-f]{8})$/);
|
|
104
|
+
return m ? m[1] : undefined;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export type PineconeSurface = 'control' | 'data';
|
|
108
|
+
|
|
109
|
+
/** Decide control- vs data-plane purely from the effective host (path is a fallback for the
|
|
110
|
+
* twin's own verifies that don't bother setting a host at all). */
|
|
111
|
+
export function routePineconeSurface(req: { host?: string; path: string }): PineconeSurface {
|
|
112
|
+
const host = req.host;
|
|
113
|
+
const path = req.path.split('?')[0] ?? '/';
|
|
114
|
+
if (host === 'api.pinecone.io' || (host !== undefined && host.startsWith('api.pinecone.io:'))) return 'control';
|
|
115
|
+
if (host !== undefined && recoverIndexFromHost(host)) return 'data';
|
|
116
|
+
// No host, or a host that doesn't parse as a minted data-plane host — fall back to a
|
|
117
|
+
// path-shaped guess: this is what lets the twin's own verifies (and the capability manifest's
|
|
118
|
+
// `withRoot` helper) drive data-plane routes purely via the explicit `index` field, with no host
|
|
119
|
+
// round-trip at all (spec §6's documented fallback), while `/indexes`/`/collections` still
|
|
120
|
+
// resolve as control even with no host set.
|
|
121
|
+
if (path.startsWith('/indexes') || path.startsWith('/collections') || path === '/') return 'control';
|
|
122
|
+
return 'data';
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── error envelopes (two-plane split, D2/⚠2) ─────────────────────────────────────────────────
|
|
126
|
+
// DATA plane (gRPC-gateway): grounded verbatim against docs.pinecone.io's Query reference (fetched
|
|
127
|
+
// during this build) — `{code:int, message:string, details:[{typeUrl,value}]}` — codes per the
|
|
128
|
+
// gRPC-gateway convention: 3=INVALID_ARGUMENT->400, 5=NOT_FOUND->404, 6=ALREADY_EXISTS->409.
|
|
129
|
+
function dataError(status: number, code: number, message: string): PineconeResponse {
|
|
130
|
+
return { status, body: { code, message, details: [] } };
|
|
131
|
+
}
|
|
132
|
+
// CONTROL plane: doc-UNVERIFIED (the fetched docs.pinecone.io error-handling page 404'd during
|
|
133
|
+
// this build's read-only pass) — modeled per the build spec's own grounding pass:
|
|
134
|
+
// `{error:{code, message}, status}`, a DIFFERENT shape from the data plane. Filed as
|
|
135
|
+
// `pinecone.errors.control_plane_shape` (todo) for full parity once independently confirmed.
|
|
136
|
+
function controlError(status: number, code: string, message: string): PineconeResponse {
|
|
137
|
+
return { status, body: { error: { code, message }, status } };
|
|
138
|
+
}
|
|
139
|
+
function readOnlyRejection(): PineconeResponse {
|
|
140
|
+
return { status: 405, body: { message: 'twin is read-only; omit readOnly to accept writes' } };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── body / helpers ────────────────────────────────────────────────────────────────────────────
|
|
144
|
+
function parseBody(body?: string): Record<string, unknown> {
|
|
145
|
+
if (!body) return {};
|
|
146
|
+
try {
|
|
147
|
+
const v = JSON.parse(body);
|
|
148
|
+
return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : {};
|
|
149
|
+
} catch {
|
|
150
|
+
return {};
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
function nowIso(occurredAt?: string): string {
|
|
154
|
+
return occurredAt ?? new Date().toISOString();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── kernel subject ids (type-prefixed; D1) ──────────────────────────────────────────────────
|
|
158
|
+
function indexKey(name: string): string {
|
|
159
|
+
return `index:${name}`;
|
|
160
|
+
}
|
|
161
|
+
function collectionKey(name: string): string {
|
|
162
|
+
return `collection:${name}`;
|
|
163
|
+
}
|
|
164
|
+
function vectorKey(index: string, namespace: string, id: string): string {
|
|
165
|
+
return `vector:${index}::${namespace}::${id}`;
|
|
166
|
+
}
|
|
167
|
+
function parseVectorSuffix(suffix: string): { index: string; namespace: string; id: string } | undefined {
|
|
168
|
+
const parts = suffix.split('::');
|
|
169
|
+
if (parts.length !== 3) return undefined;
|
|
170
|
+
return { index: parts[0]!, namespace: parts[1]!, id: parts[2]! };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
type Row = Record<string, unknown> & { type: string; id: string };
|
|
174
|
+
|
|
175
|
+
function rowsOfType(type: string, root?: string): Row[] {
|
|
176
|
+
const prefix = `${type}:`;
|
|
177
|
+
return (projectResources(SERVICE, root) as Row[]).filter(
|
|
178
|
+
(r) => r.type === type && r.id.startsWith(prefix) && r._deleted !== true,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
function getRowById(type: string, subjectId: string, root?: string): Row | undefined {
|
|
182
|
+
return (projectResources(SERVICE, root) as Row[]).find((r) => r.type === type && r.id === subjectId && r._deleted !== true);
|
|
183
|
+
}
|
|
184
|
+
async function applyWrite(
|
|
185
|
+
subjectType: string,
|
|
186
|
+
subjectId: string,
|
|
187
|
+
fields: Record<string, unknown>,
|
|
188
|
+
operation: string,
|
|
189
|
+
req: PineconeRequest,
|
|
190
|
+
): Promise<Row> {
|
|
191
|
+
const { resource } = await applyTwinWrite(
|
|
192
|
+
SERVICE,
|
|
193
|
+
{ operation, subjectType, subjectId, fields, ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}), actor: { kind: 'agent' } },
|
|
194
|
+
req.root,
|
|
195
|
+
);
|
|
196
|
+
return resource as Row;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ── object views (mirror the SDK's generated model field names exactly) ────────────────────────
|
|
200
|
+
function indexView(r: Row): Record<string, unknown> {
|
|
201
|
+
// WIRE field names, grounded against the SDK's generated IndexModel (De)Serializer
|
|
202
|
+
// (pinecone-generated-ts-fetch/db_control/models/IndexModel.js): most fields are camelCase, but
|
|
203
|
+
// `deletion_protection` and `vector_type` are SNAKE_CASE on the wire — a genuine, easy-to-miss
|
|
204
|
+
// inconsistency within the control plane itself (the data plane is uniformly camelCase — see
|
|
205
|
+
// pinecone-similarity.ts/query wire shapes). Internal kernel row field names stay camelCase
|
|
206
|
+
// (this pack's own choice); only this view's OUTPUT keys must match the wire exactly.
|
|
207
|
+
return {
|
|
208
|
+
name: r.name,
|
|
209
|
+
dimension: r.dimension ?? null,
|
|
210
|
+
metric: r.metric,
|
|
211
|
+
host: r.host,
|
|
212
|
+
spec: r.spec,
|
|
213
|
+
status: { ready: r.ready ?? true, state: r.state ?? 'Ready' },
|
|
214
|
+
deletion_protection: r.deletionProtection ?? 'disabled',
|
|
215
|
+
tags: r.tags ?? {},
|
|
216
|
+
vector_type: r.vectorType ?? 'dense',
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
function collectionView(r: Row): Record<string, unknown> {
|
|
220
|
+
// WIRE: `vector_count` is snake_case (CollectionModel.js); everything else here is camelCase-
|
|
221
|
+
// identical to its single-word form.
|
|
222
|
+
return {
|
|
223
|
+
name: r.name,
|
|
224
|
+
size: r.size ?? 0,
|
|
225
|
+
status: r.status ?? 'Ready',
|
|
226
|
+
dimension: r.dimension ?? null,
|
|
227
|
+
vector_count: r.vectorCount ?? 0,
|
|
228
|
+
environment: r.environment ?? 'twin-local',
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
function vectorView(index: string, namespace: string, id: string, r: Row): Record<string, unknown> {
|
|
232
|
+
return { id, values: r.values ?? [], metadata: r.metadata ?? {} };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ── route handler ────────────────────────────────────────────────────────────────────────────
|
|
236
|
+
export async function handlePineconeTwinRequest(req: PineconeRequest): Promise<PineconeResponse> {
|
|
237
|
+
const method = req.method.toUpperCase();
|
|
238
|
+
const [rawPath, rawQuery] = req.path.split('?');
|
|
239
|
+
const path = (rawPath ?? '/').replace(/\/+$/, '') || '/';
|
|
240
|
+
const query = new URLSearchParams(rawQuery ?? '');
|
|
241
|
+
const body = parseBody(req.body);
|
|
242
|
+
const seg = path.replace(/^\/+/, '').split('/').filter(Boolean);
|
|
243
|
+
const idAt = (i: number) => decodeURIComponent(seg[i] ?? '');
|
|
244
|
+
const surface = routePineconeSurface({ host: req.host, path });
|
|
245
|
+
|
|
246
|
+
if (path === '/' || seg.length === 0) {
|
|
247
|
+
return { status: 200, body: { service: 'pinecone', object: 'twin' } };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ══════════════════════════════ CONTROL PLANE (api.pinecone.io) ═══════════════════════════
|
|
251
|
+
if (surface === 'control') {
|
|
252
|
+
// ── indexes ──
|
|
253
|
+
if (seg[0] === 'indexes') {
|
|
254
|
+
if (seg.length === 1 && method === 'POST') {
|
|
255
|
+
if (req.readOnly) return readOnlyRejection();
|
|
256
|
+
const name = typeof body.name === 'string' ? body.name : undefined;
|
|
257
|
+
// The real CreateIndexRequest schema makes `dimension` optional (only omittable for a
|
|
258
|
+
// model-backed / integrated-embedding index — `createIndexForModel`, filed as
|
|
259
|
+
// `pinecone.records.integrated_surface`). Since this twin
|
|
260
|
+
// never infers a dimension from a model, it requires an explicit one on every create.
|
|
261
|
+
const dimension = typeof body.dimension === 'number' ? body.dimension : undefined;
|
|
262
|
+
if (!name || !dimension) return controlError(400, 'INVALID_ARGUMENT', 'name and dimension are required to create an index');
|
|
263
|
+
if (getRowById('index', indexKey(name), req.root)) return controlError(409, 'ALREADY_EXISTS', `Resource ${name} already exists`);
|
|
264
|
+
const metric = typeof body.metric === 'string' ? body.metric : 'cosine';
|
|
265
|
+
// WIRE keys are snake_case here (CreateIndexRequestToJSON) — see indexView's header note.
|
|
266
|
+
const vectorType = typeof body.vector_type === 'string' ? body.vector_type : 'dense';
|
|
267
|
+
const spec = body.spec ?? { serverless: { cloud: 'aws', region: 'us-east-1' } };
|
|
268
|
+
const deletionProtection = typeof body.deletion_protection === 'string' ? body.deletion_protection : 'disabled';
|
|
269
|
+
const tags = (body.tags as Record<string, string> | undefined) ?? {};
|
|
270
|
+
const host = mintHost(name);
|
|
271
|
+
await applyWrite('index', indexKey(name), { name, dimension, metric, vectorType, spec, deletionProtection, tags, host, ready: true, state: 'Ready' }, 'index.create', req);
|
|
272
|
+
const row = getRowById('index', indexKey(name), req.root)!;
|
|
273
|
+
return { status: 201, body: indexView(row) };
|
|
274
|
+
}
|
|
275
|
+
if (seg.length === 1 && method === 'GET') {
|
|
276
|
+
return { status: 200, body: { indexes: rowsOfType('index', req.root).map(indexView) } };
|
|
277
|
+
}
|
|
278
|
+
if (seg.length === 2 && method === 'GET') {
|
|
279
|
+
const row = getRowById('index', indexKey(idAt(1)), req.root);
|
|
280
|
+
if (!row) return controlError(404, 'NOT_FOUND', `Index ${idAt(1)} not found`);
|
|
281
|
+
return { status: 200, body: indexView(row) };
|
|
282
|
+
}
|
|
283
|
+
if (seg.length === 2 && method === 'PATCH') {
|
|
284
|
+
if (req.readOnly) return readOnlyRejection();
|
|
285
|
+
const row = getRowById('index', indexKey(idAt(1)), req.root);
|
|
286
|
+
if (!row) return controlError(404, 'NOT_FOUND', `Index ${idAt(1)} not found`);
|
|
287
|
+
// Dimension is immutable post-create (real-API invariant); reject an attempt to change it.
|
|
288
|
+
if (typeof body.dimension === 'number' && body.dimension !== row.dimension) {
|
|
289
|
+
return controlError(400, 'INVALID_ARGUMENT', 'dimension cannot be changed after index creation');
|
|
290
|
+
}
|
|
291
|
+
const fields: Record<string, unknown> = {};
|
|
292
|
+
// WIRE key is snake_case here (ConfigureIndexRequestToJSON) — see indexView's header note.
|
|
293
|
+
if (body.deletion_protection !== undefined) fields.deletionProtection = body.deletion_protection;
|
|
294
|
+
if (body.tags !== undefined) fields.tags = { ...(row.tags as Record<string, unknown> | undefined ?? {}), ...(body.tags as Record<string, unknown>) };
|
|
295
|
+
if (body.spec !== undefined) fields.spec = { ...(row.spec as Record<string, unknown> | undefined ?? {}), ...(body.spec as Record<string, unknown>) };
|
|
296
|
+
await applyWrite('index', indexKey(idAt(1)), fields, 'index.configure', req);
|
|
297
|
+
const updated = getRowById('index', indexKey(idAt(1)), req.root)!;
|
|
298
|
+
return { status: 200, body: indexView(updated) };
|
|
299
|
+
}
|
|
300
|
+
if (seg.length === 2 && method === 'DELETE') {
|
|
301
|
+
if (req.readOnly) return readOnlyRejection();
|
|
302
|
+
const row = getRowById('index', indexKey(idAt(1)), req.root);
|
|
303
|
+
if (!row) return controlError(404, 'NOT_FOUND', `Index ${idAt(1)} not found`);
|
|
304
|
+
await applyWrite('index', indexKey(idAt(1)), { _deleted: true }, 'index.delete', req);
|
|
305
|
+
// Real Pinecone index deletion is asynchronous; 202 Accepted is the conventional REST
|
|
306
|
+
// reading (doc-UNVERIFIED exact code — ⚠6, spec-sources.json) — this twin deletes
|
|
307
|
+
// synchronously so a follow-up describe/list reflects it immediately either way.
|
|
308
|
+
return { status: 202, body: null };
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
// ── collections ── (a collection is a named, curated snapshot of an existing index; full
|
|
312
|
+
// point-in-time snapshot semantics are `pinecone.collections.from_index_snapshot` (todo) —
|
|
313
|
+
// this v1 slice models create/list/describe/delete as kernel-tracked metadata records.)
|
|
314
|
+
if (seg[0] === 'collections') {
|
|
315
|
+
if (seg.length === 1 && method === 'POST') {
|
|
316
|
+
if (req.readOnly) return readOnlyRejection();
|
|
317
|
+
const name = typeof body.name === 'string' ? body.name : undefined;
|
|
318
|
+
const source = typeof body.source === 'string' ? body.source : undefined;
|
|
319
|
+
if (!name || !source) return controlError(400, 'INVALID_ARGUMENT', 'name and source (an existing index name) are required to create a collection');
|
|
320
|
+
const sourceIndex = getRowById('index', indexKey(source), req.root);
|
|
321
|
+
if (!sourceIndex) return controlError(404, 'NOT_FOUND', `Index ${source} not found`);
|
|
322
|
+
if (getRowById('collection', collectionKey(name), req.root)) return controlError(409, 'ALREADY_EXISTS', `Resource ${name} already exists`);
|
|
323
|
+
await applyWrite('collection', collectionKey(name), { name, dimension: sourceIndex.dimension, vectorCount: 0, size: 0, status: 'Ready', environment: 'twin-local' }, 'collection.create', req);
|
|
324
|
+
const row = getRowById('collection', collectionKey(name), req.root)!;
|
|
325
|
+
return { status: 201, body: collectionView(row) };
|
|
326
|
+
}
|
|
327
|
+
if (seg.length === 1 && method === 'GET') {
|
|
328
|
+
return { status: 200, body: { collections: rowsOfType('collection', req.root).map(collectionView) } };
|
|
329
|
+
}
|
|
330
|
+
if (seg.length === 2 && method === 'GET') {
|
|
331
|
+
const row = getRowById('collection', collectionKey(idAt(1)), req.root);
|
|
332
|
+
if (!row) return controlError(404, 'NOT_FOUND', `Collection ${idAt(1)} not found`);
|
|
333
|
+
return { status: 200, body: collectionView(row) };
|
|
334
|
+
}
|
|
335
|
+
if (seg.length === 2 && method === 'DELETE') {
|
|
336
|
+
if (req.readOnly) return readOnlyRejection();
|
|
337
|
+
const row = getRowById('collection', collectionKey(idAt(1)), req.root);
|
|
338
|
+
if (!row) return controlError(404, 'NOT_FOUND', `Collection ${idAt(1)} not found`);
|
|
339
|
+
await applyWrite('collection', collectionKey(idAt(1)), { _deleted: true }, 'collection.delete', req);
|
|
340
|
+
return { status: 202, body: null };
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return controlError(404, 'NOT_FOUND', `Unrecognized control-plane route: ${method} ${path}`);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ════════════════════════════════ DATA PLANE (per-index minted host) ══════════════════════
|
|
347
|
+
const indexName = req.index ?? recoverIndexFromHost(req.host);
|
|
348
|
+
if (!indexName) return dataError(404, 5, 'Could not resolve an index for this request host');
|
|
349
|
+
const indexRow = getRowById('index', indexKey(indexName), req.root);
|
|
350
|
+
if (!indexRow) return dataError(404, 5, `Index ${indexName} not found`);
|
|
351
|
+
const dimension = typeof indexRow.dimension === 'number' ? indexRow.dimension : undefined;
|
|
352
|
+
const metric = (indexRow.metric as PineconeMetric | undefined) ?? 'cosine';
|
|
353
|
+
|
|
354
|
+
function vectorRowsIn(namespace: string): Array<{ id: string; values: number[]; metadata: Record<string, unknown> }> {
|
|
355
|
+
const prefix = `vector:${indexName}::${namespace}::`;
|
|
356
|
+
return rowsOfType('vector', req.root)
|
|
357
|
+
.filter((r) => r.id.startsWith(prefix))
|
|
358
|
+
.map((r) => {
|
|
359
|
+
const parsed = parseVectorSuffix(r.id.slice('vector:'.length))!;
|
|
360
|
+
return { id: parsed.id, values: (r.values as number[]) ?? [], metadata: (r.metadata as Record<string, unknown>) ?? {} };
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (seg[0] === 'vectors' && seg[1] === 'upsert' && seg.length === 2 && method === 'POST') {
|
|
365
|
+
if (req.readOnly) return readOnlyRejection();
|
|
366
|
+
const vectors = Array.isArray(body.vectors) ? (body.vectors as Array<Record<string, unknown>>) : [];
|
|
367
|
+
const namespace = typeof body.namespace === 'string' ? body.namespace : DEFAULT_NAMESPACE;
|
|
368
|
+
for (const v of vectors) {
|
|
369
|
+
const values = Array.isArray(v.values) ? (v.values as number[]) : [];
|
|
370
|
+
if (dimension !== undefined && values.length !== dimension) {
|
|
371
|
+
return dataError(400, 3, `Vector dimension ${values.length} does not match the dimension of the index ${dimension}`);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
for (const v of vectors) {
|
|
375
|
+
const id = String(v.id ?? '');
|
|
376
|
+
if (!id) continue;
|
|
377
|
+
await applyWrite('vector', vectorKey(indexName, namespace, id), { values: v.values ?? [], metadata: v.metadata ?? {} }, 'vector.upsert', req);
|
|
378
|
+
}
|
|
379
|
+
return { status: 200, body: { upsertedCount: vectors.length } };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (seg[0] === 'vectors' && seg[1] === 'fetch' && seg.length === 2 && method === 'GET') {
|
|
383
|
+
const ids = (query.get('ids') ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
384
|
+
const namespace = query.get('namespace') ?? DEFAULT_NAMESPACE;
|
|
385
|
+
const vectors: Record<string, unknown> = {};
|
|
386
|
+
for (const id of ids) {
|
|
387
|
+
const row = getRowById('vector', vectorKey(indexName, namespace, id), req.root);
|
|
388
|
+
// Unknown ids are simply OMITTED from the map (not a 404) — the docs.pinecone.io fetch-data
|
|
389
|
+
// guide describes the response as "a map between the fetched ids and the fetched vectors"
|
|
390
|
+
// with no documented error path for a missing id; doc-UNVERIFIED but structurally the
|
|
391
|
+
// natural reading of a map-shaped response, not a fabricated success (⚠5).
|
|
392
|
+
if (row) vectors[id] = vectorView(indexName, namespace, id, row);
|
|
393
|
+
}
|
|
394
|
+
return { status: 200, body: { vectors, namespace } };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (seg[0] === 'vectors' && seg[1] === 'update' && seg.length === 2 && method === 'POST') {
|
|
398
|
+
if (req.readOnly) return readOnlyRejection();
|
|
399
|
+
const id = String(body.id ?? '');
|
|
400
|
+
const namespace = typeof body.namespace === 'string' ? body.namespace : DEFAULT_NAMESPACE;
|
|
401
|
+
if (!id) return dataError(400, 3, 'id is required');
|
|
402
|
+
const existing = getRowById('vector', vectorKey(indexName, namespace, id), req.root);
|
|
403
|
+
if (existing) {
|
|
404
|
+
const fields: Record<string, unknown> = {};
|
|
405
|
+
if (body.values !== undefined) fields.values = body.values;
|
|
406
|
+
if (body.setMetadata !== undefined) fields.metadata = { ...(existing.metadata as Record<string, unknown> | undefined ?? {}), ...(body.setMetadata as Record<string, unknown>) };
|
|
407
|
+
await applyWrite('vector', vectorKey(indexName, namespace, id), fields, 'vector.update', req);
|
|
408
|
+
}
|
|
409
|
+
return { status: 200, body: {} };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (seg[0] === 'vectors' && seg[1] === 'delete' && seg.length === 2 && method === 'POST') {
|
|
413
|
+
if (req.readOnly) return readOnlyRejection();
|
|
414
|
+
const namespace = typeof body.namespace === 'string' ? body.namespace : DEFAULT_NAMESPACE;
|
|
415
|
+
if (body.deleteAll === true) {
|
|
416
|
+
// deleteAll only clears the TARGETED namespace, never the whole index (⚠ vectors.delete_all).
|
|
417
|
+
for (const v of vectorRowsIn(namespace)) await applyWrite('vector', vectorKey(indexName, namespace, v.id), { _deleted: true }, 'vector.delete', req);
|
|
418
|
+
} else if (Array.isArray(body.ids)) {
|
|
419
|
+
for (const id of body.ids as string[]) await applyWrite('vector', vectorKey(indexName, namespace, id), { _deleted: true }, 'vector.delete', req);
|
|
420
|
+
} else if (body.filter !== undefined) {
|
|
421
|
+
// Real metadata-filter-driven delete (⚠ vectors.delete_by_filter) — never a name/keyword match.
|
|
422
|
+
for (const v of vectorRowsIn(namespace)) {
|
|
423
|
+
if (matchesFilter(v.metadata, body.filter as PineconeFilter)) {
|
|
424
|
+
await applyWrite('vector', vectorKey(indexName, namespace, v.id), { _deleted: true }, 'vector.delete', req);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
return { status: 200, body: {} };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if (seg[0] === 'vectors' && seg[1] === 'list' && seg.length === 2 && method === 'GET') {
|
|
432
|
+
const namespace = query.get('namespace') ?? DEFAULT_NAMESPACE;
|
|
433
|
+
const prefix = query.get('prefix') ?? '';
|
|
434
|
+
const limit = Number(query.get('limit') ?? '100') || 100;
|
|
435
|
+
const items = vectorRowsIn(namespace).filter((v) => v.id.startsWith(prefix)).slice(0, limit);
|
|
436
|
+
return { status: 200, body: { vectors: items.map((v) => ({ id: v.id })), namespace, pagination: { next: null } } };
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (path === '/query' && method === 'POST') {
|
|
440
|
+
const namespace = typeof body.namespace === 'string' ? body.namespace : DEFAULT_NAMESPACE;
|
|
441
|
+
const topK = typeof body.topK === 'number' ? body.topK : 10;
|
|
442
|
+
const includeValues = body.includeValues === true;
|
|
443
|
+
const includeMetadata = body.includeMetadata === true;
|
|
444
|
+
const filter = body.filter as PineconeFilter | undefined;
|
|
445
|
+
let queryVector: number[] | undefined;
|
|
446
|
+
let selfId: string | undefined;
|
|
447
|
+
if (Array.isArray(body.vector)) {
|
|
448
|
+
queryVector = body.vector as number[];
|
|
449
|
+
} else if (typeof body.id === 'string') {
|
|
450
|
+
selfId = body.id;
|
|
451
|
+
const self = getRowById('vector', vectorKey(indexName, namespace, body.id), req.root);
|
|
452
|
+
queryVector = self ? ((self.values as number[]) ?? []) : undefined;
|
|
453
|
+
}
|
|
454
|
+
if (!queryVector) return { status: 200, body: { matches: [], namespace } };
|
|
455
|
+
const candidates = vectorRowsIn(namespace)
|
|
456
|
+
.filter((v) => v.id !== selfId) // query-by-id EXCLUDES the query vector itself (⚠ query.by_id
|
|
457
|
+
// self-inclusion — not settled by the fetched docs; modeled on the common ANN convention
|
|
458
|
+
// that a nearest-neighbor search never returns the query point as its own neighbor;
|
|
459
|
+
// doc-UNVERIFIED, recorded in spec-sources.json).
|
|
460
|
+
.filter((v) => matchesFilter(v.metadata, filter))
|
|
461
|
+
.map((v) => ({ id: v.id, score: scoreVector(metric, queryVector!, v.values), values: v.values, metadata: v.metadata }));
|
|
462
|
+
const ranked = sortByScore(metric, candidates).slice(0, Math.max(0, topK));
|
|
463
|
+
const matches = ranked.map((m) => ({
|
|
464
|
+
id: m.id,
|
|
465
|
+
score: m.score,
|
|
466
|
+
...(includeValues ? { values: m.values } : {}),
|
|
467
|
+
...(includeMetadata ? { metadata: m.metadata } : {}),
|
|
468
|
+
}));
|
|
469
|
+
return { status: 200, body: { matches, namespace } };
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
if (path === '/describe_index_stats' && method === 'POST') {
|
|
473
|
+
const allVectors = rowsOfType('vector', req.root).filter((r) => r.id.startsWith(`vector:${indexName}::`));
|
|
474
|
+
const namespaces: Record<string, { vectorCount: number }> = {};
|
|
475
|
+
for (const r of allVectors) {
|
|
476
|
+
const parsed = parseVectorSuffix(r.id.slice('vector:'.length));
|
|
477
|
+
if (!parsed) continue;
|
|
478
|
+
namespaces[parsed.namespace] = { vectorCount: (namespaces[parsed.namespace]?.vectorCount ?? 0) + 1 };
|
|
479
|
+
}
|
|
480
|
+
return {
|
|
481
|
+
status: 200,
|
|
482
|
+
body: {
|
|
483
|
+
namespaces,
|
|
484
|
+
dimension: dimension ?? null,
|
|
485
|
+
// No real pod/serverless infrastructure is provisioned — indexFullness has no organic
|
|
486
|
+
// meaning here, so it is
|
|
487
|
+
// always 0 rather than a fabricated non-zero figure.
|
|
488
|
+
indexFullness: 0,
|
|
489
|
+
totalVectorCount: allVectors.length,
|
|
490
|
+
},
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
return dataError(404, 5, `Unrecognized data-plane route: ${method} ${path}`);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
export type PineconeTwinSnapshot = {
|
|
498
|
+
resourceTypes: readonly PineconeResourceType[];
|
|
499
|
+
implementedEndpoints: readonly string[];
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
// Endpoint inventory used by the conformance snapshot.
|
|
503
|
+
export function pineconeTwinSnapshot(): PineconeTwinSnapshot {
|
|
504
|
+
return {
|
|
505
|
+
resourceTypes: PINECONE_RESOURCE_TYPES,
|
|
506
|
+
implementedEndpoints: [
|
|
507
|
+
'POST /indexes', 'GET /indexes', 'GET /indexes/:name', 'PATCH /indexes/:name', 'DELETE /indexes/:name',
|
|
508
|
+
'POST /collections', 'GET /collections', 'GET /collections/:name', 'DELETE /collections/:name',
|
|
509
|
+
'POST /vectors/upsert', 'GET /vectors/fetch', 'POST /vectors/update', 'POST /vectors/delete', 'GET /vectors/list',
|
|
510
|
+
'POST /query', 'POST /describe_index_stats',
|
|
511
|
+
],
|
|
512
|
+
};
|
|
513
|
+
}
|