@voidbase-cloud/voidbase 0.2.2 → 0.3.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 (44) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +15 -7
  3. package/bin/voidbase.ts +58 -6
  4. package/docs/adapter.md +271 -0
  5. package/docs/ci.md +195 -0
  6. package/docs/deploy.md +60 -5
  7. package/docs/releasing.md +39 -31
  8. package/hooks-plugin.ts +15 -4
  9. package/package.json +9 -4
  10. package/routes/api/[...path].ts +0 -5
  11. package/scripts/cf-builds.ts +228 -0
  12. package/scripts/ci-browser.sh +60 -0
  13. package/scripts/ci-cache.sh +40 -0
  14. package/scripts/ci-lib.sh +40 -0
  15. package/scripts/ci-oracles.sh +19 -0
  16. package/scripts/ci-plan.ts +270 -0
  17. package/scripts/ci-status.ts +126 -0
  18. package/scripts/ci-suites.sh +11 -1
  19. package/scripts/ci.sh +188 -0
  20. package/scripts/gh-release.ts +48 -0
  21. package/scripts/release.sh +104 -0
  22. package/scripts/seed-reference.sh +7 -2
  23. package/scripts/sync-app.ts +1 -0
  24. package/src/adapter/bundle.ts +130 -0
  25. package/src/adapter/codegen.ts +269 -0
  26. package/src/adapter/index.ts +6 -0
  27. package/src/adapter/plugin.ts +117 -0
  28. package/src/adapter/runtime.ts +325 -0
  29. package/src/adapter/scan.ts +276 -0
  30. package/src/cloud/rest.ts +10 -2
  31. package/src/node/assets.ts +7 -1
  32. package/src/node/cloud-init.ts +14 -0
  33. package/src/node/deploy-cf.ts +113 -16
  34. package/src/node/secrets.ts +169 -0
  35. package/src/node/serve.ts +15 -2
  36. package/src/server/api.ts +7 -2
  37. package/src/server/app.ts +6 -1
  38. package/src/server/hooks/index.ts +27 -1
  39. package/src/server/hooks/migrations.ts +4 -1
  40. package/src/server/hooks/runtime.ts +10 -2
  41. package/src/server/jobs.ts +3 -1
  42. package/src/server/webauthn.ts +23 -6
  43. package/tsconfig.json +4 -0
  44. package/tsconfig.node.json +2 -1
