ADR 001: PBVex Deployment Protocol v1
Status
Accepted - protocol v1 compatibility contract.
Context
PBVex exposes a Convex-shaped TypeScript developer experience on a generic PocketBase-based Go binary. The backend, CLI, and client SDKs share this versioned contract: JSON types, validator descriptors, wire values, canonical hashing, and HTTP/realtime endpoints.
Decisions
1. Protocol version
- The deployment protocol is fixed at
protocolVersion: "v1"for this phase. - All agents must reject manifests with a
protocolVersionother thanv1.
2. Deployment manifest
A valid v1 manifest is a JSON object:
{
"protocolVersion": "v1",
"deploymentId": "<identifier>",
"functions": [
{
"name": "<identifier>",
"type": "query" | "mutation" | "action" | "httpAction",
"visibility": "public" | "internal",
"modulePath": "<bundle-path>",
"exportName": "default" | "<identifier>",
"args": <optional-validator-descriptor>,
"returns": <optional-validator-descriptor>,
"route": <optional-http-action-route>
}
],
"schema": { "tables": [] },
"components": { "definitions": [], "mounts": [] },
"emailTemplates": { "sha256": "<lowercase-sha256>", "entries": [] },
"cronJobs": [],
"migrations": [],
"config": {
"httpPathPrefix": "/api/pbvex",
"realtimePath": "/api/pbvex/realtime",
"maxUploadBytes": 67108864,
"maxFunctionArgsBytes": 1048576,
"maxReturnValueBytes": 1048576,
"defaultRequestTimeoutMs": 30000
}
}deploymentIdmust be a valid identifier.functionsis optional and may be empty to allow schema-only deployments.schema,components,emailTemplates,cronJobs, andmigrationsare optional first-class deployment definitions with strict bounded validators.functionsentries must be validFunctionDescriptorobjects.modulePathis a relative POSIX-like bundle path (no leading/, no..,[a-zA-Z0-9._/\-]+).exportNameis"default"or a valid identifier.argsandreturnsare optional validator descriptors; when present they must satisfy the bounded descriptor grammar implemented by both TypeScript and Go.routeis only valid forhttpActionfunctions and declares an HTTP method with either an exact path or a path prefix.
3. Naming grammar
- Identifiers:
^[a-zA-Z][a-zA-Z0-9_]*$, max length 1024. - Module paths: relative POSIX-like paths, no leading
/, no.., only[a-zA-Z0-9._/\-]+, max length 4096. - Object field names: non-empty, max 1024, ASCII printable (
0x20-0x7E), cannot start with$, cannot be__proto__,constructor, orprototype.
4. Canonical JSON and content hashing
- Canonical JSON is a deterministic string with:
- No whitespace.
- Object keys sorted lexicographically.
- Numbers as finite JSON numbers (
-0normalizes to0byJSON.stringify). - Strings escaped with
JSON.stringify()behavior.
- Canonical JSON rejects non-finite numbers,
undefined(including object properties), cyclic references,bigint,symbol,function, and objects with unsafe prototypes. - Content hash is
SHA-256(UTF-8 bytes of canonical JSON)returned as lowercase hex. - Bundle hash is
SHA-256(raw bundle bytes)of the deterministic executable JS bundle.
5. Wire value codec
The codec supports the following values:
null- booleans
- finite
number(NaN/Infinity are rejected on both encode and decode) string- 64-bit signed
bigint(int64), encoded as{ "$integer": "<base64-little-endian-8-bytes>" } ArrayBufferbytes, encoded as{ "$bytes": "<base64>" }- arrays
- plain objects (no unsafe prototypes, no cyclic data, no
$-prefixed or reserved keys; properties whose values areundefinedare omitted by the wire encoder) Id(a branded string; encoded as a plain string for Convex wire compatibility)
New document IDs use a canonical authenticated pbv2 envelope containing the key version, exact namespace (root or a deterministic component namespace), logical table, and raw record ID. Clients may validate the structural envelope but cannot mint IDs. The backend authenticates every v.id at database, function, component-mount, and schema-migration boundaries. Authenticated legacy pbv1 IDs are accepted only for the root namespace migration path.
Top-level and array-element undefined values are not valid in the codec. Plain-object properties whose values are undefined are omitted before encoding, and a function's top-level undefined return is normalized to null.
6. Deployment endpoints
POST /api/pbvex/deployments— upload a new deployment.- Request body:
DeploymentUploadRequestJSON envelope:json{ "manifest": <DeploymentManifest>, "bundle": "<base64-encoded deterministic executable JS bundle>", "sha256": "<lowercase-hex SHA-256 of decoded bundle bytes>", "size": <non-negative-integer>, "modules": <optional-array-of-authenticated-module-sources> } - The backend decodes the base64 bundle, verifies byte length and SHA-256, and stores the manifest and bundle as a single deployment. When the manifest declares components,
modulesis required and contains bounded{ path, bytes }entries whose base64 source bytes authenticate every declared component module hash. - Response:
{ deploymentId, bundleHash, acceptedAt }.
- Request body:
GET /api/pbvex/deployments— list stored deployments.- Response:
DeploymentListResponse.
- Response:
POST /api/pbvex/deployments/:id/activate— activate a previously uploaded deployment.- Body:
{ atomic: true }. - The backend verifies and compiles the candidate runtime before opening the database transaction. It then applies migrations and schema/component changes and swaps the active deployment in one transaction. Any failure leaves the previous deployment active.
- Response:
DeploymentActivateResponsewithdeploymentId,activatedAt, optionalpreviousDeploymentId, and optional migration-utilizationwarnings.
- Body:
POST /api/pbvex/deployments/:id/rollback— rollback to the previous active deployment.- Response:
DeploymentRollbackResponsewithdeploymentId,rolledBackAt, and optionalrestoredDeploymentId.
- Response:
POST /api/pbvex/call- HTTP envelope for public query/mutation/action calls.POST /api/pbvex/realtime- primary SSE transport for one public query subscription per request.- Request JSON is
{ id, path, args }, whereidis derived from the protocol version, function path, and canonical encoded arguments. GET /api/pbvex/realtime?id=...&path=...&args=...is a strictly bounded compatibility fallback.- Clients must request
Accept: text/event-stream; POST also requiresContent-Type: application/json. - Events are SSE
data: <RealtimeEnvelope>records withsubscribe,message, andpingoperations. - Record mutations invalidate active subscriptions. Deployment activation closes connections so clients reconnect against the new immutable snapshot and limits.
- Request JSON is
7. Error envelope
All structured errors are JSON objects:
{
"error": true,
"code": "<error-code>",
"message": "<string>",
"details": [],
"requestId": "<optional>"
}Core error codes are bad_request, invalid_manifest, invalid_function, bundle_not_found, bundle_hash_mismatch, activation_failed, not_found, unauthorized, forbidden, and internal. Service-specific protocol errors include storage admission states such as upload_expired, upload_consumed, upload_pending, and storage_full. Clients must preserve unknown string codes for forward compatibility.
8. Atomic activation/rollback semantics
- Activation is all-or-nothing for a single deployment from the caller's perspective.
- The backend validates descriptors and verifies/compiles the candidate bundle before the database transaction. The transaction applies migrations and schema/component materialization, records history, and swaps the active deployment pointer atomically.
- A failed activation does not affect the currently active deployment.
- Rollback restores the previous active deployment atomically.
9. Components
The optional components manifest field contains content-addressed component definitions and a bounded mount graph. Definitions bind their module hashes, schema, argument validator, environment declarations, dependencies, and bundle hash into the component identity. Each mount path derives a stable namespace; mounting the same definition twice isolates its data, while upgrading a definition at the same path preserves that namespace.
Component tables are materialized as deterministic PocketBase collections and recorded in the component ownership catalog. Activation authenticates mount arguments and stored/defaulted IDs against the exact namespace, migrates schema and indexes transactionally, and never silently adopts an unowned physical collection. Removed mounts and tables remain dormant for rollback or a later remount at the same canonical path.
10. Optional schema descriptor
A valid v1 manifest may optionally include a schema field describing the shape and indexes of data tables:
{
"schema": {
"tables": [
{
"tableName": "messages",
"fields": {
"body": { "type": "string" },
"author": { "type": "id", "tableName": "users" }
},
"indexes": [
{ "name": "by_author", "fields": ["author"] }
]
}
]
}
}schemais optional. When present,schema.tablesis an array ofTableDescriptorobjects.- Each table descriptor has an
tableName(identifier),fields(a JSON object mapping field names to validator JSON values), and an optionalindexesarray. - Each index descriptor has a
name(identifier) andfields(array of strings).
11. Runtime host bridge
The CLI emits a single deterministic executable JS bundle as an immediately-invoked function expression (IIFE). The bundle has no Node globals, require, or import statements at runtime. It may be evaluated by the Go runtime (e.g., Goja) with a minimal host bridge exposed on globalThis.__pbvex:
globalThis.__pbvex.registerFunction(descriptor, handler);
globalThis.__pbvex.registerMigration(descriptor, up, down);descriptoris a validFunctionDescriptor(see Decision 2).handleris the function body that the runtime will invoke for calls matchingdescriptor.- The IIFE is responsible for enumerating its exported function objects and registering each one with the host bridge.
- Bundled migration modules register their descriptor and pure
up/downhandlers throughregisterMigration. - The host bridge is the only runtime API available to the bundle; all approved globals (e.g.,
console,Object,Array) are explicitly provided by the host.
12. Compatibility boundaries
- Single-node strong consistency: this protocol assumes a single PocketBase process and does not define multi-node consensus.
- No vector search in v1.
- No arbitrary npm/Node runtime: functions are bundled ahead of time by the CLI into a single deterministic executable JS bundle and the runtime executes the bundle within the Go process.
- HTTP/realtime envelope payloads are opaque to the transport; the wire value codec applies to
body,args, andpayload. - The deploy endpoint stores the manifest and bundle as a single deployment; bundles are not tar/zip archives.
- Scheduler jobs pin the immutable deployment snapshot they target. Realtime subscriptions also invoke the snapshot captured at admission.
- Reserved PBVex routes (deployment, call, realtime, jobs, and storage) take precedence over deployed HTTP action catch-all routes.