@pylonsync/functions 0.22.40 → 0.22.41

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
@@ -27,4 +27,4 @@ export type { WorkflowDefinition, WorkflowRun, WorkflowRunRequest, WorkflowRunne
27
27
  export { resetDb, installTestIsolation } from "./testing";
28
28
  export { slugifyName, availableSlug } from "./slugify";
29
29
  export type { SsrResponse, SsrCookieOptions, SsrMetadata, Sitemap, SitemapEntry, Robots, RobotsRule, } from "./ssr-runtime";
30
- export type { QueryCtx, MutationCtx, ActionCtx, DbReader, DbWriter, Stream, Scheduler, AuthInfo, AuthMode, AuthRequirement, FnDefinition, Validator, AnyValidator, ValidatorSchema, InferValidator, InferArgs, RequireMember, RequireMemberOptions, Shards, ShardsReader, ShardsWriter, ShardInfo, ShardTransfer, MemberRow, Workflows, WorkflowRunSummary, WorkflowRunStatus, Audit, AuditLog, AuditEntry, Agents, AgentRunOptions, VectorSearchQuery, VectorSearchResult, SearchResult, PaginationResult, Llm, LlmMessage, LlmContentBlock, LlmTool, LlmCompleteRequest, LlmCompleteResponse, LlmStreamEvent, Rooms, Domains, TenantDomainResult, TenantDomainRecord, TenantDomainDns, DomainAvailability, DomainContact, RegisterDomainOptions, RegisteredDomainResult, Files, ActionFiles, StoreFileOptions, StoredFile, } from "./types";
30
+ export type { QueryCtx, MutationCtx, ActionCtx, DbReader, DbWriter, Stream, Scheduler, AuthInfo, AuthMode, AuthRequirement, FnDefinition, Validator, AnyValidator, ValidatorSchema, InferValidator, InferArgs, RequireMember, RequireMemberOptions, Shards, ShardsReader, ShardsWriter, ShardInfo, ShardTransfer, MemberRow, Workflows, WorkflowRunSummary, WorkflowRunStatus, Audit, AuditLog, AuditEntry, Agents, AgentRunOptions, VectorSearchQuery, VectorSearchResult, SearchResult, PaginationResult, Llm, LlmMessage, LlmContentBlock, LlmTool, LlmCompleteRequest, LlmCompleteResponse, LlmStreamEvent, Rooms, Domains, TenantDomainResult, TenantDomainRecord, TenantDomainDns, DomainAvailability, DomainContact, RegisterDomainOptions, RegisteredDomainResult, Files, ActionFiles, Crdt, StoreFileOptions, StoredFile, } from "./types";
package/dist/types.d.ts CHANGED
@@ -949,6 +949,29 @@ export type RequireMember = (orgId: string, opts?: RequireMemberOptions) => Prom
949
949
  * });
