@dragonmastery/tamer
v0.29.0
Published
Tamer: Cloudflare Workers infra CLI (sync, apply, deploy, migrate, destroy) and Wrangler-oriented TypeScript types.
Downloads
281
Maintainers
Readme
@dragonmastery/tamer
Tamer tooling monorepo — Cloudflare Workers infrastructure CLI (sync, apply, deploy, migrate, destroy), Wrangler-oriented TypeScript types (generated from the official Wrangler config schema), reference fixtures, and tests for the published @dragonmastery/tamer npm package.
Using Tamer in a downstream repo? Install from npm and follow Consumer quickstart. The rest of this README is for contributors hacking Tamer and for CLI/config reference.
Installation
Contributors (this repo):
bun installRequires Bun and Node.js 22+ (engines.node). Run the CLI with bun run tamer -- … or bun src/cli/index.ts … (see Developing).
Published package (downstream repos):
npm install -D @dragonmastery/tamer wranglerAlso requires the wrangler peer (>=4.0.0). Install, scripts, stack layout, and first deploy: Consumer quickstart.
Consumers (downstream repos)
Install from npm and walk through bootstrap → apply → deploy in docs/consumer-quickstart.md. Adopting existing D1/R2/workers without CF IDs in config: docs/brownfield-adoption.md. Resource kinds, ${tamer:import:…}, outputs, and CLI flags remain documented below for reference.
Types usage
import type {
WranglerConfig,
WranglerEnvironment,
WranglerD1Database,
WranglerR2Bucket,
WranglerKVNamespace,
} from "@dragonmastery/tamer";
const config: WranglerConfig = {
name: "my-worker",
main: "src/index.ts",
compatibility_date: "2025-01-01",
d1_databases: [{ binding: "DB", database_id: "xxx" }],
};| Type | Description |
| --------------------- | ------------------------------------------- |
| WranglerConfig | Full wrangler config (alias: RawConfig) |
| WranglerEnvironment | Environment block (alias: RawEnvironment) |
| WranglerD1Database | D1 binding entry |
| WranglerR2Bucket | R2 binding entry |
| WranglerKVNamespace | KV binding entry |
CLI
tamer bootstrap Create per-env Tamer metadata: `tamer-state-<env>` (D1) and `tamer-artifacts-<env>` (R2)
tamer sync Sync local state from Cloudflare (no writes)
tamer apply Provision missing D1 / R2 / KV / Queues / Hyperdrive / Vectorize / AI Gateways / Pipelines / Workflows / Secrets Stores / DNS records / dispatch namespaces
tamer migrate Run D1 migrations per worker
tamer deploy wrangler deploy per worker (after sync), then apply zone-name `tamerRoutes` via Workers Routes API
tamer dev wrangler dev (use --all for every worker)
tamer status Show config vs state (optional `--tenant product:workspace`)
tamer drift Compare state vs Cloudflare and report differences (read-only)
tamer plan CloudFormation-style preview: what `apply`+`deploy` would create (read-only)
tamer import Register an existing Cloudflare resource into state by logical name
tamer doctor Verify `CLOUDFLARE_*` credentials against the account API (`--json` supported)
tamer provision-tenant Runtime: create tenant D1 + upload dispatch script (`--main` or `--artifact-key`)
tamer destroy-tenant Runtime: remove tenant script + D1 + state (shared envs: `--confirm-tenant`)
tamer destroy Remove workers + storage + namespaces for an env
tamer wfp put Upload a single-module Worker to a dispatch namespace
tamer wfp delete Delete a Worker from a dispatch namespaceCommon flags: --env <name> (required for every command; tamer deploy --env <name> no longer silently defaults to prod), --worker <name>, --config <path>, --force, --confirm-env <name> (destroy: required for any env in tenant.protectedEnvs from tamer.config.ts — defaults to ["prod","production"] when unset; pass protectedEnvs: ["prod","production","production-eu","qa"] etc. to widen the gate, or [] to opt out — unless --force), --confirm-tenant <workspace> (destroy-tenant: same rule, sourced from tenant.protectedEnvs), --skip-workers (destroy), --wipe-metadata (destroy: delete shared tamer-state-<env> D1 and tamer-artifacts-<env> R2; use on the last stack in a multi-stack teardown), --dispatch-namespace <name> (deploy), --json (drift / plan: emit machine-readable JSON), --detailed-exitcode (plan: exit 2 instead of 0 when there are pending changes — Terraform-style CI gate), --destroy (plan: preview deletions instead of creates/updates — read-only tamer destroy dry-run).
tamer import flags: --kind d1|r2|kv|queue|hyperdrive|vectorize|ai_gateway|pipeline|workflow|secret_store|dns_record|dispatch_namespace|worker_route, --logical <name> (logical name from tamer.config.ts, or worker key for worker_route), --cf-id <id> (D1 uuid, KV id, R2 bucket name, queue id, hyperdrive config id, vectorize index name, AI gateway id, pipeline id, workflow id, secrets store id, DNS record id, or dispatch namespace name), --shard-date <YYYY-MM-DD> (sharded D1 only), --created-date <YYYY-MM-DD> (R2: optional, defaults to extracting from name), --route-id <id> and --zone-name <z> (worker_route).
Environment variables
Tamer uses the same env vars as Wrangler — see system environment variables:
CLOUDFLARE_ACCOUNT_IDCLOUDFLARE_API_TOKENR2_ACCESS_KEY_ID/R2_SECRET_ACCESS_KEY(optional) — R2 S3 API credentials. When both are set,tamer destroyempties managed R2 buckets (delete all objects, abort incomplete multipart uploads) before removing the bucket. Not the same asCLOUDFLARE_API_TOKEN.TAMER_DEV_BASE_PORT(default8787, used bydev --all)
Bun loads .env from the current working directory automatically when you run bun src/cli/index.ts ..., so you can keep credentials in a per-project .env (gitignored) instead of exporting them.
API token scopes
Custom token, scoped to the account you target. Names match Cloudflare’s API token permissions reference:
| Permission | Scope | Used by |
| ------------------------- | ------- | ------------------------------------------------------------------------ |
| Workers Scripts: Edit | Account | deploy, destroy (worker delete), wfp put, dispatch-namespace ops |
| Workers KV Storage: Edit | Account | KV apply / destroy / sync |
| Workers R2 Storage: Edit | Account | R2 apply / destroy / sync |
| D1: Edit | Account | D1 apply / destroy / migrate / sync |
| Queues: Edit | Account | Queues apply / destroy / sync / drift / import |
| Hyperdrive: Edit | Account | Hyperdrive apply / destroy / sync / drift / import |
| Vectorize: Edit | Account | Vectorize apply / destroy / sync / drift / import |
| AI Gateway: Edit | Account | AI Gateway apply / destroy / sync / drift / import |
| Pipelines: Edit | Account | Pipelines apply / destroy / sync / drift / import |
| Workflows: Edit | Account | Workflows apply / destroy / sync / drift / import |
| Secrets Store: Edit | Account | Secrets Store apply / destroy / sync / drift / import |
| DNS: Edit | Zone | DNS records apply / destroy / sync / drift / import (zones declared in dnsRecords[]) |
| Logs: Edit (or Logpush: Edit) | Account | logpushJobs in tamer.config.ts: create/delete Workers trace Logpush jobs (Workers Logpush) |
| Workers R2 Data Catalog: Edit | Account | pipelinesAuto (apply token): calls POST …/r2-catalog/{bucket}/enable and …/credential on your CLOUDFLARE_API_TOKEN — not the minted “catalog+sink” sub-token. With Workers R2 Storage: Edit on the catalog bucket for Pipelines. |
| API Tokens: Edit (or equivalent) | Account | pipelinesAuto (apply token): list permission groups and create / delete the two account sub-tokens only. |
| Account Settings: Read | Account | Account-scoped reads (and required by Wrangler subprocesses) |
| Workers Tail: Read | Account | Optional, only for wrangler tail |
| Workers Routes: Edit | Zone | Zone-name tamerRoutes: list/create/delete routes (deploy, sync, destroy, drift). Skip if you only use wrangler-native routes or custom-domain tamerRoutes. |
| Zone: Read | Zone | Resolving zone_name → zone id for those routes (same zones as above). |
The dashboard template “Edit Cloudflare Workers” covers most of these; add D1: Edit manually.
Workers trace → Pipelines → Iceberg (pipelinesAuto): On tamer apply, Tamer mints two account sub-tokens, in line with the dashboard: Workers R2 Data Catalog (Edit/Write) only for the stored catalog credential and the r2_data_catalog sink, and Workers Pipelines Send only for Logpush stream ingest. Values are stored in the logpush_pipelines state entry; treat that state as sensitive. Your CLOUDFLARE_API_TOKEN must also be allowed to call the R2 Data Catalog account HTTP API (/accounts/.../r2-catalog/... for enable + credential) on its own — that uses the apply token, not the sub-tokens. The same apply token also needs the account Token API, Logpush, Pipelines, etc. (table above, Account Token API). If sink creation fails with HTTP 422 / code 1012 (existing catalog table), change pipelinesAuto.tableName and/or namespace to a name that does not already exist in that catalog bucket.
For read-only flows (status, dry runs), use the Read variants instead.
Resource kinds
Tamer-managed resources are declared on a worker under resources. Each kind expands to a Wrangler binding plus a state row keyed by derived name.
defineWorker({
resources: {
d1: [{ logicalName: "settings", type: "single" }],
r2: [{ logicalName: "assets" }],
kv: [{ logicalName: "cache" }],
queues: [
{ logicalName: "events" }, // producer binding emitted
{ logicalName: "audit", consumerOnly: true }, // queue created, no producer binding
],
hyperdrive: [
{
logicalName: "primary",
origin: {
scheme: "postgres",
host: "db.example.com",
port: 5432,
database: "app",
user: "app",
password: { fromEnv: "PG_PW" }, // never persisted in state
},
localConnectionString: "postgres://localhost/app",
},
],
vectorize: [
{ logicalName: "embeddings", dimensions: 768, metric: "cosine" },
],
aiGateway: [
{
logicalName: "openai",
cacheTtl: 60, // seconds; 0 = caching off (default)
authentication: true, // require Authorization header on gateway endpoint
rateLimitingInterval: 60,
rateLimitingLimit: 100,
},
],
pipelines: [
{
logicalName: "events",
// Arroyo SQL — `events_stream` and `events_sink` must already exist on Cloudflare.
sql: "insert into events_sink select * from events_stream;",
},
],
workflows: [
{
logicalName: "billing",
className: "BillingWorkflow", // class exported from this worker's entrypoint
// scriptName defaults to the owning worker's deployed script — pin it
// here only to bind to a different worker's exported class.
limits: { steps: 50 }, // optional — tracked in state for drift
},
],
secretsStores: [
// Tamer manages the *store* (account-scoped container). Secret values
// inside it are written out-of-band via wrangler / dashboard / CI.
{ logicalName: "apiKeys" },
],
secretsStoreSecrets: [
// Wire one previously-uploaded secret into this worker. The wrangler
// generator resolves `store: "apiKeys"` to the live store_id from state.
{ binding: "STRIPE_KEY", store: "apiKeys", secretName: "stripe_key" },
],
},
vars: {
// No wrangler binding for AI Gateway — pull the derived id into env vars instead.
AI_GATEWAY_ID: "${tamer:ai_gateway:openai.name}",
},
});Notes:
- Queues: Tamer creates the queue itself on
applyand emitsqueues.producers[]in the generated wrangler config. Consumer subscriptions stay wrangler-side — set them on the worker config underqueues.consumers(passed through verbatim). - Hyperdrive: the origin connection (including
passwordand optionalaccess_client_secret) is sent to Cloudflare onapplyand discarded — Tamer's state only records the resulting config id, scheme, host and database name. Use{ fromEnv: "VAR" }to source secrets from the shell environment so they never entertamer.config.ts.localConnectionStringis written to the generated wrangler config forwrangler devonly. - Vectorize:
dimensionsandmetric(cosine|euclidean|dot-product) are immutable once the index is created. To change them, runtamer destroy --resource <logicalName>(or scope to the kind) and re-apply. The generated wrangler config emitsvectorize[]bindings using the derived index name. - AI Gateway: gateways are account-scoped and have no Wrangler binding kind — Workers reference them per-request via
env.AI.run(model, opts, { gateway: { id } })(or by URL on the OpenAI-compatible endpoint). Tamer therefore emits no wrangler fragment for AI Gateway; use the cross-resource ref${tamer:ai_gateway:<logical>.name}(or.id) in workervarsto inject the derived gateway id. Derived id pattern:aigw-{logical}-t-{tenantId}-{env}. All listedcacheTtl/rateLimiting*/authentication/cacheInvalidateOnUpdate/collectLogsfields are optional with sensible defaults (caching off, rate limiting off, logs on, fixed-window). - Pipelines (V1, SQL): Tamer creates the pipeline against
/accounts/{id}/pipelines/v1/pipelinesand emitspipelines[]in the generated wrangler config. Thesqlfield is mandatory and references streams (sources) and sinks (destinations) by name — those upstream/downstream resources are not Tamer-managed in this iteration; create them via the Cloudflare dashboard or Wrangler before applying, otherwise the pipeline will exist in non-running status. Pipeline SQL changes on existing pipelines requiretamer destroy --resource <logicalName>followed by re-apply (Cloudflare V1 has no PATCH endpoint today). Derived name pattern:pipe-{logical}-t-{tenantId}-{env}. Default binding key:PIPE_{LOGICAL}_T_{TENANT}(override withbinding). - Workflows: Tamer registers the workflow with Cloudflare via
PUT /accounts/{id}/workflows/{name}and emitsworkflows[]in the generated wrangler config.classNameis the exported class on the owning worker;script_namedefaults to that worker's deployed script (env-suffixed for shared envs), so workflow classes co-located with their owning worker need no extra config. SetscriptNameexplicitly to bind to a different worker. Optionallimits.steps(positive integer) is tracked in state and reapplied on drift. Derived name pattern:wf-{logical}-t-{tenantId}-{env}. Default binding key:WF_{LOGICAL}_T_{TENANT}(override withbinding). Workflow registrations are immutable in shape but mutable in target — class-name or script-name changes are issued as in-place PUTs; deletes go throughDELETE /accounts/{id}/workflows/{name}andtamer destroyremoves them transactionally. - DNS records: declared at the stack root (not per worker), since the same record shouldn't be redeclared by every worker that happens to live behind it. Add
dnsRecords[]directly on thedefineConfig({ ... })document. Tamer creates each record viaPOST /zones/{zoneId}/dns_recordsand stamps a stable attribution comment (tamer:<tenantId>:<env>:<logicalName>) so subsequentsync/applyruns can re-adopt the record from a Cloudflare listing even after state loss. Mutable-field drift (content,ttl,proxied,priority,comment) is patched in place viaPATCH /zones/{zoneId}/dns_records/{recordId};typechanges follow Cloudflare's delete-and-recreate convention. State keydns_record:{zoneId}:{type}:{name}. Thelocalenv is always implicitly skipped (DNS is a real-world side effect —wrangler devdoes not own real DNS); useskipEnvsto opt out of additional envs. SetpreserveOnDestroy: trueon a record to keep it on Cloudflare pasttamer destroy(the state row is dropped either way for clean teardown). Requires the DNS: Edit zone-scoped permission for every zone referenced indnsRecords[]. - Secrets Store: Tamer manages account-scoped stores (containers) — never the secret values inside them. On
apply, each declaredsecretsStores[]entry is created viaPOST /accounts/{id}/secrets_store/stores(idempotent — a matching name insecretsStoreListAllshort-circuits to the existingid) and the assigned id is recorded in state. To wire a stored secret into a worker, declare asecretsStoreSecrets[]entry referencing the store by its logical name (store: "apiKeys"); the wrangler generator resolves it to the livestore_idand emits asecrets_store_secrets[]row with{ binding, store_id, secret_name }. The named secret itself must already exist in the store — create it out-of-band viawrangler secrets-store secret create(or the dashboard) so secret material never enterstamer.config.tsortamer-state-*. Derived store name:sec-{logical}-t-{tenantId}-{env}. Cross-resource refs supported (${tamer:secret_store:<logical>.name|id|binding}).tamer destroyremoves managed stores transactionally — Cloudflare cascades the deletion to all contained secrets, so make sure no live worker still binds into the store first.
Stack-scoped DNS records (dnsRecords)
Declared once on the defineConfig({ ... }) root, not per worker. Each entry pins the zone (zoneId), the record type, the DNS name, and the content; everything else is optional.
import { defineConfig } from "@dragonmastery/tamer";
export default defineConfig({
workers: { /* ... */ },
dnsRecords: [
{
logicalName: "apex",
zoneId: "0123456789abcdef0123456789abcdef",
type: "A",
name: "todo.com",
content: "192.0.2.1",
proxied: true,
},
{
logicalName: "www",
zoneId: "0123456789abcdef0123456789abcdef",
type: "CNAME",
name: "www.todo.com",
content: "todo.com",
proxied: true,
},
{
logicalName: "spf",
zoneId: "0123456789abcdef0123456789abcdef",
type: "TXT",
name: "todo.com",
content: "v=spf1 -all",
ttl: 3600,
comment: "anti-spoofing baseline", // appended after Tamer's attribution comment
},
{
logicalName: "mailRouting",
zoneId: "0123456789abcdef0123456789abcdef",
type: "MX",
name: "todo.com",
content: "mail.todo.com",
priority: 10,
preserveOnDestroy: true, // survive `tamer destroy`
},
{
logicalName: "stagingApex",
zoneId: "0123456789abcdef0123456789abcdef",
type: "A",
name: "todo.com",
content: "192.0.2.2",
skipEnvs: ["dev", "prod"], // only created in `staging`
},
],
});Cross-resource references (${tamer:...})
CloudFormation !Ref / !GetAtt analogue. Embed ${tamer:<kind>:<logicalName>.<field>} in worker vars or in tamerRoutes[].host / tamerRoutes[].zone to interpolate values from already-applied state at wrangler-config generation time.
| Kind | name (default) | id | binding |
| --------------------- | ----------------------------- | --------------------------------- | ---------------------- |
| d1 | derived database name | D1 UUID | wrangler binding key |
| r2 | derived bucket name | (unsupported — buckets use name)| wrangler binding key |
| kv | derived namespace title | KV namespace id | wrangler binding key |
| queue | derived queue name | queue id | wrangler binding key |
| hyperdrive | derived config name | hyperdrive config id | wrangler binding key |
| vectorize | derived index name | vectorize index id | wrangler binding key |
| ai_gateway | derived gateway id | derived gateway id | stable cross-ref key (no wrangler binding) |
| pipeline | derived pipeline name | server-assigned pipeline id | wrangler binding key |
| workflow | derived workflow name | server-assigned workflow id | wrangler binding key |
| secret_store | derived store name | server-assigned store id | stable cross-ref key (no wrangler binding) |
| dispatch_namespace | resolved per-env namespace | namespace name | — |
| worker | env-suffixed deployed script | — | — |
Examples:
defineWorker({
resources: { r2: [{ logicalName: "assets" }], queues: [{ logicalName: "events" }] },
vars: {
ASSETS_BUCKET: "${tamer:r2:assets.name}",
EVENTS_QUEUE: "${tamer:queue:events.name}",
INTERPOLATED: "queue=${tamer:queue:events.name};kv=${tamer:kv:cache.id}",
},
tamerRoutes: [
// hosts/zones can also embed references resolved against state
{ host: "api-${tamer:worker:edge.name}.todo.com", zone: "todo.com" },
],
});Resolution behavior:
- Strict mode (
apply,deploy,destroy): unresolved references throwTamerReferenceErrorwith the field path (e.g.worker[default].vars.ASSETS_BUCKET) and a "run apply first" hint, so a typo in<logicalName>or a forgotten apply fails fast. - Tolerant mode (
plan,drift,status,sync): unresolved references are left in place as their original${tamer:...}placeholder, so read-only commands work on a fresh checkout before the first apply.
Stack outputs (outputs)
CloudFormation Outputs analogue: declare named values your stack publishes after a successful apply. Each value is a Tamer reference string (${tamer:<kind>:<logical>.<field>}) — full-string OR interpolated — resolved against the just-completed state and persisted under CfiState.stackOutputs so it survives across runs and is visible to tamer status (and to sibling stacks via ${tamer:import:<stack>.<output>}, shipping next).
export default defineConfig({
tenant: { id: "platform", name: "Platform", slug: "ext" },
worker: { main: "src/index.ts", resources: { d1: [{ logicalName: "users", type: "single" }] } },
outputs: {
usersDbId: "${tamer:d1:users.id}",
eventsQueue: "${tamer:queue:events.name}",
adminUrl: "https://admin.example.com/?db=${tamer:d1:users.id}",
pinnedLiteral: "v1.0.0",
},
});Behavior:
- Output names must match
^[a-zA-Z][a-zA-Z0-9_-]*$(CloudFormation-style identifier — keeps them safe in env-var exports, filenames, and the${tamer:import:…}parser). Validated at config-load time. - Resolution runs at the end of a successful
apply, after every resource is in state. A typo in any output (unknown logical name, unknown field, unknown kind) fails the apply withTamerReferenceErrortaggedoutputs.<name>and rolls back when--rollback-on-failureis set. - Persisted entry shape:
{ value, source, resolvedAt }— keeps both the resolved literal and the original${tamer:...}source sostatuscan flag drift. tamer statusprints a per-output row taggedresolved(declared source matches persisted source),pending(declared but never applied),stale(config edit since last apply — re-run apply), ororphan(persisted but no longer declared in config).tamer destroyclearsstackOutputsafter teardown so a futurestatusdoesn't surface refs to deleted resources.- Re-applies are structurally idempotent: when value + source are unchanged,
resolvedAtis updated in memory but state isn't dirtied (no gratuitousrevisionbump).
Cross-stack imports (${tamer:import:<stack>.<output>})
CloudFormation Fn::ImportValue analogue. One stack publishes named values via outputs: (above); a sibling stack consumes them by referencing ${tamer:import:<stack>.<output>} anywhere a regular ${tamer:...} reference is allowed (worker vars, tamerRoutes[].host / .zone, services[].service, dispatch_namespaces[].namespace, even another stack's outputs). Multiple stacks now coexist in the same tamer-state-<env> D1 by namespacing the state row as cfi_state:{stackName}, so the consumer just reads the producer's row and looks up the named output.
// fixtures/network/tamer.config.ts (the "producer")
export default defineConfig({
stack: { name: "net", description: "Network plane: shared edge router + queue" },
tenant: { id: "platform", name: "Platform", slug: "net" },
worker: { main: "src/edge.ts", resources: { queues: [{ logicalName: "events" }] } },
outputs: {
edgeQueue: "${tamer:queue:events.name}",
region: "iad",
},
});
// fixtures/app/tamer.config.ts (the "consumer")
export default defineConfig({
stack: { name: "app" },
tenant: { id: "platform", name: "Platform", slug: "app" },
worker: {
main: "src/index.ts",
vars: {
EDGE_QUEUE: "${tamer:import:net.edgeQueue}",
REGION: "${tamer:import:net.region}",
QUEUE_URL: "https://${tamer:import:net.region}.example.com/q/${tamer:import:net.edgeQueue}",
},
tamerRoutes: [{ host: "${tamer:import:net.region}.app.example.com", zone: "example.com" }],
},
outputs: {
region: "${tamer:import:net.region}", // republish a sibling's value
},
});Behavior:
- Stack identity.
stack: { name?, description? }pins the producer's row key;namedefaults totenant.slug, so existing single-stack configs keep working unchanged. Output names match^[a-zA-Z][a-zA-Z0-9_-]*$(same asoutputs:). - Pre-fetch once per command. At command start (
apply,deploy,dev,migrate,types,plan,drift,sync,status) Tamer scans the config for${tamer:import:…}sites, hydrates a read-onlyStateManagerper referenced sibling, and flattens everystackOutputs.<output>.valueinto an in-memoryimportsmap. Subsequent reference resolution is a pure map lookup — no D1 round-trips mid-build, no partial-failure modes. - Strict at write time (
apply,deploy): a typo, a sibling that hasn't been applied yet, or an output the sibling never published all fail withTamerReferenceErrortagged with the field path (e.g.worker[default].vars.EDGE_QUEUE) and anAvailable outputs on stack "<name>": …hint. Tolerant at read time (plan,drift,status,sync): unresolved import refs stay in place as their literal${tamer:import:…}placeholder so a fresh checkout works before the first apply. - Self-imports are filtered. A stack that mistakenly references its own name in
${tamer:import:…}is dropped at scan time so the typo doesn't silently resolve via the row apply is about to write — it surfaces as the same "no imported stack available" error a missing sibling would produce. tamer statusshows imports. A new "Inbound imports" panel lists every${tamer:import:…}site grouped by source stack, with the resolved value (or(missing — run \tamer apply` on that stack)) and a per-outputresolved/unresolved` tag — the operator-facing equivalent of CloudFormation's stack-export view.- Greenfield row key. No compat alias for the legacy literal
cfi_staterow key — first run on an env D1 created by an olderbootstrapwill see no state and rebuild fromsync. localenv skips import pre-fetch entirely (no shared state DB exists), so cross-stack composition is a non-localdeployment concern.
Adding a new resource kind (resource registry)
Every Tamer-managed Cloudflare resource (D1, R2, KV, Queues, Hyperdrive, Vectorize, ...) is wired through a single registry at src/core/registry/registry.ts. The CLI commands (apply, sync, drift, destroy, status, import) and the wrangler generator iterate resourceModules — none of them special-case any kind.
To add a new kind (e.g. Pipelines, Workflows, Pages):
- Add the config + state types to
src/types.ts(<Kind>ResourceConfig,<Kind>StateEntry, append toWorkerResourcesandStateEntry). - Implement the per-feature module under
src/features/<kind>/(<kind>.apply.ts,<kind>.sync.ts,<kind>.drift.ts,<kind>.destroy.ts,<kind>.status.ts,<kind>.generate.ts). - Author
src/features/<kind>/<kind>.module.tsexporting aResourceModule(seesrc/core/registry/types.ts); it wires the per-feature functions into the genericapply/sync/drift/destroy/status/generate/importOne/pickResources/fetchAllshape and declares itskind,label,configKey, andstateEntryType. - Append the module to
resourceModulesinsrc/core/registry/registry.ts.
That's it — every command picks the kind up automatically and tamer status, tamer drift, tamer plan, tamer import --kind <kind>, and the generated wrangler.json will all include it.
Routes (tamerRoutes)
Workers can declare HTTP routes that Tamer expands per env per handoff §6:
- prod / production → bare apex (
todo.com,admin.platform.com). - other shared envs (
dev,staging, …) →{env}.{apex}(dev.todo.com). - ephemeral envs (any env matching
tenant.ephemeralEnvPatternintamer.config.ts— e.g."^pr-"forpr-1234) →{env}.{apex}(pr-1234.todo.com). - local → no route.
defineWorker({
scriptName: "portal-ui",
tamerRoutes: [
{ host: "portal.todo.com" }, // → {env}.portal.todo.com/* (zone_name=portal.todo.com)
{ host: "api.todo.com", zone: "todo.com" }, // explicit parent zone
{ host: "todo.com", customDomain: true }, // CustomDomainRoute
],
});How routes land in Wrangler vs the Cloudflare API:
- Zone-name routes (
zone_name+pattern, including defaulttamerRoutesexpansion) are not written into generatedwrangler.json. They are tracked asworker_routeentries intamer-state-{env}and applied with the Workers Routes API after a successfulwrangler deployfor that script (so the worker exists before the route binds).tamer syncreconciles listings into state;tamer destroyremoves API routes before deleting workers;tamer driftreports mismatches for these routes. - Custom-domain
tamerRoutes(customDomain: true) and any staticroutesyou declare in worker config are still merged into generatedwrangler.jsonand deployed by Wrangler only.
To opt out per env, set skipEnvs (default ["local"]) or prodEnvs (default ["prod", "production"]). Tamer never strips or rewrites the wrangler-native routes field — declare both if you need static escape-hatch routes alongside tamerRoutes.
Workspace tenants (runtime)
CfiState (D1 tamer-state-{env}) includes a tenants map keyed product:workspace for signup-time resources not declared in tamer.config.ts.
tamer provision-tenant --env dev --product todo --workspace acme --main ./worker.js— for every role declared intenant.d1Shardsintamer.config.ts, creates the per-tenant D1 (db_{role}_{w}_{p}_t_{tid}_{env}); then uploads the dispatch script to the first configured dispatch namespace and recordsTenantStateEntryasready. Tamer ships no built-in shard layout — the engine is opinion-free, so a Dragoncore-style product picksd1Shards: ["system", "app", "history"], a single-DB tenant picks["main"], a billing/content split picks["billing", "content"], and a tenant that needs no per-tenant DB at all simply omitsd1Shardsand gets the dispatch script alone. Use--artifact-key <path/in/tamer-artifacts-{env}>instead of--mainto deploy from R2. Pass--shards a,bto trim the configured layout to a subset for ephemeral previews; the CLI flag cannot extend the configured set (config is the source of truth — typos surface with the configured roles in the error message). Re-runs are idempotent: existing shards are adopted, missing shards are added in place, so editingtenant.d1Shardsto add a new role and re-running picks up the new shard without disturbing the others. Pass--jsonfor a single trailing JSON line on stdout{ status: "ready"|"noop"|"failed", tenantKey, scriptName, dispatchNamespaceName, shards: [{role, derivedName, cfId}] }— designed for the Cloudflare Container caller (provision-workflow, see container invocation contract).tamer destroy-tenant --env dev --product todo --workspace acme --confirm-tenant acme— deletes the dispatch script, every tenant D1 shard recorded in state, and removes the tenant record (shared envs require confirmation or--force). Pass--jsonfor{ status: "destroyed"|"noop"|"failed", removed: { scriptName, dispatchNamespaceName, shards }, errors }.tamer status --env dev --tenant todo:acme— shows provisioning status and bound resources.
Container invocation contract
The tenant runtime commands are designed to be invoked by a Cloudflare Container (provision-workflow per docs/handoff.md §7) — same image CI uses (Dockerfile). Build and run:
docker build -t tamer .
docker run --rm \
-e CLOUDFLARE_ACCOUNT_ID -e CLOUDFLARE_API_TOKEN \
-v "$PWD":/work -w /work \
tamer provision-tenant --env dev --product todo --workspace acme \
--artifact-key todo/worker.js --jsonContract:
- Exit code is the source of truth.
0on success or no-op, non-zero on failure.provision-workflowshould branch on this, not on log scraping. - Stdout's last line is a JSON envelope when
--jsonis passed (other lines remain human-readable). Failure path also emits an envelope (status: "failed",error: <message>) before the non-zero exit. - No interactive prompts. Shared-env safety still applies — destroy callers must pass
--confirm-tenant <workspace>(or--force) explicitly. - Idempotent. Workflow retries are safe: re-invoking
provision-tenantwith the same args producesstatus: "noop"once the tenant isready; partial-failure resumes pick up from the last persisted shard.
If two writers update state concurrently, persist throws StateConflictError and the CLI exits with code 3; re-run after refreshing state.
Plan (tamer plan)
CloudFormation-style preview of apply + deploy. Reads only — never mutates state or Cloudflare. Shows every D1 / R2 / KV / dispatch namespace / Workers zone route declared in tamer.config.ts that isn't yet on Cloudflare, plus declared global Worker scripts that have no deployment (queried directly via GET /accounts/.../workers/scripts/{name}; dispatch-namespace tenant scripts are skipped — those belong to provision-tenant / WFP).
tamer plan --env dev # human-readable summary, exit 0
tamer plan --env dev --json # machine-readable for CI
tamer plan --env dev --detailed-exitcode
# ↑ exit 0 if no changes, 2 if changes pending (Terraform convention),
# non-zero on errors as usual
tamer plan --env dev --out plan.json # also persist a verifiable plan file
tamer plan --env dev --destroy # preview what `tamer destroy` would remove
tamer plan --env dev --destroy --json --detailed-exitcode
# ↑ exit 2 when there is anything to delete (CI gate before stack teardown)Scoped plan / apply (--target) — Limit preview or provisioning to a single declared resource: --target <kind>:<logicalName> (e.g. d1:settings, r2:assets, dispatch_namespace:workspace, dns_record:apex_txt). Uses the same kind strings as tamer import --kind. A scoped tamer apply still runs sync, still regenerates every worker’s wrangler.json, and still resolves the full outputs: block at the end so state stays coherent. --target is incompatible with apply --plan and plan --out — saved plan files attest the whole stack. worker_route and worker_script are not supported here (zone routes apply after tamer deploy; scripts are deployed by wrangler).
The plan reuses the drift engine for "would create" detection, so the rules are the same: drift filters by current-stack logicalNames in shared-state setups, and local envs only show storage that would be created in-memory.
For tracked resources whose recorded state has drifted from the declared config, tamer plan also emits update and replace items with terraform-style attribute change detail:
DNS records (1):
~ apex -> A todo.com (content: 192.0.2.1 -> 192.0.2.99, proxied: false -> true)
Vectorize (1):
± embeddings -> vec-embeddings-t-acme-dev (dimensions: 768 -> 1536, metric: cosine -> euclidean)
Summary: 0 to create, 1 to update, 1 to replace.+ is create, ~ is update (in-place PATCH on apply), ± is replace (Cloudflare's API rejects PATCH on the changed field, so apply must delete + recreate — DNS record type change, Vectorize dimensions / metric, Pipelines V1 sql), - is delete (destroy plan only). --json includes the same diffs as items[].changes[]: { field, from, to, kind: "mutable" | "immutable" }. Today the update / replace engine covers DNS records, Workflows, Vectorize, and Pipelines; other kinds emit only create / delete.
Destroy preview (tamer plan --destroy)
Mutually exclusive with the forward plan. Walks tamer-state-{env}, filters to entries owned by the current tamer.config.ts stack (same scoping as tamer destroy / tamer drift), and emits a delete PlanItem per managed kind — D1, R2, KV, Queues, Hyperdrive, Vectorize, AI Gateway, Pipelines, Workflows, Secrets Stores, dispatch namespaces, DNS records, and zone tamerRoutes. Declared global Worker scripts that currently exist on Cloudflare also appear as worker_script deletes. Dispatch-namespace tenant scripts are intentionally excluded — those are managed via provision-tenant / destroy-tenant. Read-only: never mutates state or Cloudflare.
--out destroy.json writes the destroy plan with the same (config, state, cloudflare) attestation as forward plans (report.mode: "destroy"); tamer destroy --plan destroy.json then recomputes the three hashes and refuses to proceed unless they all match (override with --allow-stale). Mode mismatch is non-overridable: apply --plan rejects destroy plans and destroy --plan rejects forward plans regardless of --allow-stale, so a saved plan can only ever execute the operation the operator reviewed.
tamer plan --env dev --destroy --out destroy.json
git diff destroy.json && code review ...
tamer destroy --env dev --confirm-env dev --plan destroy.json
# Refuses with one of:
# "config has changed since plan was generated; ..."
# "recorded state has changed since plan was generated; ..."
# "Cloudflare-side resources drifted since plan was generated (out-of-band create/delete); ..."
tamer destroy --env dev --confirm-env dev --plan destroy.json --allow-staleSaved plans + transactional apply
--out plan.json writes a tamer-plan/v1 document containing the plan report and an attestation: SHA-256 hashes of (a) the parsed config, (b) the recorded state's resources/tenants/stack (timestamps and revision counters are excluded so legitimate noise doesn't trigger false mismatches), and (c) the live Cloudflare snapshot at plan time — the per-kind fetchAll + sync result against a fresh in-memory state, with timestamps stripped. cloudflareHash is omitted for env: local and for legacy plans written before drift-aware refresh.
tamer apply --plan plan.json recomputes all three hashes against the current (config, state, live Cloudflare) triple and refuses to proceed if any drifted, mirroring terraform apply <planfile> plus terraform refresh. Out-of-band creates or deletes on Cloudflare between plan and apply are caught here; the recorded state alone wouldn't notice. Pass --allow-stale only when you've reviewed the diff and want to override:
tamer plan --env dev --out plan.json
git diff plan.json && code review ...
tamer apply --env dev --plan plan.json
# Refuses with one of:
# "config has changed since plan was generated; ..."
# "recorded state has changed since plan was generated; ..."
# "Cloudflare-side resources drifted since plan was generated (out-of-band create/delete); ..."
tamer apply --env dev --plan plan.json --allow-stale # opt-in overridetamer apply --rollback-on-failure snapshots state-entry keys at start and, on any failure, walks every key created during the run in reverse insertion order and asks the owning module's destroyOne to delete the underlying Cloudflare resource (best-effort; failures log a warning and never mask the original error). Use it in CI to keep your account clean when an apply fails halfway:
tamer apply --env dev --plan plan.json --rollback-on-failureImport (tamer import)
CloudFormation import_resources analogue. When state is missing one specific resource that already exists on Cloudflare (created out-of-band, or after a partial restore), import registers it under its logical name without touching the resource itself. Verifies the Cloudflare object exists and that the cf-side name matches Tamer's derived name for --logical before writing the state entry.
tamer import --env dev --kind d1 --logical settings --cf-id <d1-uuid>
tamer import --env dev --kind d1 --logical events --shard-date 2026-01-15 --cf-id <d1-uuid>
tamer import --env dev --kind r2 --logical assets --cf-id r2-assets-t-acme-dev
tamer import --env dev --kind kv --logical cache --cf-id <kv-namespace-id>
tamer import --env dev --kind queue --logical events --cf-id <queue-id>
tamer import --env dev --kind hyperdrive --logical primary --cf-id <hyperdrive-config-id>
tamer import --env dev --kind dispatch_namespace --logical workspace-workers --cf-id workspace-workers-dev
tamer import --env dev --kind worker_route --logical portal_ui --route-id <route-id> --zone-name portal.todo.com
tamer import --env dev --kind dns_record --logical apex --cf-id <dns-record-id>Use tamer sync for bulk discovery instead. import refuses to overwrite an existing state row that points at a different Cloudflare id.
Drift (tamer drift)
Read-only diff between Tamer state, Cloudflare reality, and the current tamer.config.ts. Useful for CI guards and pre-deploy sanity checks; never writes to state or to Cloudflare.
For each managed resource kind (D1, R2, KV, Queues, Hyperdrive, dispatch namespaces, HTTP routes when you use zone-name tamerRoutes, and Worker scripts for declared global workers) the report lists three buckets:
missingFromCloudflare— state references a resource that no longer exists on Cloudflare (deleted out-of-band; consider rerunningapplyor pruning state).unrecordedInState— Cloudflare has a resource that matches a declared logical name in this stack's config, but no state entry tracks it (runtamer sync).undeployed— declared in this stack's config, present in neither state nor Cloudflare (runtamer apply).
tamer drift --env dev # human-readable; non-zero exit when drift
tamer drift --env dev --json # machine-readable for CIDrift filters by the current config's logicalNames, so multi-stack environments sharing one tamer-state-<env> only report on resources owned by the current stack. Zone-name tamerRoutes appear under HTTP routes (Workers Routes API); custom-domain and wrangler-only routes are not checked here (Wrangler remains their source of truth). The Worker scripts section only emits undeployed (declared but missing on Cloudflare) since global script ids aren't tracked in state today.
State and artifacts
For non-local envs, tamer bootstrap --env <env> provisions two pieces of per-env metadata Tamer owns directly:
tamer-state-<env>(D1): stores deployment state as JSON (logical resources mapped to Cloudflare IDs — D1 UUIDs, KV IDs, dispatch namespaces, Workers zone route ids for API-managedtamerRoutes, etc.) plus optional CloudFormation-style stack metadata (stack.name,stack.owner) and a last operation marker (lastOperation: command name,in_progress/succeeded/failed, timestamps, error message) updated bybootstrap/apply/deploy/destroy/import. Each successful or failed run also appends a snapshot to operationHistory (newest first, capped at 50 entries) so operators can review recent changes.tamer events(read-only) prints that timeline, optional--limit N, and--jsonfor automation.syncmerges API listings into this document; Cloudflare remains the source of truth for what exists. State schema is on v4 (auto-migrates v2/v3 in place on read). Each stack's document lives at row keycfi_state:{stackName}(wherestackNamecomes fromstack: { name? }intamer.config.ts, defaulting totenant.slug), so multiple stacks can share the sametamer-state-<env>D1 without overwriting each other — required by${tamer:import:<stack>.<output>}cross-stack references.tamer-artifacts-<env>(R2): holds Tamer-managed bundles keyed by{resource-type}/{name}/{version}/.... Used by future runtime provisioning paths (read-only at deploy time today).
Multi-stack flows (fixtures/platform, fixtures/portal, and fixtures/internal) share one tamer-state-<env> and one tamer-artifacts-<env> per account when you use the normal bring-up scripts; tamer:down passes --wipe-metadata only on the platform destroy (fixtures/platform) so the metadata DB and artifacts bucket are removed once at the end. The bucket delete is best-effort — if it still has objects, the operator must clean them and re-run --wipe-metadata.
See Runbook for the platform + portal + internal fixtures (spin-up, tear-down, flags).
Developing
From the repo root after bun install:
- CLI:
bun run tamer -- <command>(rootpackage.json→bun src/cli/index.ts) or invokebun src/cli/index.tsdirectly. - Fixtures: reference stacks under
fixtures/platform,fixtures/portal, andfixtures/internal. Bring up / tear down withbun run tamer:up/bun run tamer:down— see Runbook. - Tests:
bun run typecheckandbun run test. CI usesbun run test:report(JUnit →reports/junit.xml, dots output). Coverage:bun run test:coverage→coverage/lcov.info. Details: Testing. - CI:
.github/workflows/ci.yaml— typecheck + unit tests on pushes and PRs tomain/apex. - Publish: npm releases via
.github/workflows/publish-npm.yaml(Trusted Publishing / OIDC). Packaging: npm-cli-packaging-strategy.md. Cross-repo contracts: publish.md.
Fixture smoke test (requires .env under each fixture directory):
bun run tamer:up -- --env dev
# …
bun run tamer:down -- --env dev --confirm-env devDocumentation
| Doc | Audience |
| --- | --- |
| Consumer quickstart | Downstream repos — install from npm, first deploy, version pinning |
| Brownfield adoption | Downstream repos — legacy CF names, naming hooks, sync/plan flow (no IDs in config) |
| Testing | Contributors — test runners, JUnit, coverage |
| Publish / integration requirements | Maintainers — cross-repo contracts for DragonMastery stacks |
| npm CLI packaging strategy | Maintainers — npm package build and release layout |
| Runbook | Contributors — platform + portal + internal fixture walkthrough |
| Platform architecture | Platform owners — repos, stacks, runtime model, rollout |
Other material lives under docs/.
License
TAMER EVALUATION LICENSE. See LICENSE for terms.
