@voidbase-cloud/voidbase 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.
Files changed (134) hide show
  1. package/.env.example +9 -0
  2. package/CHANGELOG.md +19 -0
  3. package/COMPAT.md +43 -0
  4. package/LICENSE +21 -0
  5. package/NOTICE +8 -0
  6. package/README.md +124 -0
  7. package/bin/voidbase.ts +158 -0
  8. package/crons/every-minute.ts +13 -0
  9. package/db/migrations/20260905175935_large_swarm.sql +87 -0
  10. package/db/migrations/20260905185720_wild_sunspot.sql +16 -0
  11. package/db/migrations/20260905190723_solid_toro.sql +1 -0
  12. package/db/migrations/20260905213340_remarkable_union_jack.sql +11 -0
  13. package/db/migrations/meta/20260905175935_snapshot.json +599 -0
  14. package/db/migrations/meta/20260905185720_snapshot.json +703 -0
  15. package/db/migrations/meta/20260905190723_snapshot.json +710 -0
  16. package/db/migrations/meta/20260905213340_snapshot.json +781 -0
  17. package/db/migrations/meta/_journal.json +34 -0
  18. package/db/schema.ts +130 -0
  19. package/docs/deploy.md +153 -0
  20. package/docs/differences.md +88 -0
  21. package/docs/hooks.md +84 -0
  22. package/docs/migrating.md +29 -0
  23. package/docs/perf.md +53 -0
  24. package/docs/platform.md +208 -0
  25. package/docs/releasing.md +38 -0
  26. package/env.ts +23 -0
  27. package/hooks-plugin.ts +237 -0
  28. package/package.json +134 -0
  29. package/queues/jobs.ts +13 -0
  30. package/routes/api/[...path].ts +19 -0
  31. package/scripts/bench-realtime.ts +46 -0
  32. package/scripts/bench.ts +39 -0
  33. package/scripts/ci-suites.sh +27 -0
  34. package/scripts/dev.sh +29 -0
  35. package/scripts/export.ts +70 -0
  36. package/scripts/seed-app-user.sh +14 -0
  37. package/scripts/seed-d1.ts +17 -0
  38. package/scripts/seed-reference.sh +29 -0
  39. package/scripts/starter.sh +22 -0
  40. package/scripts/sync-app.ts +22 -0
  41. package/scripts/sync-panel.ts +66 -0
  42. package/src/cloud/rest.ts +297 -0
  43. package/src/node/assets.ts +22 -0
  44. package/src/node/bundle.ts +88 -0
  45. package/src/node/cloud-init.ts +51 -0
  46. package/src/node/d1.ts +44 -0
  47. package/src/node/deploy-cf.ts +179 -0
  48. package/src/node/index.ts +5 -0
  49. package/src/node/panel.ts +21 -0
  50. package/src/node/serve.ts +125 -0
  51. package/src/node/storage.ts +51 -0
  52. package/src/platform/node/env.ts +4 -0
  53. package/src/platform/node/hooks.ts +19 -0
  54. package/src/platform/node/log.ts +7 -0
  55. package/src/platform/node/migrations.ts +5 -0
  56. package/src/platform/node/photon.ts +1 -0
  57. package/src/platform/node/sockets.ts +22 -0
  58. package/src/platform/node/sse.ts +23 -0
  59. package/src/platform/workers/env.ts +3 -0
  60. package/src/platform/workers/hooks.ts +2 -0
  61. package/src/platform/workers/log.ts +1 -0
  62. package/src/platform/workers/migrations.ts +1 -0
  63. package/src/platform/workers/photon.ts +1 -0
  64. package/src/platform/workers/sockets.ts +3 -0
  65. package/src/platform/workers/sse.ts +1 -0
  66. package/src/server/api.ts +27 -0
  67. package/src/server/app.ts +582 -0
  68. package/src/server/auth-extra.ts +113 -0
  69. package/src/server/auth-flows.ts +186 -0
  70. package/src/server/auth-response.ts +111 -0
  71. package/src/server/auth.ts +187 -0
  72. package/src/server/backups.ts +234 -0
  73. package/src/server/batch.ts +123 -0
  74. package/src/server/bootstrap.ts +71 -0
  75. package/src/server/collections/auth-option-shape.json +71 -0
  76. package/src/server/collections/ddl.ts +127 -0
  77. package/src/server/collections/fields.ts +120 -0
  78. package/src/server/collections/model.ts +185 -0
  79. package/src/server/collections/oauth2-providers.json +1 -0
  80. package/src/server/collections/scaffolds.json +210 -0
  81. package/src/server/collections/service.ts +392 -0
  82. package/src/server/collections/system.json +605 -0
  83. package/src/server/collections/system.ts +19 -0
  84. package/src/server/collections/validate.ts +239 -0
  85. package/src/server/crc32.ts +13 -0
  86. package/src/server/crons.ts +100 -0
  87. package/src/server/crypto.ts +26 -0
  88. package/src/server/db.ts +37 -0
  89. package/src/server/errors.ts +53 -0
  90. package/src/server/files-api.ts +52 -0
  91. package/src/server/filter/compile.ts +420 -0
  92. package/src/server/filter/lexer.ts +107 -0
  93. package/src/server/filter/parser.ts +49 -0
  94. package/src/server/hardening.ts +136 -0
  95. package/src/server/hooks/index.ts +147 -0
  96. package/src/server/hooks/migrations.ts +58 -0
  97. package/src/server/hooks/node-async-hooks.d.ts +7 -0
  98. package/src/server/hooks/record.ts +152 -0
  99. package/src/server/hooks/runtime.ts +344 -0
  100. package/src/server/hooks/virtual-migrations.d.ts +4 -0
  101. package/src/server/hooks/virtual.d.ts +7 -0
  102. package/src/server/hub.ts +91 -0
  103. package/src/server/ids.ts +22 -0
  104. package/src/server/jobs.ts +84 -0
  105. package/src/server/jwt.ts +61 -0
  106. package/src/server/logs.ts +144 -0
  107. package/src/server/mail/index.ts +99 -0
  108. package/src/server/mail/message.ts +43 -0
  109. package/src/server/mail/smtp.ts +82 -0
  110. package/src/server/mail/templates.ts +168 -0
  111. package/src/server/oauth2/index.ts +198 -0
  112. package/src/server/oauth2/providers.ts +153 -0
  113. package/src/server/password.ts +17 -0
  114. package/src/server/realtime/hub-client.ts +50 -0
  115. package/src/server/realtime/index.ts +239 -0
  116. package/src/server/records/expand.ts +129 -0
  117. package/src/server/records/files.ts +69 -0
  118. package/src/server/records/json.ts +23 -0
  119. package/src/server/records/picker.ts +80 -0
  120. package/src/server/records/service.ts +598 -0
  121. package/src/server/records/thumbs.ts +148 -0
  122. package/src/server/records/values.ts +295 -0
  123. package/src/server/settings-api.ts +104 -0
  124. package/src/server/settings.ts +215 -0
  125. package/src/server/sql.ts +61 -0
  126. package/src/server/static.ts +17 -0
  127. package/src/server/storage/s3.ts +118 -0
  128. package/src/server/types.ts +25 -0
  129. package/src/server/webauthn.ts +168 -0
  130. package/tsconfig.json +36 -0
  131. package/tsconfig.node.json +27 -0
  132. package/types/pb_data.d.ts +24438 -0
  133. package/vite.config.ts +10 -0
  134. package/void.json +12 -0