950
950
  * ```
951
951
  */
952
+ /**
953
+ * `ctx.crdt` (actions only): read and change a row's live CRDT document, the
954
+ * one editors bind to with `@pylonsync/loro`. Bytes are Loro's own formats.
955
+ * `@pylonsync/loro/server` wraps this in a `LoroDoc` (`openLiveDoc`).
956
+ *
957
+ * Each `apply` is its own write: it merges, then reaches everyone editing at
958
+ * once, so it cannot roll back with a mutation (queries and mutations get
959
+ * CRDT_ACTIONS_ONLY). Policies follow the `ctx.db` rule: they apply under
960
+ * strict function policies (`PYLON_STRICT_FN_POLICIES=1`), except through
961
+ * `ctx.crdt.unsafe`. Without strict policies, check access yourself.
962
+ */
963
+ export interface Crdt {
964
+ /** The row's whole document, or null when the row does not exist (or, under
965
+ * strict policies, the caller may not read it). */
966
+ snapshot(entity: string, id: string): Promise<Uint8Array | null>;
967
+ /** The changes since `version` (a Loro version vector, `doc.oplogVersion().encode()`),
968
+ * or null as for {@link Crdt.snapshot}. */
969
+ since(entity: string, id: string, version: Uint8Array): Promise<Uint8Array | null>;
970
+ /** Merge a Loro update into the row and broadcast it. */
971
+ apply(entity: string, id: string, update: Uint8Array): Promise<void>;
972
+ /** The same operations with server trust even under strict policies. */
973
+ unsafe: Omit<Crdt, "unsafe">;
974
+ }
952
975
  export interface Files {
953
976
  /**
954
977
  * Mint a signed download path for a file id. `ttlSecs` defaults to 300
@@ -1422,6 +1445,8 @@ export interface ActionCtx<R extends AuthRequirement = "optional"> {
1422
1445
  env: Record<string, string>;
1423
1446
  /** Signed file-download URLs — see {@link Files}. */
1424
1447
  files: ActionFiles;
1448
+ /** A row's live CRDT document, as Loro bytes — see {@link Crdt}. */
1449
+ crdt: Crdt;
1425
1450
  /** Shard tickets, reads, and start/stop — see {@link Shards}. */
1426
1451
  shards: Shards;
1427
1452
  /** Run a registered query within its own read transaction. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/functions",
3
- "version": "0.22.40",
3
+ "version": "0.22.41",
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
@@ -117,6 +117,7 @@ export type {
117
117
  // File storage from functions.
118
118
  Files,
119
119
  ActionFiles,
120
+ Crdt,
120
121
  StoreFileOptions,
121
122
  StoredFile,
122
123
  } from "./types";
package/src/runtime.ts CHANGED
@@ -24,6 +24,7 @@ import type {
24
24
  EmailSender,
25
25
  Files,
26
26
  ActionFiles,
27
+ Crdt,
27
28
  StoredFile,
28
29
  Shards,
29
30
  ShardInfo,
@@ -673,6 +674,36 @@ function buildActionFiles(callId: string): ActionFiles {
673
674
  * FILES_STORE_MAX_BYTES; `PYLON_MAX_UPLOAD_BYTES` can lower the host's. */
674
675
  const FILES_STORE_MAX_BYTES = 25 * 1024 * 1024;
675
676
 
677
+ /** `ctx.crdt` — see {@link Crdt}. */
678
+ function buildCrdt(callId: string): Crdt {
679
+ const ops = (unsafeOp: boolean): Omit<Crdt, "unsafe"> => {
680
+ const call = (entity: string, id: string, op: Record<string, unknown>) =>
681
+ rpcDb(callId, { type: "crdt_op", entity, id, unsafe_op: unsafeOp, ...op }) as Promise<
682
+ Record<string, string> | null
683
+ >;
684
+ return {
685
+ async snapshot(entity, id) {
686
+ const res = await call(entity, id, { op: "snapshot" });
687
+ return res ? fromBase64(res.snapshot) : null;
688
+ },
689
+ async since(entity, id, version) {
690
+ const res = await call(entity, id, { op: "since", version_base64: toBase64(version) });
691
+ return res ? fromBase64(res.update) : null;
692
+ },
693
+ async apply(entity, id, update) {
694
+ await call(entity, id, { op: "apply", update_base64: toBase64(update) });
695
+ },
696
+ };
697
+ };
698
+ return { ...ops(false), unsafe: ops(true) };
699
+ }
700
+
701
+ /** Bytes of a base64 string. */
702
+ function fromBase64(data: string): Uint8Array {
703
+ const buf = Buffer.from(data, "base64");
704
+ return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
705
+ }
706
+
676
707
  /** Base64 of bytes, or of a string's UTF-8 encoding. */
677
708
  function toBase64(data: Uint8Array | ArrayBuffer | string): string {
678
709
  const bytes =
@@ -1445,6 +1476,7 @@ function buildActionCtx(
1445
1476
  return err;
1446
1477
  },
1447
1478
  files: buildActionFiles(callId),
1479
+ crdt: buildCrdt(callId),
1448
1480
  shards: buildShards(callId),
1449
1481
  // Actions have no ctx.db; read membership via the built-in internal query.
1450
1482
  requireMember: makeRequireMember(auth.userId, (entity, filter) =>
@@ -1640,6 +1672,9 @@ async function handleCall(msg: CallMessage): Promise<void> {
1640
1672
  // so a call from a query or mutation gets the host's
1641
1673
  // FILES_WRITE_NOT_ALLOWED instead of a TypeError.
1642
1674
  files: buildActionFiles(msg.call_id),
1675
+ // Not on the query or mutation type; present at runtime so a call
1676
+ // gets the host's CRDT_ACTIONS_ONLY instead of a TypeError.
1677
+ crdt: buildCrdt(msg.call_id),
1643
1678
  shards: buildShards(msg.call_id),
1644
1679
  };
1645
1680
  break;
@@ -1669,6 +1704,9 @@ async function handleCall(msg: CallMessage): Promise<void> {
1669
1704
  // so a call from a query or mutation gets the host's
1670
1705
  // FILES_WRITE_NOT_ALLOWED instead of a TypeError.
1671
1706
  files: buildActionFiles(msg.call_id),
1707
+ // Not on the query or mutation type; present at runtime so a call
1708
+ // gets the host's CRDT_ACTIONS_ONLY instead of a TypeError.
1709
+ crdt: buildCrdt(msg.call_id),
1672
1710
  shards: buildShards(msg.call_id),
1673
1711
  };
1674
1712
  break;
@@ -395,6 +395,33 @@ export default function Doc() {
395
395
  });
396
396
  });
397
397
 
398
+ describe("assets a stylesheet references", () => {
399
+ // The asset plugin answers JavaScript imports with a JavaScript module; a
400
+ // stylesheet's url(...) must stay with Bun, or the build fails with
401
+ // "Cannot import a .js file into a CSS file".
402
+ test("a page importing CSS that uses url() still builds", async () => {
403
+ tempDir = makeFixture(
404
+ {
405
+ "page.tsx": `
406
+ import React from "react";
407
+ import "./look.css";
408
+ export default function Home() {
409
+ return <div className="hero">home</div>;
410
+ }
411
+ `,
412
+ },
413
+ { "layout.tsx": LAYOUT_BODY },
414
+ );
415
+ fs.writeFileSync(path.join(tempDir, "app", "look.css"), ".hero { background: url(./bg.png); }\n");
416
+ fs.writeFileSync(path.join(tempDir, "app", "bg.png"), new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]));
417
+ originalCwd = process.cwd();
418
+ process.chdir(tempDir);
419
+
420
+ const { manifestPath } = await buildClientBundle();
421
+ expect(fs.existsSync(manifestPath)).toBe(true);
422
+ });
423
+ });
424
+
398
425
  describe("nearestBoundaryComponent (client boundary resolution)", () => {
399
426
  const routes = new Set([
400
427
  "web/app/not-found",
@@ -93,7 +93,7 @@ type BunBuildOutput = {
93
93
  type BunPluginBuild = {
94
94
  onResolve(
95
95
  opts: { filter: RegExp; namespace?: string },
96
- callback: (args: { path: string; importer: string }) => void,
96
+ callback: (args: { path: string; importer: string }) => { path: string; namespace: string } | undefined | void,
97
97
  ): void;
98
98
  onLoad(
99
99
  opts: { filter: RegExp; namespace?: string },
@@ -2081,41 +2081,23 @@ function makeBuildId(): string {
2081
2081
  return `${process.pid.toString(36)}-${Date.now().toString(36)}-${_buildCounter}`;
2082
2082
  }
2083
2083
 
2084
- /**
2085
- * Base URL/path prepended to every SSR-emitted asset reference (the entry
2086
- * `<script type="module">`, `<link rel="stylesheet">`, and `modulepreload`s)
2087
- * and to client-side chunk loads — baked into the bundle manifest as
2088
- * `public_prefix`.
2089
- *
2090
- * Defaults to `/_pylon/build/`, which the Rust host serves from the build's
2091
- * local `outdir`. That keeps dev and any deploy that doesn't opt in unchanged.
2092
- *
2093
- * Set `PYLON_PUBLIC_PREFIX` to an ABSOLUTE base (a CDN / object-storage URL for
2094
- * this build's content-hashed assets, e.g. `https://assets.pyln.dev/<build>/`)
2095
- * to serve assets off the app machines entirely. This is what makes hashed
2096
- * assets survive an artifact prune and resolve IDENTICALLY across every Fly
2097
- * machine: the entry filename SSR emits becomes an absolute URL that always
2098
- * exists, instead of a machine-local path a prune/rollover can 404 (the bug
2099
- * where a stale machine emitted `client-entry-…-<oldhash>.js` after that file
2100
- * was pruned, breaking hydration). A trailing slash is enforced so
2101
- * `${prefix}${file}` always joins correctly.
2102
- */
2103
- /** Where the Rust host serves the client build (`/_pylon/build/<path>`). The
2104
- * manifest's `public_prefix` and the bundler's `publicPath` must agree. */
2084
+ /** Where the Rust host serves the client build (`/_pylon/build/<path>`): the
2085
+ * manifest's `public_prefix`, and the prefix of imported assets' URLs. */
2105
2086
  const BUILD_PREFIX = "/_pylon/build/";
