Bundles
Declare objects, views, pages and agents as one bundle, sync it to a workspace from code, a script or the CLI, and read what it changed and why it was refused.
A bundle is a set of objects, views, pages and agents declared as one value. bundle(name, label) starts one; the workspace applies it as system definitions, and your users keep working on top of it.
import { bundle, object, text } from "@standards/client";
import { getStandards } from "./client";
const incident = object("incidents", "Incident")
.attribute(text("title", "Title").required())
.labelExpression("{{ title }}");
export const status = bundle("status-site", "Status page")
.description("Incidents shown on the public status page")
.add(incident);
const result = await getStandards().bundles.sync(status);
console.log(result.applied ? "applied" : "unchanged", result.hash);Declare a bundle
bundle(name, label) takes the identity as two positional arguments, like object(name, label). The rest is chained:
| Method | What it does |
|---|---|
.description(text) | A sentence shown wherever the bundle is listed. |
.add(...pieces) | Adds objects, views, pages and agents, in any mix. Call it as many times as you like. |
.uses(...bundles) | Declares the bundles this one depends on, as imported values. |
.samples(records) | Sample records offered when the bundle is added as a copy (see Synced and added). |
.translations(locale, table) | A translation table for one locale, used when the bundle is added as a copy. |
nameis a lowercase slug (^[a-z0-9][a-z0-9-]{0,62}$) and is the identity.coreis reserved for the framework's own bundle. A bundle has no icon.- Pieces are added explicitly. A relation points at a name, so a relation target that is in neither the bundle nor a
.uses()bundle is expected to exist already in the workspace; the server checks it at sync. - A piece belongs to one bundle.
build()refuses an object present in the bundle and in one of its.uses()bundles with aDuplicateErrornaming both. Across unrelated bundles, the server refuses at sync (see Refusals). - Nothing registers globally and nothing runs at import.
await status.build()does the work, once, and returns the plain artifact a sync sends:{ name, label, description, hash, objects, views, pages, agents, uses, samples, translations }. Any change to the builder clears the memo. - The
hashis the sha256 of the canonical JSON of the objects, views, pages and agents, each sorted by name, computed with Web Crypto. A label or description change alone does not change it. - Every object builder stays on the bundle so
createStandards({ bundles: [status] })can infer record types. The client keybundlesis reserved: an object namedbundlesis refused.
The hash is the version a workspace holds. There is no version number to bump.
Three ways to sync
Each layer is optional above the previous one, and none locks you in.
1. The call
standards.bundles.sync(bundle, { resolutions? }) reads GET /bundles/:name (a 404 only means the workspace never received it), returns { applied: false, hash } when the hash, label and description all match, and otherwise sends PUT /bundles/:name with the built artifact. It runs from boot, a script, CI or a serverless function. Behind it is one public route, so any language can sync without the SDK.
resolutions resolve a rename or retype the server refused as ambiguous: see Objects. They travel outside the hash.
2. Discovery
discoverBundles({ cwd?, patterns? }) and defineConfig come from @standards/cli. Discovery imports every file matching the patterns, with a TypeScript loader, and returns each exported bundle() value, default or named. The default pattern is standards/bundles/*.ts. A standards.config.ts overrides it:
import { defineConfig } from "@standards/cli";
export default defineConfig({ bundles: ["src/**/*.bundle.ts", "packages/*/schema/index.ts"] });Several bundles per file are allowed and file names carry no meaning: the identity is name. Two bundles with the same name are an error naming both files. A compiled app does not discover; it imports its bundles and calls bundles.sync.
3. The CLI
standards bundles sync # discover, then sync each bundle, dependencies first
standards bundles list # name, mode, origin, hash, installedAt
standards bundles remove status-site --yessync orders bundles by .uses() and prints <name> applied <hash> or <name> unchanged <hash> for each; the first refusal stops it with exit code 1. remove demotes the bundle's objects to custom objects and refuses to run without --yes. See CLI / Schema and bundles.
What one sync does
The server recomputes the hash, checks ownership of every name, applies the resolutions, and writes the bundle's own slice under a per-workspace lock. A sync writes and checks only that slice: objects owned by core or by other bundles are read, never written, and cannot refuse it.
interface SyncBundleResult {
applied: boolean;
hash: string;
objects?: { created: number; updated: number; deleted: number };
views?: { created: number; updated: number; adopted: number; released: number };
}objects and views are present after a PUT. deleted counts attributes and objects that left the bundle; adopted counts workspace views the bundle claimed for the first time; released counts bundle views handed back to the workspace. Print it in CI so a deploy log says what the schema did.
Synced and added
A workspace holds a bundle in one of two modes.
| Mode | What happens | Who uses it |
|---|---|---|
| Synced | The bundle keeps owning its pieces. Each new hash converges them, and their structure is not editable in the app. | Core, the bundles a host declares in code, and the bundles an app pushes over HTTP. |
| Added | A copy lands in the workspace, which owns it and edits it freely. | Bundles a host offers for adding. See the licensed docs. |
Each installation records its mode, hash, origin (code for core and host bundles, http for a pushed one, none for an added copy), when and by whom. The same bundle can be synced in one workspace and added in another.
Owners
Every piece belongs to an owner: core for the framework's own objects, views and system agents, or bundle:<name>. Only views store the owner; an object's owner follows from the bundle that declares it. Core is tracked by hash like any bundle, so there is no core version to bump.
Refusals
A workspace actor never writes, over HTTP, a bundle declared in code, and never turns an added bundle into a synced one. PUT /bundles/:name refuses, with the bundle named in the message:
- the name
core; - a name the host declares in code;
- a name installed as an added copy;
- a bundle whose
usesnames a bundle the workspace does not hold; - agents or pages, which arrive over HTTP later: an HTTP bundle carries objects and views.
DELETE /bundles/:name refuses core, a name declared in code, an added copy, and a bundle another installed bundle uses, naming the dependents.
An HTTP bundle declares at most 50 objects and 200 views, only views for its own objects. The routes are exempt from rate limiting: a CI push carries the whole schema in one call.
Errors
Two errors are mapped on the PUT only; the GET lets everything but a 404 through as a StandardsRequestError.
| Error | Status | Carries |
|---|---|---|
BundleConflictError | 409 | objectName and owner (core or bundle:<name>). A declared view that clashes with the workspace's views, such as a second default view for the same object and context, is a 409 too; objectName and owner are then empty and the message says what clashed. |
BundleValidationError | 400 | The server message: bad name, oversize payload, hash mismatch, invalid definition, a refusal above, an ambiguous rename or retype (SCHEMA_AMBIGUOUS_CHANGE), or a declared view the workspace cannot seed: Failed to sync declared views: <object>:<view> (<reason>). |
Both extend StandardsRequestError; a 401 or 403 arrives as StandardsAuthError. Check the specific classes first.
import { BundleConflictError, BundleValidationError } from "@standards/client";
try {
await standards.bundles.sync(status);
} catch (error) {
if (error instanceof BundleConflictError) {
console.error(`"${error.objectName}" is owned by ${error.owner}`);
} else if (error instanceof BundleValidationError) {
console.error(error.message);
} else {
throw error;
}
}A sync script for CI
Give the sync its own script and let the process exit code carry the outcome. 0 means applied or unchanged; 1 means the workspace refused the bundle and a human has to look.
import { BundleConflictError, BundleValidationError, StandardsAuthError } from "@standards/client";
import { getStandards } from "./client";
import { status } from "./schema";
try {
const result = await getStandards().bundles.sync(status);
console.log(result.applied ? "schema.applied" : "schema.unchanged", result.hash);
process.exit(0);
} catch (error) {
if (error instanceof BundleConflictError) {
console.error("schema.conflict", error.objectName, error.owner);
process.exit(1);
}
if (error instanceof BundleValidationError || error instanceof StandardsAuthError) {
console.error("schema.rejected", error.message);
process.exit(1);
}
throw error;
}| Exit code | Meaning |
|---|---|
0 | Applied, or unchanged |
1 | Conflict (409), rejected definition (400), or refused key (401 / 403) |
Anything else (a timeout, a 5xx) is rethrown and fails the step with a stack trace, which is what you want from an outage.
Next steps
- Schema ownership: what the workspace keeps and what it demotes
- Errors: the whole error hierarchy and
instanceoforder - Records: operate the objects you just pushed
- Status page cookbook: the sync as one deploy step
- Book a demo