@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/functions",
3
- "version": "0.4.19",
3
+ "version": "0.4.20",
4
4
  "description": "TypeScript function runtime for pylon — defines server-side queries, mutations, and actions.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
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
  /**