package/docs/ci.md ADDED
@@ -0,0 +1,195 @@
1
+ # CI and release: one flow, Cloudflare runs it
2
+
3
+ `scripts/ci.sh` is the whole CI, and its last step runs `scripts/release.sh`, the whole release flow, when the
4
+ commits call for it. Both run the same way on a dev machine and on Cloudflare Workers Builds; GitHub Actions only
5
+ starts the builds (`.github/workflows/cloudflare.yml`).
6
+ Every step is recorded (`scripts/ci-lib.sh`) and `scripts/ci-status.ts` renders the record into `ci/public`:
7
+ `index.html`, `status.json`, `badge.svg`, the suite logs and the screenshots. That directory is the status Worker a
8
+ build deploys.
9
+
10
+ ## The CI steps (`scripts/ci.sh`)
11
+
12
+ | step | what |
13
+ | --- | --- |
14
+ | install | `bun install --frozen-lockfile` |
15
+ | commitlint | the commits the push or pull request introduces (`--last` when there is nothing to compare with) |
16
+ | oracles | the starter (`scripts/ci-oracles.sh`: `STARTER_DIR`, else the sibling checkout, else a shallow clone in the cache), the panel (`panel:sync`), the starter's frontend build next to it (`app:sync`), `void prepare` |
17
+ | plan | `scripts/ci-plan.ts`: which of the following steps and suites this run needs (below); hot mode trims the list to a time budget |
18
+ | typecheck, unit | `tsc --noEmit`, `bun test` |
19
+ | browser | a Chrome for the panel and starter suites (`scripts/ci-browser.sh`, below); `CI_BROWSER=0` skips them |
20
+ | boot | the run's `.env`, `void db migrate`, the dev server on 5180 (`CI_PORT`), the app user |
21
+ | reference | PocketBase 0.39.11 on 8090 (`CI_PB_PORT`) freshly seeded from the starter (again before the Bun pass: the reference keeps state the suites cannot undo, such as a stored S3 secret), the SMTP sink, the OIDC, S3 and Cloudflare API mocks, awaited before a first mail warms the SMTP transport; whatever already listens on a port is reused |
22
+ | suites | `scripts/ci-suites.sh`: every differential suite, the SDK suite, the panel suites |
23
+ | suites-bun | the same suites against `voidbase serve` on 8093 (Bun, SQLite, local files), without the browser suites |
24
+ | deploy-cf, adapter, fresh-db, mail-http, exe-smoke | the deploy dry run against the API mock, a Void app converted and run through the adapter, the fresh-database boot and the HTTP mail transport of the production build, the prebuilt executable and its update flow |
25
+ | starter | the unmodified starter frontend against voidbase |
26
+ | release | on master, with `GH_TOKEN`: the release flow (docs/releasing.md) when a releasable commit was pushed, the release PR was merged, a `Release: dry-run` trailer asks for a rehearsal, or a release still needs npm or its executables |
27
+
28
+ The script stops at the first failed step, prints the relevant logs, renders the status page and stops the servers
29
+ it started; a dev machine's `.env` is put back. Steps the plan does not select are recorded as skipped with the reason. Ports, oracles and Chrome come from the environment, so a dev machine
30
+ runs the same flow with `bun run ci`.
31
+
32
+ ## Incremental runs (`scripts/ci-plan.ts`)
33
+
34
+ A full run takes about nine minutes on the build image, most of it the two suite passes, so a run only repeats the
35
+ checks its changes reach. Every check has a set of files: the import closure of its test entry point plus the runtime
36
+ it exercises, resolved through the same import graph (`#platform/*` follows the `workerd` condition for the Workers
37
+ server and the default one for the Bun runtime), so `src/node/deploy-cf.ts` reaches the deploy dry run, the
38
+ executable smoke, typecheck and the unit tests and nothing else, while a file of the server reaches every suite on
39
+ both runtimes. File hashes come from git blob ids, so they are exact and cost nothing; the combined hash of a check's
40
+ files is compared with the one the last green run recorded, and the check runs only when they differ. The record is
41
+ the deployed status page of master (`CI_STATUS_URL`, https://release.voidbase.cloud/status.json, whose `verified`
42
+ map holds the hash each check last passed on); on a dev machine it is `ci/public/status.json` from the previous run. What passed
43
+ gets this run's hashes, what was skipped keeps the previous record's, so a chain of partial runs stays sound. The
44
+ oracle sync, the servers, the reference and Chrome happen only when a selected check needs them.
45
+
46
+ | change | what runs |
47
+ | --- | --- |
48
+ | docs, README, surface, `ci/`, `.github/` | commit messages and the plan: about half a minute |
49
+ | one suite's file | that suite on both runtimes, with the servers it needs |
50
+ | `src/node/deploy-cf.ts`, `src/cloud` | typecheck, unit, the deploy dry run, the executable smoke, cloud-rest: under two minutes |
51
+ | `src/node/serve.ts`, `bin/voidbase.ts` | the Bun pass and the executable smoke |
52
+ | `src/server`, `routes`, the app config, the harness | everything |
53
+
54
+ `bun scripts/ci-plan.ts affected <file>` prints the checks a file reaches; `explain` prints every check with its
55
+ file count and hash. Uncommitted changes never match a record, so a dirty working tree reruns what it touches.
56
+ `CI_PLAN=full` (or `--full`) runs everything regardless, and so does a commit whose message carries `Tests: all`; the
57
+ same happens when the record cannot be fetched. Only the repository's own files are hashed: a new commit of the
58
+ starter oracle is picked up by the next full run.
59
+
60
+ ## Hot mode
61
+
62
+ The suites test the server as a black box, so a server change reaches all of them and a full run is the honest
63
+ answer. During a development phase that is too slow, so hot mode keeps every run within a time budget:
64
+ `bun scripts/cf-builds.ts hot on` (`--budget 60` to change the default of sixty seconds; `hot off` to return to
65
+ full runs). With hot mode on, a run does typecheck and the unit tests always, then whatever the commits name, then
66
+ the suites of the commits' scopes, then the cheapest of the remaining selected checks until the budget is spent,
67
+ using the durations the last run recorded (`status.json`, `suites[].seconds`). The Bun pass, the browser suites and
68
+ the starter smoke wait for a normal run. Deferred checks are listed on the status page and are never marked verified,
69
+ so the first run after `hot off` does them all. Releasing in hot mode publishes to npm only, so voidbase-site can
70
+ pick the version up at once; the executables of that release are built by the first normal run on master.
71
+
72
+ The commits steer it: a Conventional Commit scope (`fix(records): ...`) puts that area's suites first
73
+ (`SCOPE_KEYS` in the planner maps every scope of `commitlint.config.js` to suites), a `Tests:` trailer names checks
74
+ that are never deferred (`Tests: thumbs s3`, `Tests: bun` for the Bun pass, `Tests: browser`), and `Tests: all`
75
+ forces a full run. A commit that edits a suite's file always runs that suite. The messages of every commit since the
76
+ last green run count, not only the last one: a Cloudflare build checks out a single commit, so the planner deepens
77
+ the history until the last green run's commit is reachable before it reads them.
78
+
79
+ ## What is kept between runs
80
+
81
+ `CI_CACHE_DIR` holds the downloads: Playwright's headless shell and the unpacked libraries, the Ubuntu packages,
82
+ the starter clone with its `node_modules` and its frontend build (reused while the starter's commit is the same), the
83
+ panel tarball (through `XDG_CACHE_HOME`) and the PocketBase archive of the reference. On a dev machine it is
84
+ `~/.cache/voidbase-ci` and simply stays there. Workers Builds keeps nothing between builds but the package manager's
85
+ cache, and that one only until the lockfile changes, so on Cloudflare `scripts/ci-cache.sh` restores the directory from
86
+ an R2 bucket at the start of a build and saves it at the end, one archive per component, uploaded only when its content
87
+ changed. `setup` creates the bucket (`voidbase-ci-cache`) when `CI_CACHE_TOKEN` is in the environment (an API token with
88
+ Workers R2 Storage edit; the deploy token of the site is accepted) and stores it on every trigger as a build secret,
89
+ with `CI_CACHE_ACCOUNT` and `CI_CACHE_BUCKET`. Without those the builds fetch everything each time, about forty
90
+ seconds of a full run.
91
+
92
+ ## Chrome (`scripts/ci-browser.sh`)
93
+
94
+ The browser suites launch whatever `CHROME_PATH` points at, else the Chrome on the PATH (a dev machine). Where there
95
+ is no Chrome, the script downloads Playwright's chromium-headless-shell under `.void/browsers` and, when the machine also
96
+ lacks the shared libraries Chrome needs, unpacks them from Ubuntu's packages into `.void/chrome-libs` without root (a
97
+ private apt root, `dpkg-deb -x`, `LD_LIBRARY_PATH`): the Workers Builds image has neither Chrome nor sudo nor those
98
+ libraries. The unpacking path is exercised on a dev machine with `CI_BROWSER_DOWNLOAD=1 CI_BROWSER_LIBS=always`.
99
+
100
+ ## GitHub Actions only starts builds
101
+
102
+ `.github/workflows/cloudflare.yml` is the only workflow. It never runs the flows: it asks Cloudflare Workers Builds
103
+ for a build of the commit at hand through the Builds API (a few seconds of Actions time) and exits.
104
+
105
+ | event | build started |
106
+ | --- | --- |
107
+ | push to master | the master trigger; the release step of the build then refreshes the release PR for releasable commits or publishes a merged release PR |
108
+ | pull request from a branch of this repository | the branches trigger: a preview URL of the results, no release work, no secrets |
109
+ | release published | the master trigger for the tagged commit, whose release step publishes it |
110
+ | Actions > cloudflare > Run workflow | the ref's trigger |
111
+
112
+ The job is skipped until the repository variables exist (`CF_ACCOUNT_ID`, `CF_CI_TRIGGER_MASTER`,
113
+ `CF_CI_TRIGGER_BRANCHES`) together with the secret `CLOUDFLARE_BUILDS_TOKEN`; `bun scripts/cf-builds.ts setup
114
+ --github` stores all four. With the repository variable
115
+ `CF_BUILDS_WAIT=1` the job also waits for the builds it started and fails when one fails, so the pull request check
116
+ reflects the result; without it the check only means "started", and the result lives in the dashboard, in
117
+ `cf-builds.ts logs`, and on the status page. Pushes never build on their own: every trigger's watch paths exclude every
118
+ path, and the API is not subject to them. Standard GitHub-hosted runners are free for public repositories anyway; the
119
+ trigger job spends seconds.
120
+
121
+ Two things stop existing with this layout because they need the OIDC token only a GitHub Actions run can mint:
122
+ `npm publish --provenance` and the build attestations of the release archives. Releases carry checksums only.
123
+
124
+ ## Cloudflare Workers Builds
125
+
126
+ [Workers Builds](https://developers.cloudflare.com/workers/ci-cd/builds/) is Cloudflare's build system for Workers: the
127
+ "Cloudflare Workers and Pages" GitHub App starts a build on every push to a connected repository, runs a build command
128
+ and a deploy command in Cloudflare's build image, and posts a check run (and a preview URL on pull requests) back to
129
+ GitHub. One project, `voidbase-ci`, runs voidbase's flows there: a Worker whose deploy publishes the status page of
130
+ the build (`ci/wrangler.jsonc`, assets only). A project has at most two triggers with one command each, which is
131
+ why the release flow runs inside the CI build rather than as a project of its own:
132
+
133
+ | trigger | build command | deploy command |
134
+ | --- | --- | --- |
135
+ | master | `bash scripts/ci.sh` | `wrangler deploy -c ci/wrangler.jsonc` |
136
+ | branches | `bash scripts/ci.sh` | `wrangler versions upload -c ci/wrangler.jsonc`: a preview URL of the results on the pull request |
137
+
138
+ A failed build command means no deploy, so the status Worker shows the last build that ran to the end; the log of a
139
+ failed build is in the dashboard and in `cf-builds.ts logs`. The release secrets (`GH_TOKEN`, `NPM_TOKEN`, optionally
140
+ `GH_PACKAGES_TOKEN`) are build secrets of the master trigger only, so builds of other branches never carry them.
141
+
142
+ ### Limits and cost
143
+
144
+ | | Free plan | Paid plan |
145
+ | --- | --- | --- |
146
+ | build minutes | 3,000 a month | 6,000 a month, then $0.005 a minute |
147
+ | concurrent builds | 1 | 6 |
148
+ | build timeout | 20 minutes | 20 minutes |
149
+ | CPU, memory | 2 vCPU, 8 GB | 4 vCPU, 8 GB |
150
+
151
+ The image is Ubuntu 24.04 x86_64 with Node 22, Bun 1.2.15 (the projects set `BUN_VERSION=1.3.14`, the version the
152
+ workflows pin), git, curl, unzip and build-essential; no Chrome, no lsof, no jq, no gh. The scripts need none of them:
153
+ `scripts/gh-release.ts` talks to GitHub's API directly. The 20-minute timeout is the constraint to watch: the CI run
154
+ takes 7.5 minutes on GitHub's 4 vCPU and gets 2 on the Free plan, and the two projects build one after the other there.
155
+
156
+ ### Setup, once
157
+
158
+ 1. Install the [Cloudflare Workers and Pages GitHub App](https://github.com/apps/cloudflare-workers-and-pages) for
159
+ `voidbase-cloud/voidbase` (an organization owner does this on GitHub; limit it to that repository).
160
+ 2. Create a user API token at dash.cloudflare.com/profile/api-tokens with **Workers Builds Configuration: Edit** and
161
+ **Workers Scripts: Edit**, and export it as `CLOUDFLARE_BUILDS_TOKEN`. The Builds API takes user tokens only; the
162
+ account-owned token `voidbase deploy` uses is rejected.
163
+ 3. `GH_TOKEN=... NPM_TOKEN=... bun scripts/cf-builds.ts setup --github` connects the repository, creates the Worker
164
+ and its two triggers with push builds off, sets `BUN_VERSION`, stores the release secrets it finds in the
165
+ environment on the master trigger, and writes the workflow's variables and secret into the GitHub repository
166
+ (`gh variable set`, `gh secret set`). The release secrets: `GH_TOKEN` (a fine-grained PAT with contents and pull requests write on the repository, for release-please
167
+ and the release assets), `NPM_TOKEN` (the npm granular token), optionally `GH_PACKAGES_TOKEN` (a classic PAT with
168
+ `write:packages`; fine-grained tokens cannot publish packages, and without it the GitHub Packages copy is skipped).
169
+ The first run stops when the account has no build token yet: open the `voidbase-ci` Worker in the dashboard,
170
+ Settings > Builds > API token > Create new token, and run setup again.
171
+ 4. `bun scripts/cf-builds.ts build --branch master --follow` runs the first build and streams its log; `status`,
172
+ `builds`, `logs <uuid>`, `cancel <uuid>` and `env` cover the rest (the header of the script lists them).
173
+ 5. From then on every push and pull request goes through the workflow, one build each. `gh variable set
174
+ CF_BUILDS_WAIT --body 1` makes the workflow wait for the build it started.
175
+
176
+ `test/cf-builds.ts` runs the CLI against `test/cf-mock.ts`, whose Builds endpoints follow the request and response
177
+ shapes of Cloudflare's API reference; the live API is exercised the first time the App and the user token exist.
178
+
179
+ ### What to expect
180
+
181
+ - No provenance and no attestations (OIDC): `npm publish` runs without `--provenance`, the release archives carry
182
+ checksums only.
183
+ - GitHub Packages only with `GH_PACKAGES_TOKEN`.
184
+ - Logs live in the dashboard and in `cf-builds.ts logs`; the deployed page is the last build that ran to the end.
185
+ - release-please and the release assets use `GH_TOKEN`, a PAT, so a release it creates fires the `release` event and
186
+ the workflow starts one more release build for the tagged commit, which finds everything published and stops.
187
+
188
+ ## The status page
189
+
190
+ `ci/public/index.html` lists the steps with their durations and logs, every suite with its result and last line, the
191
+ screenshots of the panel and starter suites, and links `status.json` (the same, as data) and `badge.svg`
192
+ (`ci: passing`). The page's canonical address is https://release.voidbase.cloud (the custom domain
193
+ `ci/wrangler.jsonc` declares; the workers.dev address stays on for the preview URLs): `badge.svg` there is the badge
194
+ in the README and `status.json` the record of the last green run and the feed for anything else; a pull request's
195
+ build uploads a version, so its preview URL shows the same page for that commit.
package/docs/deploy.md CHANGED
@@ -43,7 +43,26 @@ deploy just skips the jobs queue and says so.
43
43
 
44
44
  ```bash
45
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
46
+ voidbase deploy # --name <worker>, --account <id> when the token reaches several accounts
47
+ ```
48
+
49
+ A static site is served at `/` by the same Worker when there is one: `./pb_public` (PocketBase's convention, picked up
50
+ automatically when the directory exists, exactly like `voidbase serve`), `--public-dir <dir>` or
51
+ `VOIDBASE_DEPLOY_PUBLIC_DIR`. Build it first: the directory needs an `index.html`. Unknown paths get its `404.html`
52
+ (a copy of `index.html` unless the build made one) with status 404, `/api` and `/_/` are untouched. A `_redirects`
53
+ file in that directory (Netlify/Pages syntax, `source destination [status]`) is split in two: path-only lines are
54
+ uploaded with the assets as Cloudflare's own `_redirects` (evaluated before the Worker), and lines whose source names a
55
+ host (`https://api.example.com/ /_/ 302`) become zone Redirect Rules after the upload, tagged with the Worker's name so
56
+ a redeploy replaces exactly its own rules. That is how one Worker behind several custom domains answers differently per
57
+ hostname: the site on the apex, the API hostname's root sent to the admin panel, `www` sent to the apex. Writing them needs
58
+ the zone permission Single Redirect > Edit on the deploy token (the API's permission listing calls it Dynamic URL
59
+ Redirects Write; it sits with the zone permissions, like DNS Edit, scoped to the zone). Without it the deploy prints the
60
+ rules to create by hand and carries on.
61
+ (Void's own `routing.redirects` are not used: they are applied by the Void platform's dispatch worker, which a
62
+ self-hosted deploy does not have.)
63
+
64
+ ```bash
65
+ voidbase deploy --public-dir ../sk/build # a build that lives elsewhere
47
66
  ```
48
67
 
49
68
  What it does, in order: resolves the account through the token, creates `<name>-db` (D1), `<name>-storage`
@@ -51,7 +70,8 @@ What it does, in order: resolves the account through the token, creates `<name>-
51
70
  (`node_modules/voidbase/.cloud/<name>`, nothing appears in your tree) with a `wrangler.jsonc` carrying the real
52
71
  ids and, when the directory has a `main.ts` exporting `register(app)`, composes it into the Worker; stores the
53
72
  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
73
+ password saved in `pb_data/.superuser-credentials`; the local dev default `changeme123` never goes live) together
74
+ with the secrets `pb_secrets/` declares (below), syncs
55
75
  the admin panel and your frontend build into that project, and runs `void deploy --backend cloudflare`,
56
76
  which builds, applies the D1 migrations and uploads the Worker with its cron trigger. It ends with the
57
77
  `https://<name>.<your-subdomain>.workers.dev` URL and a health check. Re-running is idempotent: existing resources
@@ -66,8 +86,9 @@ https://dash.cloudflare.com/?to=/:account/api-tokens).
66
86
 
67
87
  ### A custom domain
68
88
 
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
89
+ `voidbase deploy --domain api.example.com` (or `VOIDBASE_DEPLOY_DOMAIN`; several hostnames comma separated, the first is
90
+ the URL the deploy reports) turns workers.dev off for the Worker and attaches each hostname through the Workers Custom
91
+ Domains API after the upload: Cloudflare creates the DNS record and the
71
92
  certificate (a minute or two), and the token needs nothing beyond Workers Scripts edit, provided the zone is on the same
72
93
  account. Cloudflare still requires the account to have a workers.dev subdomain before it accepts any upload (error
73
94
  10063): open Workers & Pages once, or `PUT /accounts/<id>/workers/subdomain {"subdomain": "<name>"}`.
@@ -83,6 +104,40 @@ account. Cloudflare still requires the account to have a workers.dev subdomain b
83
104
  | `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
105
  | Smart Placement | the Worker runs next to its D1 database | always on |
85
106
 
107
+ ### Secrets: `pb_secrets/`
108
+
109
+ The app's own secrets (an SMTP password, OAuth client secrets, `VOIDBASE_ENCRYPTION_KEY`) have a directory, in
110
+ PocketBase's naming:
111
+
112
+ ```
113
+ pb_secrets/main.pb.js secrets({ SMTP_PASSWORD: "the mail provider's password", ... }) committed
114
+ pb_secrets/secrets.json { "SMTP_PASSWORD": "..." } git-ignored
115
+ ```
116
+
117
+ `main.pb.js` names the secrets and is read, never run (`voidbase init` writes an empty one and the `.gitignore`
118
+ lines). `secrets.json` holds the values on your machine; the shell outranks it, and it outranks the `.env` files,
119
+ so a dev placeholder such as `VOIDBASE_SUPERUSER_PASSWORD=changeme123` never shadows it. `voidbase serve` loads them into the environment, so
120
+ `$os.getenv("SMTP_PASSWORD")` and the app's own code see the same names locally as on Cloudflare. `voidbase deploy`
121
+ stores the declared values the Worker does not have yet as the Worker's secrets (encrypted, per Worker: two
122
+ instances never share one) next to the superuser, and refuses to deploy while a declared secret has neither a local
123
+ value nor one already on the Worker. A value the Worker already holds is left alone by a deploy: a deploy ships
124
+ code, and a checkout whose `secrets.json` carries dev values (another OAuth client, the placeholder password) must
125
+ not overwrite production by deploying. Replacing is explicit:
126
+
127
+ ```bash
128
+ voidbase secrets # each declared name: local value or not, on the Worker or not
129
+ voidbase secrets push # store the local values on the Worker (replacing), without redeploying
130
+ ```
131
+
132
+ That is also what makes CI simple: a checkout without `secrets.json` deploys with nothing but the deploy token,
133
+ because the values were pushed once from a machine that has them. The superuser follows the same rule: a checkout
134
+ without credentials of its own keeps the superuser the Worker has.
135
+
136
+ A value in `secrets.json` that `main.pb.js` does not declare is never deployed (the list says so). `VOIDBASE_DEPLOY_SECRETS=A,B`
137
+ still stores environment variables as secrets for a deploy driven purely by the shell. Cloudflare's account-level
138
+ Secrets Store is deliberately not used: one store is shared by every Worker of the account, and its bindings are
139
+ read asynchronously, which `$os.getenv` is not.
140
+
86
141
  ### Every instance is isolated
87
142
 
88
143
  Two voidbase instances on one account never share a resource. Everything the deploy creates is named or derived from
@@ -128,7 +183,7 @@ release: `provisionInstance(cf, { account, name, release, superuser })` creates
128
183
  assets through an upload session and the script with its bindings, DO migration, cron trigger and workers.dev
129
184
  subdomain, tagged `voidbase` + `voidbase-release:<version>`; `destroyInstance` removes all of it (worker first,
130
185
  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.
186
+ (`cloudflare` OAuth2 provider, see `voidbase-site/cloud`) or an API token with the same permissions.
132
187
  `test/cloud-rest.ts` exercises it against `test/cf-mock.ts`. The hub and the queue are decided when the release
133
188
  is bundled (`voidbase bundle --no-hub` / `--no-queue`), not per instance: an instance can leave them out at
134
189
  provisioning, but cannot add what the release does not carry. Tokens a control plane keeps go to rest sealed
package/docs/releasing.md CHANGED
@@ -36,52 +36,60 @@ ci(release): compile release notes with release-please
36
36
  ```
37
37
 
38
38
  `.husky/commit-msg` runs commitlint on every commit; `.husky/pre-commit` runs `bun run check` and `bun test`.
39
- `bun install` installs the hooks (`prepare`); `git commit --no-verify` skips them, and CI (`ci.yml`, job
40
- `commitlint`) checks the pushed or proposed commits regardless.
39
+ `bun install` installs the hooks (`prepare`); `git commit --no-verify` skips them, and the CI build
40
+ (`scripts/ci.sh`, step `commitlint`) checks the pushed or proposed commits regardless.
41
41
 
42
42
  ## The release
43
43
 
44
- 1. Push or merge conventional commits to `master`. `release.yml` runs release-please, which opens or updates the
45
- pull request "chore(master): release X.Y.Z": the next version from the commit types, the `CHANGELOG.md`
46
- section compiled from the commits, the `package.json` bump. Keep merging work; the PR follows.
47
- 2. Merge the PR. release-please tags `vX.Y.Z` and creates the GitHub release with that section as notes.
48
- 3. The `publish` job of the same run then installs, typechecks, runs the unit and cloud-rest tests, packs, smoke-
49
- installs the tarball and runs the CLI from it, publishes to npm (`--provenance` when the repository is public)
50
- and to GitHub Packages, and attaches the tarball to the release.
51
- 4. The `executables` job builds the prebuilt executables for every platform (`scripts/build-exe.ts`: Bun
52
- cross-compiles from one runner; the panel, the system migrations and the hooks typings are embedded), smokes the
53
- runner's own build (`test/exe-smoke.ts`: serve with pb_hooks, the panel from the embedded zip, a thumbnail through
54
- the wasm, then `voidbase update` against a mock GitHub API), attaches `voidbase_<version>_<os>_<arch>.zip` for
55
- linux/darwin/windows × amd64/arm64 (plus musl builds) and `checksums.txt` to the release, attests each archive
56
- with `actions/attest-build-provenance`, and puts the release notes in PocketBase's shape: the
57
- `./voidbase update` hint first, then the compiled notes.
44
+ `scripts/release.sh` is the whole flow. It runs as the last step of the CI build on master (docs/ci.md) when the
45
+ pushed commits are releasable (`feat`, `fix`, `perf`, `revert`, a breaking change), when the release PR was merged,
46
+ when a commit carries a `Release: dry-run` trailer, or when a release still needs npm or its executables (one cut
47
+ by hand, or one published in hot mode). It is idempotent: a re-run after a partial failure does only what is still
48
+ missing. In hot mode it publishes to npm and leaves the executables to the first normal run.
49
+
50
+ 1. Push or merge conventional commits to `master`. `release-pr` (release-please) opens or updates the pull request
51
+ "chore(master): release X.Y.Z": the next version from the commit types, the `CHANGELOG.md` section compiled from
52
+ the commits, the `package.json` bump. Keep merging work; the PR follows.
53
+ 2. Merge the PR. `github-release` tags `vX.Y.Z` and creates the GitHub release with that section as notes.
54
+ 3. `publish`, when release `v<package.json version>` exists and npm lacks the version: install, typecheck, the unit
55
+ and cloud-rest tests, `npm pack`, a smoke install of the tarball that runs the CLI from it, `npm publish`, the
56
+ GitHub Packages copy (with `GH_PACKAGES_TOKEN`), and the tarball attached to the release (`scripts/gh-release.ts`).
57
+ 4. `executables`, when that release lacks `checksums.txt`: the prebuilt executables for every platform
58
+ (`scripts/build-exe.ts`: Bun cross-compiles from one machine; the panel, the system migrations and the hooks
59
+ typings are embedded), the smoke of the machine's own build (`test/exe-smoke.ts`: serve with pb_hooks, the panel
60
+ from the embedded zip, a thumbnail through the wasm, then `voidbase update` against a mock GitHub API),
61
+ `voidbase_<version>_<os>_<arch>.zip` for linux/darwin/windows × amd64/arm64 (plus musl builds) and `checksums.txt`
62
+ attached to the release, and the release notes in PocketBase's shape: the `./voidbase update` hint first, then the
63
+ compiled notes.
58
64
 