@@ -0,0 +1,34 @@
1
+ {
2
+ "version": "7",
3
+ "dialect": "sqlite",
4
+ "entries": [
5
+ {
6
+ "idx": 0,
7
+ "version": "6",
8
+ "when": 1788631175137,
9
+ "tag": "20260905175935_large_swarm",
10
+ "breakpoints": true
11
+ },
12
+ {
13
+ "idx": 1,
14
+ "version": "6",
15
+ "when": 1788634640757,
16
+ "tag": "20260905185720_wild_sunspot",
17
+ "breakpoints": true
18
+ },
19
+ {
20
+ "idx": 2,
21
+ "version": "6",
22
+ "when": 1788635243749,
23
+ "tag": "20260905190723_solid_toro",
24
+ "breakpoints": true
25
+ },
26
+ {
27
+ "idx": 3,
28
+ "version": "6",
29
+ "when": 1788644020730,
30
+ "tag": "20260905213340_remarkable_union_jack",
31
+ "breakpoints": true
32
+ }
33
+ ]
34
+ }
package/db/schema.ts ADDED
@@ -0,0 +1,130 @@
1
+ // System tables only. User collections are created at runtime from _collections rows (schema is data, as in PocketBase).
2
+ // This file exists so `void db generate` produces the bootstrap migration that `void deploy` applies.
3
+ import { index, integer, sqliteTable, text, uniqueIndex } from "void/schema-d1";
4
+ import { sql } from "void/db";
5
+
6
+ const now = sql`(strftime('%Y-%m-%d %H:%M:%fZ'))`;
7
+ const randomId = sql`('r'||lower(hex(randomblob(7))))`;
8
+
9
+ export const collections = sqliteTable(
10
+ "_collections",
11
+ {
12
+ id: text("id").primaryKey().notNull().default(randomId),
13
+ system: integer("system", { mode: "boolean" }).notNull().default(false),
14
+ type: text("type").notNull().default("base"),
15
+ name: text("name").notNull().unique(),
16
+ fields: text("fields").notNull().default("[]"),
17
+ indexes: text("indexes").notNull().default("[]"),
18
+ listRule: text("listRule"),
19
+ viewRule: text("viewRule"),
20
+ createRule: text("createRule"),
21
+ updateRule: text("updateRule"),
22
+ deleteRule: text("deleteRule"),
23
+ options: text("options").notNull().default("{}"),
24
+ created: text("created").notNull().default(now),
25
+ updated: text("updated").notNull().default(now),
26
+ },
27
+ (t) => [index("idx__collections_type").on(t.type)],
28
+ );
29
+
30
+ export const params = sqliteTable("_params", {
31
+ id: text("id").primaryKey().notNull().default(randomId),
32
+ value: text("value"),
33
+ created: text("created").notNull().default(now),
34
+ updated: text("updated").notNull().default(now),
35
+ });
36
+
37
+ // PocketBase-style JS migrations (pb_migrations) applied by the hooks runtime. Named _pbMigrations because Void keeps its own _migrations table.
38
+ export const migrations = sqliteTable("_pbMigrations", {
39
+ file: text("file").primaryKey().notNull(),
40
+ applied: integer("applied").notNull(),
41
+ });
42
+
43
+ // System auth collection: superusers (collection id pbc_3142635823 in PocketBase).
44
+ export const superusers = sqliteTable(
45
+ "_superusers",
46
+ {
47
+ id: text("id").primaryKey().notNull().default(randomId),
48
+ password: text("password").notNull().default(""),
49
+ tokenKey: text("tokenKey").notNull().default(""),
50
+ email: text("email").notNull().default(""),
51
+ emailVisibility: integer("emailVisibility", { mode: "boolean" }).notNull().default(false),
52
+ verified: integer("verified", { mode: "boolean" }).notNull().default(false),
53
+ created: text("created").notNull().default(""),
54
+ updated: text("updated").notNull().default(""),
55
+ },
56
+ (t) => [
57
+ uniqueIndex("idx_tokenKey_pbc_3142635823").on(t.tokenKey),
58
+ uniqueIndex("idx_email_pbc_3142635823").on(t.email).where(sql`${t.email} != ''`),
59
+ ],
60
+ );
61
+
62
+ const refCols = {
63
+ id: text("id").primaryKey().notNull().default(randomId),
64
+ collectionRef: text("collectionRef").notNull().default(""),
65
+ recordRef: text("recordRef").notNull().default(""),
66
+ created: text("created").notNull().default(""),
67
+ updated: text("updated").notNull().default(""),
68
+ };
69
+
70
+ export const mfas = sqliteTable("_mfas", { ...refCols, method: text("method").notNull().default("") }, (t) => [
71
+ index("idx_mfas_collectionRef_recordRef").on(t.collectionRef, t.recordRef),
72
+ ]);
73
+
74
+ export const otps = sqliteTable(
75
+ "_otps",
76
+ { ...refCols, password: text("password").notNull().default(""), sentTo: text("sentTo").notNull().default("") },
77
+ (t) => [index("idx_otps_collectionRef_recordRef").on(t.collectionRef, t.recordRef)],
78
+ );
79
+
80
+ export const externalAuths = sqliteTable(
81
+ "_externalAuths",
82
+ { ...refCols, provider: text("provider").notNull().default(""), providerId: text("providerId").notNull().default("") },
83
+ (t) => [
84
+ uniqueIndex("idx_externalAuths_record_provider").on(t.collectionRef, t.recordRef, t.provider),
85
+ uniqueIndex("idx_externalAuths_collection_provider").on(t.collectionRef, t.provider, t.providerId),
86
+ ],
87
+ );
88
+
89
+ export const authOrigins = sqliteTable(
90
+ "_authOrigins",
91
+ { ...refCols, fingerprint: text("fingerprint").notNull().default("") },
92
+ (t) => [uniqueIndex("idx_authOrigins_unique_pairs").on(t.collectionRef, t.recordRef, t.fingerprint)],
93
+ );
94
+
95
+ // Realtime change feed (decision d5): appended inside the record write batch, polled by open SSE streams.
96
+ export const changes = sqliteTable(
97
+ "_changes",
98
+ {
99
+ id: integer("id").primaryKey({ autoIncrement: true }),
100
+ collection: text("collection").notNull(),
101
+ recordId: text("recordId").notNull(),
102
+ action: text("action").notNull(), // create | update | delete
103
+ data: text("data"), // record JSON at the time of the change (deletes cannot be re-read)
104
+ created: text("created").notNull().default(now),
105
+ },
106
+ (t) => [index("idx__changes_collection").on(t.collection, t.id)],
107
+ );
108
+
109
+ // Connected SSE clients and their subscriptions (the POST can land on any isolate).
110
+ export const realtimeClients = sqliteTable("_realtime_clients", {
111
+ id: text("id").primaryKey().notNull(),
112
+ subscriptions: text("subscriptions").notNull().default("[]"),
113
+ token: text("token").notNull().default(""),
114
+ created: text("created").notNull().default(now),
115
+ updated: text("updated").notNull().default(now),
116
+ });
117
+
118
+ // Request and application logs (PocketBase's auxiliary _logs table): data is the JSON payload PocketBase's
119
+ // panel filters on (data.type, data.status, data.auth ...), level uses slog levels (-4 debug, 0 info, 4 warn, 8 error).
120
+ export const logs = sqliteTable(
121
+ "_logs",
122
+ {
123
+ id: text("id").primaryKey(),
124
+ created: text("created").notNull().default(""),
125
+ data: text("data").notNull().default("{}"),
126
+ message: text("message").notNull().default(""),
127
+ level: integer("level").notNull().default(0),
128
+ },
129
+ (t) => [index("idx_logs_created").on(t.created), index("idx_logs_level").on(t.level), index("idx_logs_message").on(t.message)],
130
+ );
package/docs/deploy.md ADDED
@@ -0,0 +1,153 @@
1
+ # Deploying voidbase
2
+
3
+ voidbase is a Void app: one Worker (with its realtime hub Durable Object inside), one D1 database, one R2 bucket, a
4
+ jobs queue and the cron triggers the hooks need. Void infers the bindings (`DB`, `STORAGE`, the queue from `queues/`) from the source and provisions them; the
5
+ system tables come from the checked-in Drizzle migrations in `db/migrations/`, and PocketBase-style `pb_migrations`
6
+ run on the first request.
7
+
8
+ ## Before the first deploy
9
+
10
+ ```bash
11
+ bun install
12
+ bun run panel:sync # unmodified PocketBase admin panel -> public/_ (see --brand in scripts/sync-panel.ts)
13
+ bun run app:sync # optional: a built SvelteKit/SPA app -> public/ (deep links get index.html as the 404 page)
14
+ bun run build # vp build; bundles pb_hooks and pb_migrations into the Worker
15
+ ```
16
+
17
+ Secrets and settings that must exist in production (declared in `env.ts`):
18
+
19
+ | Variable | Purpose |
20
+ | --- | --- |
21
+ | `VOIDBASE_SUPERUSER_EMAIL`, `VOIDBASE_SUPERUSER_PASSWORD` | first superuser, upserted on the first request. Remove or rotate after the first login |
22
+ | `VOIDBASE_ENCRYPTION_KEY` | optional; encrypts the settings row (SMTP password, OAuth2 secrets) at rest |
23
+ | `AUDITLOG` | only if your `pb_hooks` read it, like the starter's audit log |
24
+ | `VOIDBASE_ALERT_WEBHOOK_URL` | optional; every unhandled request error (HTTP 500) is POSTed there as JSON `{source, level, message, status, time, method, path, error, stack}` (Slack/Discord/PagerDuty-style receivers or your own endpoint) |
25
+ | `VOIDBASE_MAIL_HTTP_URL`, `VOIDBASE_MAIL_HTTP_KEY` | optional HTTP mail provider (Resend-compatible JSON endpoint + bearer key) used instead of SMTP for every email, including the panel's test email |
26
+
27
+ Everything else (SMTP, OAuth2 providers, rate limits, backups cron, trusted proxy) is configured from the
28
+ panel's Settings pages and stored in D1.
29
+
30
+ ## Go live on your Cloudflare account (primary path)
31
+
32
+ One API token, one command. Create the token with this link; it opens the Cloudflare dashboard's token wizard for
33
+ your account with the permissions voidbase needs already selected (Workers Scripts edit, D1 edit, Workers R2
34
+ Storage edit, Queues edit, Account Settings read):
35
+
36
+ [Create VOIDBASE_DEPLOY_CF_API_KEY](https://dash.cloudflare.com/?to=/:account/api-tokens&permissionGroupKeys=%5B%7B%22key%22%3A%22workers_scripts%22%2C%22type%22%3A%22edit%22%7D%2C%7B%22key%22%3A%22d1%22%2C%22type%22%3A%22edit%22%7D%2C%7B%22key%22%3A%22workers_r2%22%2C%22type%22%3A%22edit%22%7D%2C%7B%22key%22%3A%22queues%22%2C%22type%22%3A%22edit%22%7D%2C%7B%22key%22%3A%22account_settings%22%2C%22type%22%3A%22read%22%7D%5D&name=VOIDBASE_DEPLOY_CF_API_KEY)
37
+
38
+ Queues edit is optional: a token without it (one created before this permission was added) still deploys, the
39
+ deploy just skips the jobs queue and says so.
40
+
41
+ `voidbase token` prints the same link. Then, in the directory that holds `pb_hooks/` and `pb_migrations/`
42
+ (`voidbase-sveltekit-starter/vb` for the starter):
43
+
44
+ ```bash
45
+ export VOIDBASE_DEPLOY_CF_API_KEY=... # or put it in .env next to pb_hooks, or a CI secret
46
+ voidbase deploy --public-dir ../sk/build # --name <worker>, --account <id> when the token reaches several accounts
47
+ ```
48
+
49
+ What it does, in order: resolves the account through the token, creates `<name>-db` (D1), `<name>-storage`
50
+ (R2) and `<name>-jobs` (Queue) if they do not exist, writes the Void project inside the voidbase package
51
+ (`node_modules/voidbase/.cloud/<name>`, nothing appears in your tree) with a `wrangler.jsonc` carrying the real
52
+ ids and, when the directory has a `main.ts` exporting `register(app)`, composes it into the Worker; stores the
53
+ superuser as worker secrets (from `VOIDBASE_SUPERUSER_*` / `PB_SUPERUSER_*`, or a generated
54
+ password saved in `pb_data/.superuser-credentials`; the local dev default `changeme123` never goes live), syncs
55
+ the admin panel and your frontend build into that project, and runs `void deploy --backend cloudflare`,
56
+ which builds, applies the D1 migrations and uploads the Worker with its cron trigger. It ends with the
57
+ `https://<name>.<your-subdomain>.workers.dev` URL and a health check. Re-running is idempotent: existing resources
58
+ and credentials are reused. `--dry-run` does everything except install, secrets and the upload.
59
+
60
+ Quotas to know: the Workers Free plan allows 10 D1 databases per account (paid plans 50,000) and 5 cron
61
+ triggers per worker; voidbase needs one database, one bucket, one queue and the triggers its hooks declare (an
62
+ hourly tick without any). Cloudflare's own permission reference is at
63
+ https://developers.cloudflare.com/fundamentals/api/reference/permissions/ should the link's pre-selection ever
64
+ stop matching (the token then needs those permissions, picked by hand at
65
+ https://dash.cloudflare.com/?to=/:account/api-tokens).
66
+
67
+ ### A custom domain
68
+
69
+ `voidbase deploy --domain api.example.com` (or `VOIDBASE_DEPLOY_DOMAIN`) turns workers.dev off for the Worker and attaches
70
+ the hostname through the Workers Custom Domains API after the upload: Cloudflare creates the DNS record and the
71
+ certificate (a minute or two), and the token needs nothing beyond Workers Scripts edit, provided the zone is on the same
72
+ account. Cloudflare still requires the account to have a workers.dev subdomain before it accepts any upload (error
73
+ 10063): open Workers & Pages once, or `PUT /accounts/<id>/workers/subdomain {"subdomain": "<name>"}`.
74
+ `destroyInstance` in `voidbase/cloud` detaches custom domains before deleting the Worker.
75
+
76
+ ### What the deploy wires up, and the knobs
77
+
78
+ | Binding | What it does | Knob |
79
+ | --- | --- | --- |
80
+ | `<name>-jobs` queue (`queues/<name>-jobs.ts`) | outbound mail and automatic backups run from the queue with retries (30 s, 60 s, ... up to 15 min, five times, then dropped and posted to `VOIDBASE_ALERT_WEBHOOK_URL`). Requests never wait on SMTP. Without the queue everything runs inline, as on the Bun runtime | `--no-queue` / `VOIDBASE_DEPLOY_QUEUE=0`; skipped automatically when the token lacks Queues edit |
81
+ | `RATE_LIMITER` (Cloudflare rate-limit binding) | a ceiling per client IP on `/api`, counted per Cloudflare location across every isolate there, on top of the settings' rate-limit rules (which count per isolate). Applies only while rate limits are enabled in Settings, and skips superusers and excluded IPs like the rules do. Cloudflare documents it as eventually consistent, not an exact counter | `--rate-limit 300/10` (requests per 10 or 60 seconds, default PocketBase's `/api/` rule) / `VOIDBASE_DEPLOY_RATE_LIMIT`, `0` disables |
82
+ | `LOGS_ANALYTICS` (Workers Analytics Engine) | one data point per request (method, path, status, auth collection, error, execution time) at any log level, queryable in the dashboard and the SQL API at $0.25 per million points, while the panel's log keeps writing D1 rows from `VOIDBASE_LOG_MIN_LEVEL` up. The account has to enable Analytics Engine once, at https://dash.cloudflare.com/?to=/:account/workers/analytics-engine, or the upload fails with code 10089 | opt-in: `--analytics` / `VOIDBASE_DEPLOY_ANALYTICS=1` |
83
+ | `HUB` (Durable Object `VoidbaseHub`, SQLite-backed, in this Worker) | the realtime hub: every SSE connection holds one hibernatable socket to it, writes publish to it, so events arrive in tens of milliseconds instead of the D1 poll's second, and idle apps cost nothing (the object sleeps). Free plan included | `--no-hub` / `VOIDBASE_DEPLOY_HUB=0` keeps the D1 poll |
84
+ | Smart Placement | the Worker runs next to its D1 database | always on |
85
+
86
+ ### Every instance is isolated
87
+
88
+ Two voidbase instances on one account never share a resource. Everything the deploy creates is named or derived from
89
+ the worker name: `<name>-db`, `<name>-storage`, `<name>-jobs`, the `<name>_requests` dataset, and the rate-limit
90
+ binding's `namespace_id` is hashed from the name, because Cloudflare shares counters between bindings that reuse an
91
+ id across Workers. The realtime hub is a Durable Object class exported from the instance's own Worker rather than a Worker shared
92
+ by apps. `test/deploy-cf.ts` asserts the naming.
93
+
94
+ ## Option B: the Void platform
95
+
96
+ ```bash
97
+ void auth login
98
+ voidbase deploy --void # void deploy from this checkout, or from cloud/ in a consumer
99
+ ```
100
+
101
+ `void deploy` builds, applies the Drizzle migrations to the remote D1, provisions D1/R2/cron from the source and
102
+ makes the deploy live; secrets go through `void secret put`. Continuous deploys: `void init --github` (GitHub
103
+ OIDC) or the Void GitHub app. Operations: `void project logs --level error`, `void project requests --status 5xx`,
104
+ `void project rollback`.
105
+
106
+ ## Option C: a visible Void project
107
+
108
+ `voidbase cloud init [dir]` writes the same project into your tree, importing the `voidbase` package by name, for
109
+ people who want to edit it (extra routes, bindings, a custom domain in `wrangler.jsonc`). Deploy it with
110
+ `voidbase deploy --dir <dir>`, or by hand: `wrangler login`, `CLOUDFLARE_ACCOUNT_ID`, then
111
+ `void deploy --backend cloudflare --provision` (interactive shells only; commit the `wrangler.jsonc` it writes for
112
+ CI). Every `.env*` file that backend loads ships as plaintext worker vars, so keep secrets in `wrangler secret put`.
113
+
114
+ ## Option D: instances created by a control plane (`voidbase bundle` + `voidbase/cloud`)
115
+
116
+ `voidbase deploy` builds on your machine. A service that creates voidbase instances for other people (the site's
117
+ /cloud page is one: sign in with Cloudflare, one click, an instance in the user's own account) cannot build, so it
118
+ uploads a prebuilt release over Cloudflare's REST API instead:
119
+
120
+ ```bash
121
+ voidbase bundle # builds the generic Worker + panel once -> .cloud/releases/<version>/
122
+ voidbase bundle --push https://<control plane> --token <superuser token> # ... and stores it in that instance (POST /api/vbcloud/releases)
123
+ ```
124
+
125
+ `voidbase/cloud` (src/cloud/rest.ts, plain fetch, runs in a Worker) then does what the deploy does, from the
126
+ release: `provisionInstance(cf, { account, name, release, superuser })` creates `<name>-db`, `<name>-storage`,
127
+ `<name>-jobs`, applies the D1 migrations through `/query` (tracked in wrangler's `d1_migrations` table), uploads the
128
+ assets through an upload session and the script with its bindings, DO migration, cron trigger and workers.dev
129
+ subdomain, tagged `voidbase` + `voidbase-release:<version>`; `destroyInstance` removes all of it (worker first,
130
+ bucket last, emptied before); `listVoidbaseWorkers` finds instances by tag. The token is the user's OAuth access token
131
+ (`cloudflare` OAuth2 provider, see `voidbase-site/vb/cloud`) or an API token with the same permissions.
132
+ `test/cloud-rest.ts` exercises it against `test/cf-mock.ts`. The hub and the queue are decided when the release
133
+ is bundled (`voidbase bundle --no-hub` / `--no-queue`), not per instance: an instance can leave them out at
134
+ provisioning, but cannot add what the release does not carry. Tokens a control plane keeps go to rest sealed
135
+ with `VOIDBASE_ENCRYPTION_KEY` (`sealSecret` / `openSecret` from `voidbase/cloud`).
136
+
137
+ ## After deploying
138
+
139
+ 1. Open `/_/`, log in with the bootstrap superuser, change the password.
140
+ 2. Settings > Application: set the application URL (used in emails) and, if you terminate TLS elsewhere,
141
+ the trusted proxy header. On Workers the client IP already comes from `CF-Connecting-IP`.
142
+ 3. Settings > Mail server: SMTP on port 465 or 587 (25 is blocked on Workers); send the test email.
143
+ 4. Settings > Backups: set a cron to write zips to R2 (`__backups__/`).
144
+ 5. Point your app at the Worker URL. The `pocketbase` JS SDK needs no other change.
145
+
146
+ ## Local preview of the production build
147
+
148
+ ```bash
149
+ VOIDBASE_PERSIST_TO=.void-preview bun run build && vp preview --port 5181
150
+ ```
151
+
152
+ `vp preview` runs the built Worker in workerd with the same module-scope restrictions as production, which is
153
+ how `test/fresh-db.ts` catches code that only works in dev.
@@ -0,0 +1,88 @@
1
+ # Differences and limits versus PocketBase
2
+
3
+ voidbase speaks PocketBase's HTTP API on Cloudflare Workers with D1 (SQLite), R2 (files) and the Workers cron
4
+ trigger. The panel and the SDK cannot tell the two apart for the surfaces listed in [COMPAT.md](../COMPAT.md);
5
+ this page lists where the platform forces a different shape, and the limits that come with it.
6
+
7
+ ## Storage and database
8
+
9
+ | Topic | PocketBase | voidbase |
10
+ | --- | --- | --- |
11
+ | Database | one SQLite file on local disk | one D1 database (SQLite semantics, remote) |
12
+ | Transactions | interactive, `RunInTransaction` | none: every write validates first, then runs as **one D1 batch** (atomic). Hook code cannot open a transaction; `$app.runInTransaction(fn)` runs `fn` directly |
13
+ | Bound parameters | SQLite default (32766) | **100 per statement** (D1). Large `IN (...)` lists and wide inserts are chunked by the server; hand-written `$app.dao()` SQL must respect it |
14
+ | Columns per table | 2000 | 100 (D1) |
15
+ | Row / query size | SQLite limits | 1 MB per row, 128 MB per query result (D1). List endpoints paginate anyway |
16
+ | `_logs`, `_changes` | logs in a second SQLite file | tables in the same D1 database, pruned by the built-in crons |
17
+ | Files | local `pb_data/storage` or S3 | R2 bucket bound as `STORAGE` (keys `{collectionId}/{recordId}/{filename}`), or any S3-compatible bucket when `settings.s3.enabled` (SigV4 over fetch, path-style or virtual-host); `settings.backups.s3` likewise for archives |
18
+ | Backups | zip of the SQLite files + storage | zip of `data.json` (every table) + `storage/`, kept in R2 under `__backups__/`. A PocketBase backup cannot be restored here and vice versa; use import/export for cross-migration |
19
+
20
+ ## Runtime
21
+
22
+ | Topic | PocketBase | voidbase |
23
+ | --- | --- | --- |
24
+ | Process | long-running binary, in-memory state | `voidbase serve` is a long-running Bun process with SQLite and local files (limits below do not apply there: transactions are real, rate limits exact, realtime pushes from memory); on Cloudflare, stateless isolates. Anything PocketBase keeps in memory (resend limits, OTP attempts, MFA sessions, WebAuthn challenges, backup lock) lives in `_params` or its own table |
25
+ | Rate limits | per process, exact | per isolate, **approximate**: each isolate keeps its own fixed-window counters. Configure limits as a safety net, not as billing |
26
+ | Realtime | in-process broadcaster | every SSE connection polls the `_changes` table from inside its own request, about once a second; connections in one isolate share the read. Events arrive within roughly one second and cost one D1 read per second per isolate while at least one client is connected (zero when idle) |
27
+ | Crons | in-process scheduler | Cloudflare cron trigger every minute runs the due jobs (`runDue`). A job may run on any isolate; keep jobs idempotent. `POST /api/crons/:id` runs a job on demand |
28
+ | CPU time | unlimited | Workers CPU limit per request (30 s on paid plans by default). Thumbnail generation of very large images and huge batch requests are the operations most likely to hit it |
29
+ | Request body | 32 MB default (configurable) | 32 MB, and Cloudflare's own upload limit applies (100 MB on Free/Pro plans, higher on Business/Enterprise) |
30
+ | Outbound mail | net/smtp | `cloudflare:sockets` TCP with STARTTLS or implicit TLS (port 25 is blocked on Workers; use 465 or 587), or an HTTP provider when `VOIDBASE_MAIL_HTTP_URL` is set (Resend-compatible JSON, bearer key in `VOIDBASE_MAIL_HTTP_KEY`); the settings JSON stays PocketBase-shaped either way |
31
+ | OAuth2 | Go providers | the same 32 providers implemented on `fetch`; Apple client secret generation with ES256 in WebCrypto |
32
+ | JS hooks | goja VM, synchronous | bundled at build time into the Worker (`pb_hooks/*.pb.js`), running on V8. `$app.*`, `$http.send`, `$filesystem.*` and mail calls are asynchronous under the hood; the bundler inserts the awaits so hook code stays PocketBase-shaped. See [hooks.md](./hooks.md) |
33
+ | Migrations | `pb_migrations/*.js` at startup | the same files, bundled at build time and applied on the first request after a deploy (tracked in `_pbMigrations`) |
34
+ | Panel | embedded | the unmodified panel build copied to `public/_` by `bun run panel:sync` |
35
+ | Settings encryption | `--encryptionEnv` | `VOIDBASE_ENCRYPTION_KEY` (16, 24 or 32 chars): the settings row is stored AES-GCM encrypted |
36
+ | Superuser bootstrap | `superuser upsert` CLI | `VOIDBASE_SUPERUSER_EMAIL` / `VOIDBASE_SUPERUSER_PASSWORD` env, upserted on the first request |
37
+
38
+ ## Thumbnails
39
+
40
+ Sizes, crop anchors and fit rules follow PocketBase's `tools/filesystem` (imaging semantics), generated in Photon
41
+ (Rust compiled to wasm) and cached in R2. Only sizes declared on the field (plus `100x100`) are honoured, as in
42
+ PocketBase. WebP output is not produced: JPEG in, JPEG out; PNG in, PNG out.
43
+
44
+ ## Not implemented
45
+
46
+ - `OnTerminate`, `OnBackupCreate` / `OnBackupRestore` hook events (registered, never fired).
47
+ - `$os.cmd` / `$os.exec`, `$filesystem.fileFromPath`, `$template` rendering from disk: there is no filesystem or shell on Workers.
48
+ - PocketBase's own CLI (`pocketbase serve|migrate|superuser`). Use the panel, `bun run` scripts and Void's CLI ([deploy.md](./deploy.md)).
49
+
50
+ ## Health endpoint
51
+
52
+ `GET /api/health` returns `canBackup: false` while a backup or restore is running. `possibleProxyHeader` never
53
+ reports `CF-Connecting-IP`: Workers set that header themselves and voidbase already uses it as the client IP,
54
+ so the panel's "behind a reverse proxy" reminder only fires for other proxy headers you have not listed in
55
+ `settings.trustedProxy`.
56
+
57
+ ## Static files on Cloudflare
58
+
59
+ PocketBase serves `--publicDir` itself: an existing file, otherwise `index.html` with status 200, and the admin panel's
60
+ index for any `/_/` path. On Cloudflare, voidbase hands everything outside `/api` to the static asset layer, which
61
+ never invokes the Worker (assets are free and skip the isolate; see docs/platform.md). Cloudflare answers a miss with
62
+ the nearest `404.html`, so the build ships `404.html` copies of `index.html` and of `_/index.html`:
63
+
64
+ - deep links (`/posts/abc/`) get the SPA shell with **status 404** instead of PocketBase's 200 (the body is identical; the
65
+ client router boots as usual, and this is the shape SvelteKit documents for Cloudflare);
66
+ - a browser navigating to an unknown `/api/...` URL gets that HTML page with status 404, while API clients (anything
67
+ without `text/html` in `Accept`) get PocketBase's JSON 404;
68
+ - `voidbase serve` (Bun) keeps PocketBase's exact semantics, including the 200 index fallback.
69
+
70
+ ## Background jobs on Cloudflare
71
+
72
+ With the jobs queue that `voidbase deploy` creates, the system emails (verification, password reset, email change,
73
+ OTP, login alert) and the automatic backups run from a Cloudflare Queue instead of inside the request. Hooks still
74
+ run in the request on the final message; only the transport (SMTP or the HTTP provider) is deferred, retried five
75
+ times with backoff and, when it keeps failing, dropped and posted to `VOIDBASE_ALERT_WEBHOOK_URL`. Delivery is
76
+ at-least-once: a mail whose SMTP session broke after the server accepted it can arrive twice. The panel's test
77
+ email and `$app.newMailClient().send()` stay synchronous and report transport errors, as in PocketBase. Without the
78
+ queue (the Bun runtime, or a deploy whose token could not create one) everything runs inline as before.
79
+
80
+ ## Realtime transport
81
+
82
+ The SSE protocol, topics, `PB_CONNECT`, subscription updates and the per-subscriber rule checks are PocketBase's.
83
+ What differs is the transport behind them. On Cloudflare a per-instance Durable Object (the hub) fans out changes:
84
+ every connection holds one hibernatable WebSocket to it, a record write publishes after its D1 batch has committed,
85
+ and the isolate holding the connection fetches the record with the subscriber's auth and rules before sending. Like
86
+ PocketBase there is no replay: an event that happens while a client is reconnecting is missed. If the hub becomes
87
+ unreachable the stream ends and the SDK reconnects. Without the hub binding (the Bun runtime, or a deploy with
88
+ `--no-hub`) the same protocol runs on a D1 change feed polled about once a second by each connection.
package/docs/hooks.md ADDED
@@ -0,0 +1,84 @@
1
+ # JS hooks and migrations
2
+
3
+ voidbase runs PocketBase's `pb_hooks/*.pb.js` and `pb_migrations/*.js` files with the same globals. The
4
+ difference is *when* they load: the files are bundled into the Worker at build time (`hooks-plugin.ts`), so a
5
+ hook change needs a rebuild and redeploy, not a restart. Directories are configured with `VOIDBASE_HOOKS_DIR`
6
+ and `VOIDBASE_MIGRATIONS_DIR` (defaults `pb_hooks`, `pb_migrations`).
7
+
8
+ ## Writing hooks
9
+
10
+ Hook code is written exactly as for PocketBase. Because `$app.*`, `$http.send`, `$filesystem.*` and mail
11
+ calls are asynchronous on Workers, the bundler rewrites the file so each of those calls is awaited and the
12
+ enclosing function becomes async; `e.next()` is awaited too. Ordinary synchronous-looking PocketBase code works
13
+ unchanged. `require("./other.js")` resolves against the hooks directory bundle.
14
+
15
+ ```js
16
+ /// <reference path="../pb_data/types.d.ts" />
17
+ routerAdd("GET", "/api/hello", (e) => {
18
+ return e.json(200, { hello: e.auth?.email() ?? "guest" });
19
+ }, $apis.requireAuth());
20
+
21
+ onRecordCreateRequest((e) => {
22
+ e.record.set("slug", e.record.get("title").toLowerCase().replaceAll(" ", "-"));
23
+ e.next();
24
+ }, "posts");
25
+
26
+ cronAdd("digest", "0 8 * * *", () => {
27
+ const users = $app.findRecordsByFilter("users", "verified = true", "-created", 100, 0);
28
+ $app.logger().info("digest", "count", users.length);
29
+ });
30
+ ```
31
+
32
+ ## Events
33
+
34
+ Every `on*` function registers a handler; `e.next()` runs the remaining handlers and then the core action.
35
+ Throwing an `ApiError` subclass (`BadRequestError`, `ForbiddenError`, `NotFoundError`, `UnauthorizedError`,
36
+ `InternalServerError`, `ValidationError`) answers the request with that error. Handlers accept optional
37
+ collection-name tags as trailing arguments.
38
+
39
+ | Family | Events |
40
+ | --- | --- |
41
+ | App | `onBootstrap`, `onServe` (both once per isolate on its first request), `onSettingsReload` |
42
+ | Records (model) | `onRecordEnrich`, `onRecordValidate`, `onRecord{Create,Update,Delete}`, `onRecord{Create,Update,Delete}Execute`, `onRecordAfter{Create,Update,Delete}{Success,Error}`, and the `onModel*` equivalents |
43
+ | Records (request) | `onRecordsListRequest`, `onRecordViewRequest`, `onRecord{Create,Update,Delete}Request` |
44
+ | Auth (request) | `onRecordAuthRequest`, `onRecordAuthWithPasswordRequest`, `onRecordAuthWithOAuth2Request`, `onRecordAuthWithOTPRequest`, `onRecordAuthRefreshRequest`, `onRecordRequestOTPRequest`, `onRecordRequest{Verification,PasswordReset,EmailChange}Request`, `onRecordConfirm{Verification,PasswordReset,EmailChange}Request` |
45
+ | Collections | `onCollectionValidate`, `onCollection{Create,Update,Delete}`, `onCollectionAfter{Create,Update,Delete}{Success,Error}`, `onCollectionsListRequest`, `onCollectionViewRequest`, `onCollection{Create,Update,Delete}Request`, `onCollectionsImportRequest` |
46
+ | Files | `onFileDownloadRequest` (`e.servedName` editable), `onFileTokenRequest` |
47
+ | Realtime | `onRealtimeConnectRequest`, `onRealtimeSubscribeRequest` (`e.subscriptions` editable), `onRealtimeMessageSend` (`e.message` editable) |
48
+ | Settings | `onSettingsListRequest` (`e.settings` editable), `onSettingsUpdateRequest` (`e.oldSettings`, `e.newSettings`) |
49
+ | Mail | `onMailerSend`, `onMailerRecord{AuthAlert,PasswordReset,Verification,EmailChange,OTP}Send` |
50
+ | Batch | `onBatchRequest` (`e.batch` editable) |
51
+ | Registered, never fired | `onTerminate`, `onBackupCreate`, `onBackupRestore` |
52
+
53
+ Request events expose the PocketBase `RequestEvent` surface: `e.auth`, `e.request`, `e.requestInfo()`,
54
+ `e.pathParam(name)`, `e.bindBody(obj)`, `e.json(status, data)`, `e.string`, `e.html`, `e.noContent`,
55
+ `e.redirect`, `e.next()`.
56
+
57
+ ## Globals
58
+
59
+ | Global | Supported members |
60
+ | --- | --- |
61
+ | `$app` | `findCollectionByNameOrId`, `findAllCollections`, `findRecordById`, `findFirstRecordByData`, `findFirstRecordByFilter`, `findRecordsByFilter`, `findAuthRecordByEmail`, `findAuthRecordByToken`, `countRecords`, `expandRecord(s)`, `save`, `saveNoValidate`, `delete`, `runInTransaction` (runs the callback directly), `settings()`, `isDev()`, `logger()`, `newMailClient()`, `dao()` (raw SQL, D1 limits apply) |
62
+ | `$apis` | `requireAuth`, `requireSuperuserAuth`, `requireGuestOnly`, `requireSuperuserOrOwnerAuth`, `enrichRecord(s)` |
63
+ | `$http` | `send({url, method, body, headers, timeout})` |
64
+ | `$filesystem` | `fileFromURL`, `fileFromBytes`; `fileFromPath` throws (no filesystem) |
65
+ | `$security` | `randomString`, `randomStringWithAlphabet`, `pseudorandomString`, `sha256` |
66
+ | `$os` | `getenv` (Worker env vars), `readFile` (files bundled from the hooks directory), `writeFile`, `args`; `cmd`/`exec` throw |
67
+ | `$dbx` | `exp`, `hashExp` |
68
+ | `$mails`, `$template` | placeholders: `$template.loadFiles(...).render()` returns `""`. Build mail bodies as strings and send them with `$app.newMailClient().send(new MailerMessage({...}))` |
69
+ | Classes | `Record`, `Collection`, `RecordUpsertForm`, `MailerMessage`, `DateTime`, `RequestInfo`, `Field` and the typed field classes (`TextField`, `RelationField`, ...) |
70
+ | Registration | `routerAdd`, `routerUse`, `cronAdd`, `cronRemove`, `migrate` |
71
+
72
+ ## Migrations
73
+
74
+ `pb_migrations/*.js` files written by PocketBase's automigrate (`new Collection({...})`, `app.save`,
75
+ `app.findCollectionByNameOrId`, `collection.fields.addAt`, `unmarshal`) run unchanged. Pending migrations are
76
+ applied on the first request after a deploy, in file order, and recorded in `_pbMigrations`. The down
77
+ function is kept for parity but there is no CLI to run it; roll back by deploying a new migration.
78
+
79
+ ## Testing hooks locally
80
+
81
+ `bun test/fresh-db.ts` builds the Worker with `test/fixtures/hooks` and `test/fixtures/migrations`, boots
82
+ `vp preview` on an empty D1 and checks the hook side effects end to end. Point it at your own hooks with
83
+ `VOIDBASE_HOOKS_DIR` / `VOIDBASE_MIGRATIONS_DIR`, or run `./scripts/dev.sh start 5180` and edit `pb_hooks/`:
84
+ the dev server rebuilds the hooks bundle on save.
@@ -0,0 +1,29 @@
1
+ # Migrating an app from PocketBase
2
+
3
+ 1. **Export the schema** from PocketBase: Settings > Export collections, or `GET /api/collections?perPage=500`.
4
+ Import it into voidbase with Settings > Import collections (`PUT /api/collections/import`). Collection and
5
+ field ids are preserved, so existing tokens' `collectionId` claims and relation fields keep working.
6
+ 2. **Move the data.** For each collection, page through `GET /api/collections/{name}/records` on PocketBase and
7
+ `POST` the records to voidbase as a superuser (ids are kept when supplied). Files: download from
8
+ `/api/files/...` and re-upload as multipart. Passwords cannot be exported by PocketBase; users keep their
9
+ accounts through a password reset, or you write the `password` hash column directly with `POST /api/sql`
10
+ (bcrypt hashes are compatible).
11
+ 3. **Copy `pb_hooks/` and `pb_migrations/`** into the voidbase project (or point `VOIDBASE_HOOKS_DIR` and
12
+ `VOIDBASE_MIGRATIONS_DIR` at them) and rebuild. Read [hooks.md](./hooks.md) for the few members that do
13
+ not exist on Workers (`$os.cmd`, `$filesystem.fileFromPath`, `$template` from disk).
14
+ 4. **Settings:** re-enter SMTP credentials and OAuth2 client secrets in the panel (PocketBase never exports
15
+ secrets). Update every provider's redirect URL to `https://<your-worker>/api/oauth2-redirect`.
16
+ 5. **Client apps:** change the base URL passed to `new PocketBase(...)`. Realtime, files, thumbs, batch, auth
17
+ flows and the admin panel behave the same; the differences that can matter are listed in
18
+ [differences.md](./differences.md).
19
+ 6. **Verify.** `bun test/conformance/compare.ts <pocketbaseURL> <voidbaseURL>` runs the same requests against
20
+ both servers and diffs the JSON; the other suites under `test/conformance/` cover records, files, auth,
21
+ realtime, settings, logs, crons, backups and SQL.
22
+
23
+ ## Leaving voidbase
24
+
25
+ `bun scripts/export.ts <url> <outDir>` logs in as a superuser and writes `data.db` (SQLite with PocketBase's table
26
+ and column layout, password hashes included), `collections.json` (import format) and `storage/` (every file as
27
+ `{collectionId}/{recordId}/{filename}`), reading only through the API so it works against a deployed instance.
28
+ Import `collections.json` into PocketBase, copy `storage/` into `pb_data/storage/` and load the rows from `data.db`.
29
+
package/docs/perf.md ADDED
@@ -0,0 +1,53 @@
1
+ # Performance baseline
2
+
3
+ Numbers below come from `scripts/bench.ts` and `scripts/bench-realtime.ts` against the **local dev server**
4
+ (Void `vp dev`, miniflare/workerd on WSL2, D1 and R2 emulated on disk). They show the shape of the cost per
5
+ endpoint, not production latency: on Cloudflare the Worker runs next to D1 (single-digit ms reads in the same
6
+ region), R2 adds a few ms, and cold starts of the Worker are in the tens of milliseconds. Re-run both scripts against
7
+ a deployment for real figures (`bun scripts/bench.ts https://<your-worker> --n 50 --concurrency 20`).
8
+
9
+ ## Endpoints (local, 2026-09-06)
10
+
11
+ | endpoint | p50 ms | p95 ms | max ms | concurrent req/s | errors |
12
+ | --- | ---: | ---: | ---: | ---: | ---: |
13
+ | GET /api/health | 23.3 | 45.9 | 46.7 | 52 | 0 |
14
+ | GET records list (30) | 30.7 | 48.2 | 151.0 | 56 | 0 |
15
+ | GET records list + filter + sort | 24.9 | 41.1 | 86.1 | 40 | 0 |
16
+ | GET record view | 23.8 | 39.4 | 52.7 | 54 | 0 |
17
+ | POST record create | 28.5 | 40.5 | 77.7 | 37 | 0 |
18
+ | PATCH record update | 29.6 | 38.8 | 53.8 | 37 | 0 |
19
+ | POST auth-with-password | 117.6 | 137.9 | 202.9 | 9 | 0 |
20
+ | GET file | 24.3 | 32.1 | 53.7 | 52 | 0 |
21
+ | GET thumb 100x100 (cached after first) | 37.2 | 59.2 | 92.3 | 38 | 0 |
22
+ | GET collections list (superuser) | 25.3 | 33.7 | 53.9 | 54 | 0 |
23
+
24
+ Reading the table: every request pays one settings read (cached per isolate for a few seconds), one auth lookup when a
25
+ token is present, and the D1 statements the endpoint needs (list: one count + one page query; create: one batch with
26
+ the insert and the realtime change-feed row). `auth-with-password` is dominated by bcrypt (cost 10, ~100 ms of CPU),
27
+ as in PocketBase. Thumbnails are generated once (Photon, wasm) and then served from the R2/S3 cache.
28
+
29
+ ## Realtime fan-out (local)
30
+
31
+ | transport | clients | opened + subscribed | received the event | delivery p50 | p95 |
32
+ | --- | ---: | ---: | ---: | ---: | ---: |
33
+ | hub (Durable Object, push) | 100 | 3.4 s | 100 / 100 | 346 ms | 353 ms |
34
+ | hub, one client | 1 | | 1 / 1 | about 50 ms | |
35
+ | D1 poll (fallback) | 100 | 4.5 s | 100 / 100 | 587 ms | 837 ms |
36
+
37
+ With the hub, a write publishes once to the instance's Durable Object, which pushes to every connection's socket;
38
+ the remaining latency at 100 clients is the per-subscriber record fetch and rule check the isolate does for each
39
+ connection (one D1 read each, serialized in the single-threaded local runtime). With the poll, each SSE connection
40
+ is a long-running Worker request that polls the `_changes` table about once per second; connections in the same
41
+ isolate share the read, so the D1 cost is roughly one query per second per isolate while at least one client is
42
+ connected and zero when idle, and delivery latency is bounded by the poll interval. The local dev server could not hold 300 concurrent streams within the bench's five-minute budget; that is a
43
+ miniflare limit, not the design's. The dev server also never cancels an SSE request whose client vanished, so a
44
+ killed benchmark leaves streams (and their hub sockets, which keep pinging) behind until the idle cleanup; on
45
+ Cloudflare a client disconnect cancels the request and the hub sees the socket close. Production limits to measure on a deployment: concurrent connections per isolate
46
+ (Cloudflare spreads long requests across isolates), D1 reads per second at 1k and 10k clients (expected: number of
47
+ isolates x 1/s), and per-request CPU (the poll loop sleeps, it does not spin).
48
+
49
+ ## Cold start
50
+
51
+ The Worker bundle is about 1.5 MB with the Photon wasm (loaded lazily on the first thumbnail). Bootstrap on a fresh
52
+ isolate runs one `_params` read (settings) and, on the very first request after a deploy, the pending
53
+ `pb_migrations`. There is no in-memory state to warm apart from the settings cache and rate-limit counters.