2106
2087
 
2107
2088
  /** Files a page can import as a URL (Bun's `file` loader). */
2108
2089
  const ASSET_RE = /\.(wasm|png|jpe?g|gif|webp|avif|ico|svg|woff2?|ttf|otf|mp3|mp4|webm|wav|ogg)$/i;
2109
2090
 
2110
2091
  /**
2111
- * Imported assets (a `.wasm`, an image) as absolute URLs under the build
2112
- * prefix. Bun's own `file` loader exports a RELATIVE path ("assets/x.wasm"),
2092
+ * Assets JavaScript imports (a `.wasm`, an image) as absolute URLs under the
2093
+ * build prefix. Bun's `file` loader exports a RELATIVE path ("assets/x.wasm"),
2113
2094
  * and code that fetches it resolves it against the page: loro-crdt's browser
2114
2095
  * build fetched /dashboard/orgs/acme/assets/loro_wasm_bg-….wasm and 404'd.
2115
2096
  * Bun's `publicPath` would make it absolute, but it also rewrites imports
2116
2097
  * between split chunks and drops their `chunks/` directory (Bun 1.3.14), so
2117
2098
  * this plugin writes the file itself, named by its content hash, and exports
2118
- * the absolute URL.
2099
+ * the absolute URL. A stylesheet's `url(...)` is left to Bun: CSS cannot
2100
+ * import a JavaScript module.
2119
2101
  */