59
65
  The layout mirrors PocketBase's releases: the zip holds the executable, `CHANGELOG.md` and `LICENSE`;
60
66
  `checksums.txt` is goreleaser's format (`<sha256> <file>`), which `voidbase update` checks before replacing the
61
- executable. Verify an archive's provenance with `gh attestation verify voidbase_X.Y.Z_linux_amd64.zip --owner
62
- voidbase-cloud`. For the "Immutable" badge and the release attestation GitHub adds itself, enable immutable releases
63
- once in the repository settings (Settings > General > Releases); it is a setting, not something the workflow can
64
- turn on.
67
+ executable. Builds on Cloudflare cannot attest the archives or publish with npm provenance (both need the OIDC token
68
+ of a GitHub Actions run), so the checksums are the integrity check. For the "Immutable" badge and the release
69
+ attestation GitHub adds itself, enable immutable releases once in the repository settings (Settings > General >
70
+ Releases); it is a setting, not something a workflow can turn on.
65
71
 
66
72
  `.release-please-manifest.json` holds the released version (0.1.0 was cut by hand and its notes written by hand;
67
73
  everything after it is compiled). `release-please-config.json` maps commit types to changelog sections.
68
74
 
69
75
  ## Rehearsals and manual paths
70
76
 
71
- - Actions > release > Run workflow with `dry_run` on (or `gh workflow run release.yml -f dry_run=true`): the
72
- publish job with `npm publish --dry-run`, nothing published, no release touched.
73
- - A release cut by hand also publishes: `gh release create vX.Y.Z --notes-file notes.md` after bumping
74
- `package.json` to X.Y.Z on `master`; the `release: published` event runs the publish job against that tag.
77
+ - A dry run: a commit on master whose message carries a `Release: dry-run` trailer makes the build rehearse the
78
+ flow (release-please in dry-run mode, `npm publish --dry-run`, the executables, nothing published, no release
79
+ touched), or locally `bun run release -- --dry-run` (needs `GH_TOKEN` with read access and `NPM_TOKEN`).
80
+ - A release cut by hand also publishes: `gh release create vX.Y.Z --notes-file notes.md` after bumping `package.json`
81
+ to X.Y.Z on `master`; the `release` event starts a build of the tagged commit, whose release step publishes it.
75
82
  - Publishing from a machine: `bun run check && bun test`, then
76
83
  `NPM_CONFIG_//registry.npmjs.org/:_authToken=$VOIDBASE_NPM_TOKEN npm publish --access public`.
