@pylonsync/functions 0.12.0 → 0.13.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/dist/index.d.ts CHANGED
@@ -26,4 +26,4 @@ export type { WorkflowDefinition, WorkflowRun, WorkflowRunRequest, WorkflowRunne
26
26
  export { resetDb, installTestIsolation } from "./testing";
27
27
  export { slugifyName, availableSlug } from "./slugify";
28
28
  export type { SsrResponse, SsrCookieOptions, SsrMetadata, Sitemap, SitemapEntry, Robots, RobotsRule, } from "./ssr-runtime";
29
- export type { QueryCtx, MutationCtx, ActionCtx, DbReader, DbWriter, Stream, Scheduler, AuthInfo, AuthMode, AuthRequirement, FnDefinition, Validator, AnyValidator, ValidatorSchema, InferValidator, InferArgs, RequireMember, RequireMemberOptions, Shards, MemberRow, Workflows, VectorSearchQuery, VectorSearchResult, SearchResult, PaginationResult, Llm, LlmMessage, LlmContentBlock, LlmTool, LlmCompleteRequest, LlmCompleteResponse, LlmStreamEvent, Rooms, Domains, TenantDomainResult, TenantDomainDns, DomainAvailability, DomainContact, RegisterDomainOptions, RegisteredDomainResult, } from "./types";
29
+ export type { QueryCtx, MutationCtx, ActionCtx, DbReader, DbWriter, Stream, Scheduler, AuthInfo, AuthMode, AuthRequirement, FnDefinition, Validator, AnyValidator, ValidatorSchema, InferValidator, InferArgs, RequireMember, RequireMemberOptions, Shards, ShardsReader, ShardInfo, MemberRow, Workflows, VectorSearchQuery, VectorSearchResult, SearchResult, PaginationResult, Llm, LlmMessage, LlmContentBlock, LlmTool, LlmCompleteRequest, LlmCompleteResponse, LlmStreamEvent, Rooms, Domains, TenantDomainResult, TenantDomainDns, DomainAvailability, DomainContact, RegisterDomainOptions, RegisteredDomainResult, } from "./types";
@@ -24,7 +24,7 @@ export interface ProductionBuildResult {
24
24
  export declare function buildProduction(opts: ProductionBuildOptions): Promise<ProductionBuildResult>;
25
25
  /** The artifact-relative path for one `build.include` entry. Throws when the
26
26
  * entry cannot be copied into the artifact as-is. */
27
- export declare function checkInclude(path: any, cwd: string, outDir: string, inc: string, src: string): string;
27
+ export declare function checkInclude(path: any, cwd: string, outDir: string, inc: string, src: string, label?: string): string;
28
28
  /**
29
29
  * Every app module the SSR runtime can import: files named by convention
30
30
  * anywhere under the app dir, plus every component and layout the manifest
package/dist/types.d.ts CHANGED
@@ -766,6 +766,39 @@ export interface Shards {
766
766
  claims?: Record<string, unknown>;
767
767
  ttlSecs?: number;
768
768
  }): Promise<string>;
769
+ /**
770
+ * Start shard `shardId` of a kind declared with `shard({...})` in app.ts.
771
+ * `params` reach the module's `init`. Actions only: a mutation's
772
+ * rollback cannot undo it.
773
+ *
774
+ * Throws `SHARD_EXISTS` when the id is running, `SHARD_LIMIT_REACHED`
775
+ * at the kind's `maxInstances`, `SHARD_KIND_NOT_FOUND`,
776
+ * `SHARD_ID_INVALID`, or `SHARD_INIT_FAILED` when `init` refuses.
777
+ */
778
+ create(kind: string, shardId: string, params?: unknown): Promise<ShardInfo>;
779
+ /** Stop a shard and close its subscribers' connections. Resolves to
780
+ * `false` when no shard has that id. Actions only. */
781
+ stop(shardId: string): Promise<boolean>;
782
+ /** A running shard, or `null`. */
783
+ get(shardId: string): Promise<ShardInfo | null>;
784
+ /** Every running shard. */
785
+ list(): Promise<ShardInfo[]>;
786
+ }
787
+ /** `ctx.shards` in a query or mutation: tickets and reads, no start or stop. */
788
+ export type ShardsReader = Pick<Shards, "ticket" | "get" | "list">;
789
+ /** A running shard, from `ctx.shards.create`, `get`, or `list`. */
790
+ export interface ShardInfo {
791
+ id: string;
792
+ /** The shard kind's name. */
793
+ kind: string;
794
+ /** Ticks run so far. */
795
+ tick: number;
796
+ subscribers: number;
797
+ /** False once the shard stopped (finished, idle, or failed) and before
798
+ * the host removes it. */
799
+ running: boolean;
800
+ /** Why the module stopped, when it trapped. */
801
+ error?: string;
769
802
  }
770
803
  /** Context for query handlers (read-only).
771
804
  *
@@ -792,8 +825,8 @@ export interface QueryCtx<R extends AuthRequirement = "optional"> {
792
825
  requireMember: RequireMember;
793
826
  /** Signed file-download URLs — see {@link Files}. */
794
827
  files: Files;
795
- /** Shard tickets — see {@link Shards}. */
796
- shards: Shards;
828
+ /** Shard tickets and reads — see {@link Shards}. */
829
+ shards: ShardsReader;
797
830
  /**
798
831
  * Fires when the host cancels this call (idle timeout exceeded).
799
832
  * Thread it into `fetch(url, { signal: ctx.signal })` or SDK calls so
@@ -821,8 +854,8 @@ export interface MutationCtx<R extends AuthRequirement = "optional"> {
821
854
  workflows: Workflows;
822
855
  /** Signed file-download URLs — see {@link Files}. */
823
856
  files: Files;
824
- /** Shard tickets — see {@link Shards}. */
825
- shards: Shards;
857
+ /** Shard tickets and reads — see {@link Shards}. */
858
+ shards: ShardsReader;
826
859
  /** Create a typed error that triggers rollback. */
827
860
  error(code: string, message: string): Error;
828
861
  /** Assert org membership (optionally a role) — see {@link RequireMember}. */
@@ -992,7 +1025,7 @@ export interface ActionCtx<R extends AuthRequirement = "optional"> {
992
1025
  env: Record<string, string>;
993
1026
  /** Signed file-download URLs — see {@link Files}. */
994
1027
  files: Files;
995
- /** Shard tickets — see {@link Shards}. */
1028
+ /** Shard tickets, reads, and start/stop — see {@link Shards}. */
996
1029
  shards: Shards;
997
1030
  /** Run a registered query within its own read transaction. */
998
1031
  runQuery<T = unknown>(fnName: string, args: Record<string, unknown>): Promise<T>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/functions",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
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
@@ -74,6 +74,8 @@ export type {
74
74
  RequireMember,
75
75
  RequireMemberOptions,
76
76
  Shards,
77
+ ShardsReader,
78
+ ShardInfo,
77
79
  MemberRow,
78
80
  Workflows,
79
81
  VectorSearchQuery,
@@ -234,6 +234,18 @@ export async function buildProduction(
234
234
  if (!fs.existsSync(src)) throw new Error(`build.include: "${inc}" does not exist`);
235
235
  fs.cpSync(src, path.join(outDir, rel), { recursive: true, dereference: true });
236
236
  }
237
+ // Shard modules, at the paths app.ts gives, which the runtime reads at boot.
238
+ for (const def of (manifest.shards ?? []) as Array<{ name: string; wasm: string }>) {
239
+ const src = path.resolve(cwd, def.wasm);
240
+ const rel = checkInclude(path, cwd, outDir, def.wasm, src, `shard "${def.name}" wasm`);
241
+ if (!fs.existsSync(src)) {
242
+ throw new Error(
243
+ `shard "${def.name}": ${def.wasm} does not exist; run \`pylon shards build\` first`,
244
+ );
245
+ }
246
+ fs.mkdirSync(path.dirname(path.join(outDir, rel)), { recursive: true });
247
+ fs.copyFileSync(src, path.join(outDir, rel));
248
+ }
237
249
 
238
250
  fs.writeFileSync(path.join(outDir, "pylon.manifest.json"), manifestRaw, "utf8");
239
251
  const pkg = JSON.parse(fs.readFileSync(path.join(here, "..", "package.json"), "utf8"));
@@ -317,21 +329,22 @@ export function checkInclude(
317
329
  outDir: string,
318
330
  inc: string,
319
331
  src: string,
332
+ label = "build.include",
320
333
  ): string {
321
334
  const rel = path.relative(cwd, src);
322
335
  const isOutside = (r: string) =>
323
336
  r === ".." || r.startsWith(`..${path.sep}`) || r.startsWith("../") || path.isAbsolute(r);
324
- if (rel === "") throw new Error(`build.include: "${inc}" is the project directory`);
337
+ if (rel === "") throw new Error(`${label}: "${inc}" is the project directory`);
325
338
  if (isOutside(rel)) {
326
- throw new Error(`build.include: "${inc}" is outside the project directory`);
339
+ throw new Error(`${label}: "${inc}" is outside the project directory`);
327
340
  }
328
341
  // Neither the output directory nor anything that contains it or is in it.
329
342
  if (!isOutside(path.relative(src, outDir)) || !isOutside(path.relative(outDir, src))) {
330
- throw new Error(`build.include: "${inc}" overlaps the output directory`);
343
+ throw new Error(`${label}: "${inc}" overlaps the output directory`);
331
344
  }
332
345
  const top = rel.split(/[\\/]/)[0];
333
346
  if (RESERVED_INCLUDE_NAMES.has(top)) {
334
- throw new Error(`build.include: "${inc}" would write into the artifact's own "${top}"`);
347
+ throw new Error(`${label}: "${inc}" would write into the artifact's own "${top}"`);
335
348
  }
336
349
  return rel;
337
350
  }
@@ -1,6 +1,6 @@
1
1
  /**
2
- * `ctx.shards.ticket` — the frame the runtime sends to the host, and the
3
- * ticket string it returns. The host signs (it holds the secret); this
2
+ * `ctx.shards` — the frames the runtime sends to the host (`ticket`,
3
+ * `create`, `stop`, `get`, `list`) and the values it returns. The host signs (it holds the secret); this
4
4
  * checks the TS half. Same child-process NDJSON harness as
5
5
  * runtime-email.test.ts.
6
6
  */
@@ -18,24 +18,16 @@ function parseFrames(text: string): Record<string, unknown>[] {
18
18
  .map((l) => JSON.parse(l) as Record<string, unknown>);
19
19
  }
20
20
 
21
- test("ctx.shards.ticket sends sign_shard_ticket and returns the host's ticket", async () => {
22
- const dir = mkdtempSync(join(tmpdir(), "pylon-fn-ticket-"));
21
+ /** Run one function in a real runtime child; `host` answers its frames. */
22
+ async function runFunction(
23
+ source: string,
24
+ host: (frame: Record<string, unknown>) => unknown,
25
+ hostFrameTypes: string[],
26
+ fnType: "mutation" | "action" = "mutation",
27
+ ): Promise<{ value: unknown; hostFrames: Record<string, unknown>[] }> {
28
+ const dir = mkdtempSync(join(tmpdir(), "pylon-fn-shards-"));
23
29
  mkdirSync(join(dir, "functions"));
24
- writeFileSync(
25
- join(dir, "functions", "join.ts"),
26
- `export default {
27
- type: "mutation",
28
- handler: async (ctx) => {
29
- const ticket = await ctx.shards.ticket("zone-3", {
30
- subscriberId: "char_12",
31
- claims: { realm: "north" },
32
- ttlSecs: 30,
33
- });
34
- return { ticket };
35
- },
36
- };
37
- `,
38
- );
30
+ writeFileSync(join(dir, "functions", "run.ts"), source);
39
31
  const proc = Bun.spawn([process.execPath, RUNTIME, "./functions"], {
40
32
  cwd: dir,
41
33
  stdin: "pipe",
@@ -45,43 +37,101 @@ test("ctx.shards.ticket sends sign_shard_ticket and returns the host's ticket",
45
37
  const reader = (proc.stdout as ReadableStream<Uint8Array>).getReader();
46
38
  const decoder = new TextDecoder();
47
39
  let buffered = "";
48
- const readUntil = async (pred: (f: Record<string, unknown>[]) => boolean) => {
49
- let frames = parseFrames(buffered);
50
- while (!pred(frames)) {
40
+ let consumed = 0;
41
+ const next = async (): Promise<Record<string, unknown>> => {
42
+ for (;;) {
43
+ const frames = parseFrames(buffered);
44
+ if (frames.length > consumed) return frames[consumed++];
51
45
  const { done, value } = await reader.read();
52
- if (done) break;
46
+ if (done) throw new Error("runtime exited");
53
47
  buffered += decoder.decode(value, { stream: true });
54
- frames = parseFrames(buffered);
55
48
  }
56
- return frames;
57
49
  };
58
50
  const send = (msg: Record<string, unknown>) =>
59
51
  (proc.stdin as import("bun").FileSink).write(JSON.stringify(msg) + "\n");
60
52
 
61
- await readUntil((fs) => fs.some((f) => f.type === "ready"));
53
+ while ((await next()).type !== "ready");
62
54
  send({
63
55
  type: "call",
64
56
  call_id: "c_1",
65
- fn_name: "join",
66
- fn_type: "mutation",
57
+ fn_name: "run",
58
+ fn_type: fnType,
67
59
  args: {},
68
60
  auth: { user_id: "u1", is_admin: false, tenant_id: null },
69
61
  });
62
+ const hostFrames: Record<string, unknown>[] = [];
63
+ try {
64
+ for (;;) {
65
+ const frame = await next();
66
+ if (frame.type === "return") return { value: frame.value, hostFrames };
67
+ if (frame.type === "error") throw new Error(JSON.stringify(frame));
68
+ if (hostFrameTypes.includes(frame.type as string)) {
69
+ hostFrames.push(frame);
70
+ send({ type: "result", call_id: "c_1", data: host(frame) });
71
+ }
72
+ }
73
+ } finally {
74
+ proc.kill();
75
+ }
76
+ }
70
77
 
71
- let frames = await readUntil((fs) => fs.some((f) => f.type === "sign_shard_ticket"));
72
- expect(frames.find((f) => f.type === "sign_shard_ticket")).toMatchObject({
78
+ test("ctx.shards.ticket sends sign_shard_ticket and returns the host's ticket", async () => {
79
+ const { value, hostFrames } = await runFunction(
80
+ `export default {
81
+ type: "mutation",
82
+ handler: async (ctx) => {
83
+ const ticket = await ctx.shards.ticket("zone-3", {
84
+ subscriberId: "char_12",
85
+ claims: { realm: "north" },
86
+ ttlSecs: 30,
87
+ });
88
+ return { ticket };
89
+ },
90
+ };
91
+ `,
92
+ () => "v1.payload.sig",
93
+ ["sign_shard_ticket"],
94
+ );
95
+ expect(hostFrames[0]).toMatchObject({
73
96
  call_id: "c_1",
74
97
  shard: "zone-3",
75
98
  subscriber_id: "char_12",
76
99
  claims: { realm: "north" },
77
100
  ttl_secs: 30,
78
101
  });
79
- send({ type: "result", call_id: "c_1", data: "v1.payload.sig" });
102
+ expect(value).toEqual({ ticket: "v1.payload.sig" });
103
+ });
80
104
 
81
- frames = await readUntil((fs) => fs.some((f) => f.type === "return"));
82
- expect(frames.find((f) => f.type === "return")).toMatchObject({
83
- call_id: "c_1",
84
- value: { ticket: "v1.payload.sig" },
85
- });
86
- proc.kill();
105
+ test("ctx.shards.create/get/list/stop in an action send shard_op frames", async () => {
106
+ const info = { id: "m1", kind: "arena", tick: 0, subscribers: 0, running: true };
107
+ const { value, hostFrames } = await runFunction(
108
+ `export default {
109
+ type: "action",
110
+ handler: async (ctx) => {
111
+ const created = await ctx.shards.create("arena", "m1", { fog: true });
112
+ const got = await ctx.shards.get("m1");
113
+ const all = await ctx.shards.list();
114
+ const stopped = await ctx.shards.stop("m1");
115
+ return { created, got, all, stopped };
116
+ },
117
+ };
118
+ `,
119
+ (frame) => {
120
+ switch (frame.op) {
121
+ case "create":
122
+ case "get":
123
+ return info;
124
+ case "list":
125
+ return [info];
126
+ default:
127
+ return true;
128
+ }
129
+ },
130
+ ["shard_op"],
131
+ "action",
132
+ );
133
+ expect(hostFrames.map((f) => f.op)).toEqual(["create", "get", "list", "stop"]);
134
+ expect(hostFrames[0]).toMatchObject({ kind: "arena", id: "m1", params: { fog: true } });
135
+ expect(hostFrames[3]).toMatchObject({ id: "m1" });
136
+ expect(value).toEqual({ created: info, got: info, all: [info], stopped: true });
87
137
  });
package/src/runtime.ts CHANGED
@@ -23,6 +23,7 @@ import type {
23
23
  EmailSender,
24
24
  Files,
25
25
  Shards,
26
+ ShardInfo,
26
27
  Stream,
27
28
  Scheduler,
28
29
  Llm,
@@ -535,6 +536,24 @@ function buildShards(callId: string): Shards {
535
536
  ttl_secs: opts?.ttlSecs,
536
537
  }) as Promise<string>;
537
538
  },
539
+ async create(kind, shardId, params) {
540
+ return rpc(callId, {
541
+ type: "shard_op",
542
+ op: "create",
543
+ kind,
544
+ id: shardId,
545
+ params: params ?? {},
546
+ }) as Promise<ShardInfo>;
547
+ },
548
+ async stop(shardId) {
549
+ return rpc(callId, { type: "shard_op", op: "stop", id: shardId }) as Promise<boolean>;
550
+ },
551
+ async get(shardId) {
552
+ return rpc(callId, { type: "shard_op", op: "get", id: shardId }) as Promise<ShardInfo | null>;
553
+ },
554
+ async list() {
555
+ return rpc(callId, { type: "shard_op", op: "list" }) as Promise<ShardInfo[]>;
556
+ },
538
557
  };
539
558
  }
540
559
 
package/src/types.ts CHANGED
@@ -861,6 +861,45 @@ export interface Shards {
861
861
  shardId: string,
862
862
  opts?: { subscriberId?: string; claims?: Record<string, unknown>; ttlSecs?: number },
863
863
  ): Promise<string>;
864
+
865
+ /**
866
+ * Start shard `shardId` of a kind declared with `shard({...})` in app.ts.
867
+ * `params` reach the module's `init`. Actions only: a mutation's
868
+ * rollback cannot undo it.
869
+ *
870
+ * Throws `SHARD_EXISTS` when the id is running, `SHARD_LIMIT_REACHED`
871
+ * at the kind's `maxInstances`, `SHARD_KIND_NOT_FOUND`,
872
+ * `SHARD_ID_INVALID`, or `SHARD_INIT_FAILED` when `init` refuses.
873
+ */
874
+ create(kind: string, shardId: string, params?: unknown): Promise<ShardInfo>;
875
+
876
+ /** Stop a shard and close its subscribers' connections. Resolves to
877
+ * `false` when no shard has that id. Actions only. */
878
+ stop(shardId: string): Promise<boolean>;
879
+
880
+ /** A running shard, or `null`. */
881
+ get(shardId: string): Promise<ShardInfo | null>;
882
+
883
+ /** Every running shard. */
884
+ list(): Promise<ShardInfo[]>;
885
+ }
886
+
887
+ /** `ctx.shards` in a query or mutation: tickets and reads, no start or stop. */
888
+ export type ShardsReader = Pick<Shards, "ticket" | "get" | "list">;
889
+
890
+ /** A running shard, from `ctx.shards.create`, `get`, or `list`. */
891
+ export interface ShardInfo {
892
+ id: string;
893
+ /** The shard kind's name. */
894
+ kind: string;
895
+ /** Ticks run so far. */
896
+ tick: number;
897
+ subscribers: number;
898
+ /** False once the shard stopped (finished, idle, or failed) and before
899
+ * the host removes it. */
900
+ running: boolean;
901
+ /** Why the module stopped, when it trapped. */
902
+ error?: string;
864
903
  }
865
904
 
866
905
  /** Context for query handlers (read-only).
@@ -888,8 +927,8 @@ export interface QueryCtx<R extends AuthRequirement = "optional"> {
888
927
  requireMember: RequireMember;
889
928
  /** Signed file-download URLs — see {@link Files}. */
890
929
  files: Files;
891
- /** Shard tickets — see {@link Shards}. */
892
- shards: Shards;
930
+ /** Shard tickets and reads — see {@link Shards}. */
931
+ shards: ShardsReader;
893
932
  /**
894
933
  * Fires when the host cancels this call (idle timeout exceeded).
895
934
  * Thread it into `fetch(url, { signal: ctx.signal })` or SDK calls so
@@ -918,8 +957,8 @@ export interface MutationCtx<R extends AuthRequirement = "optional"> {
918
957
  workflows: Workflows;
919
958
  /** Signed file-download URLs — see {@link Files}. */
920
959
  files: Files;
921
- /** Shard tickets — see {@link Shards}. */
922
- shards: Shards;
960
+ /** Shard tickets and reads — see {@link Shards}. */
961
+ shards: ShardsReader;
923
962
  /** Create a typed error that triggers rollback. */
924
963
  error(code: string, message: string): Error;
925
964
  /** Assert org membership (optionally a role) — see {@link RequireMember}. */
@@ -1093,7 +1132,7 @@ export interface ActionCtx<R extends AuthRequirement = "optional"> {
1093
1132
  env: Record<string, string>;
1094
1133
  /** Signed file-download URLs — see {@link Files}. */
1095
1134
  files: Files;
1096
- /** Shard tickets — see {@link Shards}. */
1135
+ /** Shard tickets, reads, and start/stop — see {@link Shards}. */
1097
1136
  shards: Shards;
1098
1137
  /** Run a registered query within its own read transaction. */
1099
1138
  runQuery<T = unknown>(