2120
2102
  function absoluteAssetPlugin(
2121
2103
  outdir: string,
@@ -2123,10 +2105,19 @@ function absoluteAssetPlugin(
2123
2105
  path: typeof import("node:path"),
2124
2106
  createHash: typeof import("node:crypto").createHash,
2125
2107
  ): { name: string; setup(build: BunPluginBuild): void } {
2108
+ const NAMESPACE = "pylon-asset";
2126
2109
  return {
2127
2110
  name: "pylon-absolute-assets",
2128
2111
  setup(build) {
2129
- build.onLoad({ filter: ASSET_RE }, (args) => {
2112
+ build.onResolve({ filter: ASSET_RE }, (args) => {
2113
+ if (!args.importer || /\.css$/i.test(args.importer)) return undefined;
2114
+ const from = path.dirname(args.importer);
2115
+ const resolved = args.path.startsWith(".") || path.isAbsolute(args.path)
2116
+ ? path.resolve(from, args.path)
2117
+ : ((Bun as any).resolveSync(args.path, from) as string);
2118
+ return { path: resolved, namespace: NAMESPACE };
2119
+ });
2120
+ build.onLoad({ filter: /.*/, namespace: NAMESPACE }, (args) => {
2130
2121
  const bytes = fs.readFileSync(args.path);
2131
2122
  const hash = createHash("sha256").update(bytes).digest("hex").slice(0, 12);
2132
2123
  const ext = path.extname(args.path);
@@ -2139,6 +2130,25 @@ function absoluteAssetPlugin(
2139
2130
  };
2140
2131
  }
2141
2132
 
2133
+ /**
2134
+ * Base URL/path prepended to every SSR-emitted asset reference (the entry
2135
+ * `<script type="module">`, `<link rel="stylesheet">`, and `modulepreload`s)
2136
+ * and to client-side chunk loads — baked into the bundle manifest as
2137
+ * `public_prefix`.
2138
+ *
2139
+ * Defaults to `/_pylon/build/`, which the Rust host serves from the build's
2140
+ * local `outdir`. That keeps dev and any deploy that doesn't opt in unchanged.
2141
+ *
2142
+ * Set `PYLON_PUBLIC_PREFIX` to an ABSOLUTE base (a CDN / object-storage URL for
2143
+ * this build's content-hashed assets, e.g. `https://assets.pyln.dev/<build>/`)
2144
+ * to serve assets off the app machines entirely. This is what makes hashed
2145
+ * assets survive an artifact prune and resolve IDENTICALLY across every Fly
2146
+ * machine: the entry filename SSR emits becomes an absolute URL that always
2147
+ * exists, instead of a machine-local path a prune/rollover can 404 (the bug
2148
+ * where a stale machine emitted `client-entry-…-<oldhash>.js` after that file
2149
+ * was pruned, breaking hydration). A trailing slash is enforced so
2150
+ * `${prefix}${file}` always joins correctly.
2151
+ */
2142
2152
  function resolvePublicPrefix(): string {
2143
2153
  const raw = (process.env.PYLON_PUBLIC_PREFIX ?? "").trim();
2144
2154
  if (!raw) return "/_pylon/build/";
package/src/types.ts CHANGED
@@ -1044,6 +1044,30 @@ export type RequireMember = (
1044
1044
  * });
1045
1045
  * ```
1046
1046
  */
1047
+ /**
1048
+ * `ctx.crdt` (actions only): read and change a row's live CRDT document, the
1049
+ * one editors bind to with `@pylonsync/loro`. Bytes are Loro's own formats.
1050
+ * `@pylonsync/loro/server` wraps this in a `LoroDoc` (`openLiveDoc`).
1051
+ *
1052
+ * Each `apply` is its own write: it merges, then reaches everyone editing at
1053
+ * once, so it cannot roll back with a mutation (queries and mutations get
1054
+ * CRDT_ACTIONS_ONLY). Policies follow the `ctx.db` rule: they apply under
1055
+ * strict function policies (`PYLON_STRICT_FN_POLICIES=1`), except through
1056
+ * `ctx.crdt.unsafe`. Without strict policies, check access yourself.
1057
+ */
1058
+ export interface Crdt {
1059
+ /** The row's whole document, or null when the row does not exist (or, under
1060
+ * strict policies, the caller may not read it). */
1061
+ snapshot(entity: string, id: string): Promise<Uint8Array | null>;
1062
+ /** The changes since `version` (a Loro version vector, `doc.oplogVersion().encode()`),
1063
+ * or null as for {@link Crdt.snapshot}. */
1064
+ since(entity: string, id: string, version: Uint8Array): Promise<Uint8Array | null>;
1065
+ /** Merge a Loro update into the row and broadcast it. */
1066
+ apply(entity: string, id: string, update: Uint8Array): Promise<void>;
1067
+ /** The same operations with server trust even under strict policies. */
1068
+ unsafe: Omit<Crdt, "unsafe">;
1069
+ }
1070
+
1047
1071
  export interface Files {
1048
1072
  /**
1049
1073
  * Mint a signed download path for a file id. `ttlSecs` defaults to 300
@@ -1542,6 +1566,8 @@ export interface ActionCtx<R extends AuthRequirement = "optional"> {
1542
1566
  env: Record<string, string>;
1543
1567
  /** Signed file-download URLs — see {@link Files}. */
1544
1568
  files: ActionFiles;
1569
+ /** A row's live CRDT document, as Loro bytes — see {@link Crdt}. */
1570
+ crdt: Crdt;
1545
1571
  /** Shard tickets, reads, and start/stop — see {@link Shards}. */
1546
1572
  shards: Shards;
1547
1573
  /** Run a registered query within its own read transaction. */