77
84
 
78
- Secrets and permissions: `NPM_TOKEN` (repository secret, an npm granular token with publish rights on the
79
- `@voidbase-cloud` scope). release-please opens the release PR with the workflow's own token only if the
80
- organization allows it (voidbase-cloud > Settings > Actions > General > "Allow GitHub Actions to create and
81
- approve pull requests", then the same switch on the repository); otherwise add `RELEASE_PLEASE_TOKEN`, a
82
- fine-grained PAT with contents and pull requests write on this repository, which the workflow prefers when
83
- present and which also makes CI run on the release PR (the workflow's own token cannot trigger other workflows).
84
- GitHub Packages and the release assets use the workflow's `GITHUB_TOKEN`.
85
+ Secrets and permissions. The master trigger on Cloudflare holds `GH_TOKEN` (a fine-grained PAT with contents, pull
86
+ requests and issues write on this repository: release-please opens and labels the release PR and creates the release
87
+ with it, the assets are uploaded with it), `NPM_TOKEN` (an npm granular token with publish rights on the
88
+ `@voidbase-cloud` scope) and optionally `GH_PACKAGES_TOKEN` (a classic PAT with `write:packages`; without it the
89
+ GitHub Packages copy is skipped). `bun scripts/cf-builds.ts setup` stores them from the environment; builds of other
90
+ branches never carry them. GitHub itself holds only what the workflow needs to start builds: the secret
91
+ `CLOUDFLARE_BUILDS_TOKEN` and the trigger variables. release-please's PR needs no organization setting for Actions,
92
+ since a PAT opens it.
85
93
 
86
94
  Consumers: `bun add @voidbase-cloud/voidbase`; from GitHub Packages instead, `.npmrc` with
87
95
  `@voidbase-cloud:registry=https://npm.pkg.github.com` and a token with `read:packages`.
package/hooks-plugin.ts CHANGED
@@ -21,24 +21,25 @@ const ASYNC_PROPS = new Set([
21
21
  "findAuthRecordByEmail", "findAuthRecordByToken", "expandRecord", "expandRecords",
22
22
  "fileFromURL", "fileFromBytes", "fileFromPath", "bindBody", "requestInfo",
23
23
  "importCollections",
24
+ "queueJob", // $jobs.queueJob hands work to the jobs queue
24
25
  ]);
25
26
 
26
27
  export const HOOK_GLOBALS = [
27
- "$app", "$apis", "$http", "$os", "$filesystem", "$security", "$mails", "$template", "$dbx",
28
+ "$app", "$apis", "$http", "$os", "$filesystem", "$security", "$mails", "$template", "$dbx", "$env", "$jobs",
28
29
  "routerAdd", "routerUse", "cronAdd", "cronRemove", "migrate",
29
30
  "Record", "Collection", "RecordUpsertForm", "MailerMessage", "DateTime", "RequestInfo",
30
31
  "Field", "TextField", "EditorField", "NumberField", "BoolField", "EmailField", "URLField", "DateField", "AutodateField", "SelectField", "FileField", "RelationField", "JSONField", "GeoPointField", "PasswordField",
31
32
  "ApiError", "NotFoundError", "BadRequestError", "ForbiddenError", "UnauthorizedError", "InternalServerError", "ValidationError",
32
33
  "__hooks", "require", "module", "exports", "console", "toString", "sleep", "arrayOf", "unmarshal",
33
34
  ];
34
- const EVENT_HOOKS = ["Bootstrap", "Serve", "Terminate", "BackupCreate", "BackupRestore",
35
+ export const EVENT_HOOKS = ["Bootstrap", "Serve", "Terminate", "BackupCreate", "BackupRestore",
35
36
  "ModelValidate", "ModelCreate", "ModelCreateExecute", "ModelAfterCreateSuccess", "ModelAfterCreateError", "ModelUpdate", "ModelUpdateExecute", "ModelAfterUpdateSuccess", "ModelAfterUpdateError", "ModelDelete", "ModelDeleteExecute", "ModelAfterDeleteSuccess", "ModelAfterDeleteError",
36
37
  "RecordEnrich", "RecordValidate", "RecordCreate", "RecordCreateExecute", "RecordAfterCreateSuccess", "RecordAfterCreateError", "RecordUpdate", "RecordUpdateExecute", "RecordAfterUpdateSuccess", "RecordAfterUpdateError", "RecordDelete", "RecordDeleteExecute", "RecordAfterDeleteSuccess", "RecordAfterDeleteError",
37
38
  "CollectionValidate", "CollectionCreate", "CollectionCreateExecute", "CollectionAfterCreateSuccess", "CollectionAfterCreateError", "CollectionUpdate", "CollectionUpdateExecute", "CollectionAfterUpdateSuccess", "CollectionAfterUpdateError", "CollectionDelete", "CollectionDeleteExecute", "CollectionAfterDeleteSuccess", "CollectionAfterDeleteError",
38
39
  "MailerSend", "MailerRecordAuthAlertSend", "MailerRecordPasswordResetSend", "MailerRecordVerificationSend", "MailerRecordEmailChangeSend", "MailerRecordOTPSend",
39
40
  "RealtimeConnectRequest", "RealtimeMessageSend", "RealtimeSubscribeRequest",
40
41
  "SettingsListRequest", "SettingsUpdateRequest", "SettingsReload", "FileDownloadRequest", "FileTokenRequest",
41
- "RecordAuthRequest", "RecordAuthWithPasswordRequest", "RecordAuthRefreshRequest", "RecordRequestPasswordResetRequest", "RecordConfirmPasswordResetRequest", "RecordRequestVerificationRequest", "RecordConfirmVerificationRequest", "RecordRequestEmailChangeRequest", "RecordConfirmEmailChangeRequest", "RecordRequestOTPRequest", "RecordAuthWithOTPRequest",
42
+ "RecordAuthRequest", "RecordAuthWithPasswordRequest", "RecordAuthWithOAuth2Request", "RecordAuthRefreshRequest", "RecordRequestPasswordResetRequest", "RecordConfirmPasswordResetRequest", "RecordRequestVerificationRequest", "RecordConfirmVerificationRequest", "RecordRequestEmailChangeRequest", "RecordConfirmEmailChangeRequest", "RecordRequestOTPRequest", "RecordAuthWithOTPRequest",
42
43
  "RecordsListRequest", "RecordViewRequest", "RecordCreateRequest", "RecordUpdateRequest", "RecordDeleteRequest",
43
44
  "CollectionsListRequest", "CollectionViewRequest", "CollectionCreateRequest", "CollectionUpdateRequest", "CollectionDeleteRequest", "CollectionsImportRequest", "BatchRequest",
44
45
  ].map((n) => "on" + n);
@@ -126,10 +127,15 @@ function transform(sf: ts.SourceFile, asyncNames: Set<string>, asyncFns: Set<ts.
126
127
  return out;
127
128
  }
128
129
 
130
+ // Generated bundles in pb_hooks must skip the await insertion below: it rewrites calls by method name (`delete`,
131
+ // `next`, `send` ...), which in ordinary bundled code turns the wrong functions async and breaks their callers. A
132
+ // file whose first line is `// voidbase:raw` is emitted as it stands, with only module/exports/require in scope.
133
+ const isRaw = (code: string) => /^\s*\/\/\s*voidbase:raw\b/.test(code);
134
+
129
135
  export function compileHooksDir(dir: string): string {
130
136
  const files = readDir(dir);
131
137
  const sources = new Map<string, ts.SourceFile>();
132
- for (const f of files) if (f.kind !== "file") sources.set(f.name, ts.createSourceFile(f.name, f.code, ts.ScriptTarget.ES2022, true, ts.ScriptKind.JS));
138
+ for (const f of files) if (f.kind !== "file" && !isRaw(f.code)) sources.set(f.name, ts.createSourceFile(f.name, f.code, ts.ScriptTarget.ES2022, true, ts.ScriptKind.JS));
133
139
  const asyncNames = new Set<string>();
134
140
  const asyncFns = new Set<ts.Node>();
135
141
  for (let i = 0; i < 20; i++) {
@@ -143,6 +149,11 @@ export function compileHooksDir(dir: string): string {
143
149
  const raw: string[] = [];
144
150
  for (const f of files) {
145
151
  if (f.kind === "file") { raw.push(`${JSON.stringify(f.name)}: ${JSON.stringify(f.code)}`); continue; }
152
+ if (isRaw(f.code)) {
153
+ // no destructure of every global: a bundle declares its own names and would collide with them
154
+ modules.push(`${JSON.stringify(basename(f.name, ".js"))}: async function (__g) { const { module, exports, require } = __g;\n${f.code}\nreturn module.exports; }`);
155
+ continue;
156
+ }
146
157
  const body = transform(sources.get(f.name)!, asyncNames, asyncFns);
147
158
  if (f.kind === "hook") hooks.push(`{ name: ${JSON.stringify(f.name)}, run: async function (__g) { ${destructure}\n${body}\n} }`);
148
159
  else modules.push(`${JSON.stringify(basename(f.name, ".js"))}: async function (__g) { ${destructure}\n${body}\nreturn module.exports; }`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voidbase-cloud/voidbase",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "PocketBase-compatible backend on Cloudflare Workers (D1, R2, Queues, Durable Objects) via Void, or a single Bun process",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -35,7 +35,9 @@
35
35
  "./api": "./src/server/api.ts",
36
36
  "./passkeys": "./src/server/webauthn.ts",
37
37
  "./cloud": "./src/cloud/rest.ts",
38
- "./bundle": "./src/node/bundle.ts"
38
+ "./bundle": "./src/node/bundle.ts",
39
+ "./adapter": "./src/adapter/runtime.ts",
40
+ "./adapter/plugin": "./src/adapter/index.ts"
39
41
  },
40
42
  "imports": {
41
43
  "#platform/env": {
@@ -110,11 +112,14 @@
110
112
  "surface": "bun surface/render.ts",
111
113
  "test": "bun test",
112
114
  "app:sync": "bun scripts/sync-app.ts",
113
- "check": "void prepare && tsc -p tsconfig.json --noEmit && tsc -p tsconfig.node.json --noEmit",
115
+ "check": "void prepare && tsc -p tsconfig.json --noEmit && tsc -p tsconfig.node.json --noEmit && tsc -p tsconfig.scripts.json --noEmit",
114
116
  "pack:check": "npm pack --dry-run",
115
117
  "prepublishOnly": "bun run check && bun test",
116
118
  "prepare": "husky || true",
117
- "build:exe": "bun scripts/build-exe.ts"
119
+ "build:exe": "bun scripts/build-exe.ts",
120
+ "ci": "bash scripts/ci.sh",
121
+ "release": "bash scripts/release.sh",
122
+ "cf:builds": "bun scripts/cf-builds.ts"
118
123
  },
119
124
  "dependencies": {
120
125
  "@cf-wasm/photon": "^0.4.0",
@@ -1,11 +1,6 @@
1
1
  // Every /api/* request is handled by the voidbase Hono app (PocketBase wire protocol).
2
2
  import { defineHandler } from "void";
3
3
  import { app } from "../../src/server/app";
4
- import { appApi } from "../../src/server/api";
5
- import { mountWebAuthn } from "../../src/server/webauthn";
6
-
7
- // this checkout serves the pocketbase-sveltekit-starter, whose backend registers passkey routes (pb/webauthn)
8
- mountWebAuthn(appApi().router);
9
4
 
10
5
  const handle = defineHandler((c) =>
11
6
  app.fetch(c.req.raw, c.env, (c as unknown as { executionCtx?: ExecutionContext }).executionCtx),