@pylonsync/functions 0.4.19 → 0.4.20
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/dist/index.d.ts +1 -1
- package/dist/types.d.ts +75 -0
- package/package.json +1 -1
- package/src/index.ts +4 -0
- package/src/runtime-vector.test.ts +87 -0
- package/src/runtime.ts +22 -0
- package/src/types.ts +76 -0
package/dist/index.d.ts
CHANGED
|
@@ -24,4 +24,4 @@ export type { WorkflowDefinition, WorkflowRun, WorkflowRunRequest, WorkflowRunne
|
|
|
24
24
|
export { resetDb, installTestIsolation } from "./testing";
|
|
25
25
|
export { slugifyName, availableSlug } from "./slugify";
|
|
26
26
|
export type { SsrResponse, SsrCookieOptions, SsrMetadata, Sitemap, SitemapEntry, Robots, RobotsRule, } from "./ssr-runtime";
|
|
27
|
-
export type { QueryCtx, MutationCtx, ActionCtx, DbReader, DbWriter, Stream, Scheduler, AuthInfo, AuthMode, AuthRequirement, FnDefinition, Validator, AnyValidator, ValidatorSchema, InferValidator, InferArgs, RequireMember, RequireMemberOptions, MemberRow, Workflows, Llm, LlmMessage, LlmContentBlock, LlmTool, LlmCompleteRequest, LlmCompleteResponse, LlmStreamEvent, Rooms, } from "./types";
|
|
27
|
+
export type { QueryCtx, MutationCtx, ActionCtx, DbReader, DbWriter, Stream, Scheduler, AuthInfo, AuthMode, AuthRequirement, FnDefinition, Validator, AnyValidator, ValidatorSchema, InferValidator, InferArgs, RequireMember, RequireMemberOptions, MemberRow, Workflows, VectorSearchQuery, VectorSearchResult, SearchResult, PaginationResult, Llm, LlmMessage, LlmContentBlock, LlmTool, LlmCompleteRequest, LlmCompleteResponse, LlmStreamEvent, Rooms, } from "./types";
|
package/dist/types.d.ts
CHANGED
|
@@ -128,6 +128,28 @@ export interface DbReader {
|
|
|
128
128
|
* entities without a `search:` config (`SEARCH_NOT_CONFIGURED`).
|
|
129
129
|
*/
|
|
130
130
|
search(entity: string, query: Record<string, unknown>): Promise<SearchResult>;
|
|
131
|
+
/**
|
|
132
|
+
* Exact k-nearest-neighbor search over a `field.vector(dims)` field.
|
|
133
|
+
* Cosine similarity by default (`metric: "dot" | "l2"` to change);
|
|
134
|
+
* hits come back best-first with the full row on `doc` (vector
|
|
135
|
+
* fields stripped — re-fetch by id if you need the embedding).
|
|
136
|
+
*
|
|
137
|
+
* ```ts
|
|
138
|
+
* const [embedding] = await ctx.llm.embed(["how do I reset my password?"]);
|
|
139
|
+
* const { hits } = await ctx.db.vectorSearch("Doc", {
|
|
140
|
+
* field: "embedding",
|
|
141
|
+
* vector: embedding,
|
|
142
|
+
* limit: 5,
|
|
143
|
+
* filter: { status: "published" }, // equality / IN pre-filter
|
|
144
|
+
* });
|
|
145
|
+
* ```
|
|
146
|
+
*
|
|
147
|
+
* Exact scan, not ANN: every non-NULL embedding is scored. Fine to
|
|
148
|
+
* ~100k rows per entity; past that, a dedicated vector store wins.
|
|
149
|
+
* Throws `VECTOR_FIELD_NOT_FOUND` when `field` isn't a vector field
|
|
150
|
+
* and `INVALID_QUERY` on dimension mismatch or bad filters.
|
|
151
|
+
*/
|
|
152
|
+
vectorSearch(entity: string, query: VectorSearchQuery): Promise<VectorSearchResult>;
|
|
131
153
|
/**
|
|
132
154
|
* Cursor-paginated list. Pass `cursor` from a previous page's `nextCursor`
|
|
133
155
|
* to continue; pass `null` for the first page.
|
|
@@ -165,6 +187,32 @@ export interface SearchResult<T = Record<string, unknown>> {
|
|
|
165
187
|
/** Milliseconds spent in the search engine. */
|
|
166
188
|
tookMs: number;
|
|
167
189
|
}
|
|
190
|
+
/** Request shape for [`DbReader.vectorSearch`]. */
|
|
191
|
+
export interface VectorSearchQuery {
|
|
192
|
+
/** The `vector(dims)` field to search. */
|
|
193
|
+
field: string;
|
|
194
|
+
/** Query embedding; length must equal the field's declared dims. */
|
|
195
|
+
vector: number[];
|
|
196
|
+
/** Max hits. Default 10, capped at 200. */
|
|
197
|
+
limit?: number;
|
|
198
|
+
/** Similarity metric. Default "cosine" (higher = closer);
|
|
199
|
+
* "dot" (higher = closer); "l2" (Euclidean distance, lower = closer). */
|
|
200
|
+
metric?: "cosine" | "dot" | "l2";
|
|
201
|
+
/** Equality pre-filter applied in SQL before scoring. A plain value
|
|
202
|
+
* means equality; an array means SQL `IN`; `null` means IS NULL. */
|
|
203
|
+
filter?: Record<string, unknown>;
|
|
204
|
+
}
|
|
205
|
+
/** Result shape for [`DbReader.vectorSearch`]. */
|
|
206
|
+
export interface VectorSearchResult<T = Record<string, unknown>> {
|
|
207
|
+
/** Best-first hits for the chosen metric. */
|
|
208
|
+
hits: Array<{
|
|
209
|
+
id: string;
|
|
210
|
+
score: number;
|
|
211
|
+
doc: T;
|
|
212
|
+
}>;
|
|
213
|
+
/** Milliseconds spent scanning + scoring. */
|
|
214
|
+
tookMs: number;
|
|
215
|
+
}
|
|
168
216
|
export interface DbWriter extends DbReader {
|
|
169
217
|
/**
|
|
170
218
|
* Escape hatch — same shape as [`DbReader.unsafe`] but with the
|
|
@@ -339,6 +387,33 @@ export interface Llm {
|
|
|
339
387
|
* `complete` would refuse.
|
|
340
388
|
*/
|
|
341
389
|
stream(request: LlmCompleteRequest, onEvent: (event: LlmStreamEvent) => void): Promise<LlmCompleteResponse>;
|
|
390
|
+
/**
|
|
391
|
+
* Batch-embed texts via the configured embeddings provider. One
|
|
392
|
+
* embedding per input, in input order. Pair with a
|
|
393
|
+
* `field.vector(dims)` field and `ctx.db.vectorSearch` for
|
|
394
|
+
* retrieval:
|
|
395
|
+
*
|
|
396
|
+
* ```ts
|
|
397
|
+
* const [vec] = await ctx.llm.embed([doc.body]);
|
|
398
|
+
* await ctx.db.update("Doc", doc.id, { embedding: vec });
|
|
399
|
+
* ```
|
|
400
|
+
*
|
|
401
|
+
* The embeddings provider is a separate axis from chat: with
|
|
402
|
+
* `OPENAI_API_KEY` set it defaults to OpenAI
|
|
403
|
+
* `text-embedding-3-small` (1536 dims) even when chat runs
|
|
404
|
+
* Anthropic; `PYLON_EMBEDDINGS_PROVIDER=voyage` + `VOYAGE_API_KEY`
|
|
405
|
+
* selects Voyage `voyage-3.5` (1024 dims).
|
|
406
|
+
* `PYLON_EMBEDDINGS_MODEL` overrides the model.
|
|
407
|
+
*
|
|
408
|
+
* Not available in queries (reactive re-runs would re-bill the
|
|
409
|
+
* provider) — embed in a mutation/action and store the vector.
|
|
410
|
+
* Errors carry `err.code`: `EMBEDDINGS_NOT_CONFIGURED`,
|
|
411
|
+
* `PROVIDER_HTTP_<code>`, `PROVIDER_UNREACHABLE`,
|
|
412
|
+
* `INVALID_REQUEST`.
|
|
413
|
+
*/
|
|
414
|
+
embed(input: string[], opts?: {
|
|
415
|
+
model?: string;
|
|
416
|
+
}): Promise<number[][]>;
|
|
342
417
|
}
|
|
343
418
|
/**
|
|
344
419
|
* One event from an in-flight {@link Llm.stream} call.
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -63,6 +63,10 @@ export type {
|
|
|
63
63
|
RequireMemberOptions,
|
|
64
64
|
MemberRow,
|
|
65
65
|
Workflows,
|
|
66
|
+
VectorSearchQuery,
|
|
67
|
+
VectorSearchResult,
|
|
68
|
+
SearchResult,
|
|
69
|
+
PaginationResult,
|
|
66
70
|
// LLM + realtime surfaces. A handler writing an agent tool loop
|
|
67
71
|
// builds its own message array and branches on stream events, so
|
|
68
72
|
// these have to be nameable from app code.
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire-shape tests for the vector surfaces (runtime.ts):
|
|
3
|
+
*
|
|
4
|
+
* 1. `ctx.db.vectorSearch` emits `{type:"db", op:"vector_search"}`
|
|
5
|
+
* frames with the query on `data` and the unsafe_op / ssr_read
|
|
6
|
+
* flags the Rust policy gate keys on.
|
|
7
|
+
* 2. `ctx.llm.embed` emits `{type:"llm_embed", request:{input,model}}`
|
|
8
|
+
* with an op_id so concurrent embeds demux.
|
|
9
|
+
*
|
|
10
|
+
* Same child-process harness as runtime-db.test.ts — runtime.ts runs
|
|
11
|
+
* main() on import, so the builders are exercised in a probe script
|
|
12
|
+
* whose emitted NDJSON frames are the assertion target.
|
|
13
|
+
*/
|
|
14
|
+
import { expect, test } from "bun:test";
|
|
15
|
+
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
|
|
19
|
+
const RUNTIME = join(import.meta.dir, "runtime.ts");
|
|
20
|
+
|
|
21
|
+
const SCRIPT = `
|
|
22
|
+
import { buildDbReader, buildLlm } from ${JSON.stringify(RUNTIME)};
|
|
23
|
+
|
|
24
|
+
const reader = buildDbReader("c_r");
|
|
25
|
+
const llm = buildLlm("c_l");
|
|
26
|
+
|
|
27
|
+
// No host replies — don't await; the emitted frames are the target.
|
|
28
|
+
reader.vectorSearch("Doc", {
|
|
29
|
+
field: "embedding",
|
|
30
|
+
vector: [1, 0, 0],
|
|
31
|
+
limit: 5,
|
|
32
|
+
metric: "l2",
|
|
33
|
+
filter: { kind: "a" },
|
|
34
|
+
}).catch(() => {});
|
|
35
|
+
reader.unsafe.vectorSearch("Doc", { field: "embedding", vector: [1] }).catch(() => {});
|
|
36
|
+
llm.embed(["hello", "world"], { model: "text-embedding-3-large" }).catch(() => {});
|
|
37
|
+
|
|
38
|
+
setTimeout(() => process.exit(0), 300);
|
|
39
|
+
`;
|
|
40
|
+
|
|
41
|
+
test("vectorSearch + llm.embed emit the exact wire shapes the host parses", async () => {
|
|
42
|
+
const dir = mkdtempSync(join(tmpdir(), "pylon-fn-vec-"));
|
|
43
|
+
const scriptPath = join(dir, "probe.ts");
|
|
44
|
+
writeFileSync(scriptPath, SCRIPT);
|
|
45
|
+
|
|
46
|
+
const proc = Bun.spawn(["bun", scriptPath], {
|
|
47
|
+
stdin: "pipe",
|
|
48
|
+
stdout: "pipe",
|
|
49
|
+
stderr: "pipe",
|
|
50
|
+
});
|
|
51
|
+
const [stdout] = await Promise.all([
|
|
52
|
+
new Response(proc.stdout).text(),
|
|
53
|
+
proc.exited,
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
const frames = stdout
|
|
57
|
+
.split("\n")
|
|
58
|
+
.filter((l) => l.trim().startsWith("{"))
|
|
59
|
+
.map((l) => JSON.parse(l) as Record<string, any>);
|
|
60
|
+
|
|
61
|
+
const vs = frames.filter((f) => f.type === "db" && f.op === "vector_search");
|
|
62
|
+
expect(vs.length).toBe(2);
|
|
63
|
+
const safe = vs.find((f) => f.unsafe_op === false)!;
|
|
64
|
+
expect(safe).toMatchObject({
|
|
65
|
+
entity: "Doc",
|
|
66
|
+
ssr_read: false,
|
|
67
|
+
data: {
|
|
68
|
+
field: "embedding",
|
|
69
|
+
vector: [1, 0, 0],
|
|
70
|
+
limit: 5,
|
|
71
|
+
metric: "l2",
|
|
72
|
+
filter: { kind: "a" },
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
expect(safe.op_id).toBeDefined();
|
|
76
|
+
const unsafe = vs.find((f) => f.unsafe_op === true)!;
|
|
77
|
+
expect(unsafe.data.field).toBe("embedding");
|
|
78
|
+
|
|
79
|
+
const embed = frames.find((f) => f.type === "llm_embed")!;
|
|
80
|
+
expect(embed).toBeDefined();
|
|
81
|
+
expect(embed.call_id).toBe("c_l");
|
|
82
|
+
expect(embed.op_id).toBeDefined();
|
|
83
|
+
expect(embed.request).toMatchObject({
|
|
84
|
+
input: ["hello", "world"],
|
|
85
|
+
model: "text-embedding-3-large",
|
|
86
|
+
});
|
|
87
|
+
});
|
package/src/runtime.ts
CHANGED
|
@@ -689,6 +689,16 @@ function buildReaderOps(
|
|
|
689
689
|
ssr_read: ssrRead,
|
|
690
690
|
})) as any;
|
|
691
691
|
},
|
|
692
|
+
async vectorSearch(entity, query) {
|
|
693
|
+
return (await rpcDb(callId, {
|
|
694
|
+
type: "db",
|
|
695
|
+
op: "vector_search",
|
|
696
|
+
entity,
|
|
697
|
+
data: query,
|
|
698
|
+
unsafe_op: unsafeOp,
|
|
699
|
+
ssr_read: ssrRead,
|
|
700
|
+
})) as any;
|
|
701
|
+
},
|
|
692
702
|
};
|
|
693
703
|
}
|
|
694
704
|
|
|
@@ -915,6 +925,18 @@ export function buildLlm(callId: string): Llm {
|
|
|
915
925
|
(event) => onEvent(event as LlmStreamEvent),
|
|
916
926
|
)) as LlmCompleteResponse;
|
|
917
927
|
},
|
|
928
|
+
|
|
929
|
+
async embed(
|
|
930
|
+
input: string[],
|
|
931
|
+
opts?: { model?: string },
|
|
932
|
+
): Promise<number[][]> {
|
|
933
|
+
// op_id-keyed rpc so concurrent embeds in one handler demux.
|
|
934
|
+
const resp = (await rpcDb(callId, {
|
|
935
|
+
type: "llm_embed",
|
|
936
|
+
request: { input, model: opts?.model },
|
|
937
|
+
})) as { embeddings: number[][] };
|
|
938
|
+
return resp.embeddings;
|
|
939
|
+
},
|
|
918
940
|
};
|
|
919
941
|
}
|
|
920
942
|
|
package/src/types.ts
CHANGED
|
@@ -156,6 +156,32 @@ export interface DbReader {
|
|
|
156
156
|
query: Record<string, unknown>
|
|
157
157
|
): Promise<SearchResult>;
|
|
158
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Exact k-nearest-neighbor search over a `field.vector(dims)` field.
|
|
161
|
+
* Cosine similarity by default (`metric: "dot" | "l2"` to change);
|
|
162
|
+
* hits come back best-first with the full row on `doc` (vector
|
|
163
|
+
* fields stripped — re-fetch by id if you need the embedding).
|
|
164
|
+
*
|
|
165
|
+
* ```ts
|
|
166
|
+
* const [embedding] = await ctx.llm.embed(["how do I reset my password?"]);
|
|
167
|
+
* const { hits } = await ctx.db.vectorSearch("Doc", {
|
|
168
|
+
* field: "embedding",
|
|
169
|
+
* vector: embedding,
|
|
170
|
+
* limit: 5,
|
|
171
|
+
* filter: { status: "published" }, // equality / IN pre-filter
|
|
172
|
+
* });
|
|
173
|
+
* ```
|
|
174
|
+
*
|
|
175
|
+
* Exact scan, not ANN: every non-NULL embedding is scored. Fine to
|
|
176
|
+
* ~100k rows per entity; past that, a dedicated vector store wins.
|
|
177
|
+
* Throws `VECTOR_FIELD_NOT_FOUND` when `field` isn't a vector field
|
|
178
|
+
* and `INVALID_QUERY` on dimension mismatch or bad filters.
|
|
179
|
+
*/
|
|
180
|
+
vectorSearch(
|
|
181
|
+
entity: string,
|
|
182
|
+
query: VectorSearchQuery
|
|
183
|
+
): Promise<VectorSearchResult>;
|
|
184
|
+
|
|
159
185
|
/**
|
|
160
186
|
* Cursor-paginated list. Pass `cursor` from a previous page's `nextCursor`
|
|
161
187
|
* to continue; pass `null` for the first page.
|
|
@@ -196,6 +222,30 @@ export interface SearchResult<T = Record<string, unknown>> {
|
|
|
196
222
|
tookMs: number;
|
|
197
223
|
}
|
|
198
224
|
|
|
225
|
+
/** Request shape for [`DbReader.vectorSearch`]. */
|
|
226
|
+
export interface VectorSearchQuery {
|
|
227
|
+
/** The `vector(dims)` field to search. */
|
|
228
|
+
field: string;
|
|
229
|
+
/** Query embedding; length must equal the field's declared dims. */
|
|
230
|
+
vector: number[];
|
|
231
|
+
/** Max hits. Default 10, capped at 200. */
|
|
232
|
+
limit?: number;
|
|
233
|
+
/** Similarity metric. Default "cosine" (higher = closer);
|
|
234
|
+
* "dot" (higher = closer); "l2" (Euclidean distance, lower = closer). */
|
|
235
|
+
metric?: "cosine" | "dot" | "l2";
|
|
236
|
+
/** Equality pre-filter applied in SQL before scoring. A plain value
|
|
237
|
+
* means equality; an array means SQL `IN`; `null` means IS NULL. */
|
|
238
|
+
filter?: Record<string, unknown>;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Result shape for [`DbReader.vectorSearch`]. */
|
|
242
|
+
export interface VectorSearchResult<T = Record<string, unknown>> {
|
|
243
|
+
/** Best-first hits for the chosen metric. */
|
|
244
|
+
hits: Array<{ id: string; score: number; doc: T }>;
|
|
245
|
+
/** Milliseconds spent scanning + scoring. */
|
|
246
|
+
tookMs: number;
|
|
247
|
+
}
|
|
248
|
+
|
|
199
249
|
// ---------------------------------------------------------------------------
|
|
200
250
|
// Database — write operations (extends read)
|
|
201
251
|
// ---------------------------------------------------------------------------
|
|
@@ -422,6 +472,32 @@ export interface Llm {
|
|
|
422
472
|
request: LlmCompleteRequest,
|
|
423
473
|
onEvent: (event: LlmStreamEvent) => void,
|
|
424
474
|
): Promise<LlmCompleteResponse>;
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Batch-embed texts via the configured embeddings provider. One
|
|
478
|
+
* embedding per input, in input order. Pair with a
|
|
479
|
+
* `field.vector(dims)` field and `ctx.db.vectorSearch` for
|
|
480
|
+
* retrieval:
|
|
481
|
+
*
|
|
482
|
+
* ```ts
|
|
483
|
+
* const [vec] = await ctx.llm.embed([doc.body]);
|
|
484
|
+
* await ctx.db.update("Doc", doc.id, { embedding: vec });
|
|
485
|
+
* ```
|
|
486
|
+
*
|
|
487
|
+
* The embeddings provider is a separate axis from chat: with
|
|
488
|
+
* `OPENAI_API_KEY` set it defaults to OpenAI
|
|
489
|
+
* `text-embedding-3-small` (1536 dims) even when chat runs
|
|
490
|
+
* Anthropic; `PYLON_EMBEDDINGS_PROVIDER=voyage` + `VOYAGE_API_KEY`
|
|
491
|
+
* selects Voyage `voyage-3.5` (1024 dims).
|
|
492
|
+
* `PYLON_EMBEDDINGS_MODEL` overrides the model.
|
|
493
|
+
*
|
|
494
|
+
* Not available in queries (reactive re-runs would re-bill the
|
|
495
|
+
* provider) — embed in a mutation/action and store the vector.
|
|
496
|
+
* Errors carry `err.code`: `EMBEDDINGS_NOT_CONFIGURED`,
|
|
497
|
+
* `PROVIDER_HTTP_<code>`, `PROVIDER_UNREACHABLE`,
|
|
498
|
+
* `INVALID_REQUEST`.
|
|
499
|
+
*/
|
|
500
|
+
embed(input: string[], opts?: { model?: string }): Promise<number[][]>;
|
|
425
501
|
}
|
|
426
502
|
|
|
427
503
|
/**
|