Schema Deployment

Last updated about 2 months ago

Deploy your spec and projections with a deploy-scoped API key — same host, same bearer token as reads and writes. No JWT, no separate console login. This is the self-serve path built for CI: push spec.json and your projection definitions with just J17_API_KEY.

The Admin API still offers a JWT-based path on the console API. Use whichever fits — the deploy key collapses everything to one host and one credential.

Deploy keys

A deploy key is a fourth scope, alongside read, write, and admin:

Scope Can deploy schema? Can read/write data?
read No Read only
write No Read + write
deploy Yes No
admin Yes Read + write

deploy is deliberately orthogonal to write: a key that posts events cannot redefine the schema those events are validated against, and a deploy key cannot write into live event streams. It does own environment provisioning — seeding a fresh environment (cold start) and resetting one — but only on staging and test, never production. Use a dedicated deploy key in CI; use admin only when one credential genuinely needs both.

Create one from the dashboard (Instance settings → API Keys → scope "Deploy"), or via the console API:

curl -X POST https://console.j17.dev/api/instances/$INSTANCE_ID/keys \
  -H "Authorization: Bearer $JWT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "CI Deploy",
    "scope": "deploy",
    "environment": "test"
  }'

Deploy keys are environment-scoped like any other key. A test key only works on *-test.j17.dev and can only ever deploy the test environment — the environment is fixed by the host you call, not by the request body, so a test key cannot touch production.

Deploy a spec

POST /_spec
Authorization: Bearer $DEPLOY_KEY
Content-Type: application/json

{
  "spec": {
    "aggregate_types": { ... },
    "agent_types": [ ... ]
  }
}
curl -X POST https://myapp-test.j17.dev/_spec \
  -H "Authorization: Bearer $DEPLOY_KEY" \
  -H "Content-Type: application/json" \
  -d @spec.json

The spec goes inside the "spec" key — the same envelope the dashboard upload and the JWT endpoint expect. The environment comes from the host, not the body.

Response:

{ "ok": true, "environment": "test" }

The deploy takes effect immediately: your next read or write on that host sees the new spec. It reaches that host as a notification from the control plane, and if one is ever lost the host re-checks on its own within five minutes — so the worst case is a delay, not a deploy that silently never applies.

Warnings

A successful deploy (and a successful validate) may carry a warnings array — advisory, never blocking:

{
  "ok": true,
  "environment": "test",
  "warnings": [
    "aggregate type 'trip' appends to state in every event type (26 of 26). Its cached state grows with its stream, and keeping it current costs ~2x the state size in Redis traffic on every write. If it is written more often than it is read, list it in modules.never_cache."
  ]
}

Currently the one warning is the append-everywhere shape: an aggregate type whose every event appends to state grows without bound, and its cache entry costs more to maintain than it saves unless the type is read at least as often as it is written. Listing the type in modules.never_cache resolves it — reads replay the stream instead, which for this shape transfers roughly the same bytes anyway. The field is absent when there is nothing to say.

Validate without deploying

POST /_spec/validate checks a spec and writes nothing:

curl -X POST https://myapp-test.j17.dev/_spec/validate \
  -H "Authorization: Bearer $DEPLOY_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "spec": { ... } }'
{ "ok": true, "environment": "test", "valid": true }

It runs exactly what a real deploy runs — structural validation and the backward-compatibility check against the environment's current spec — and returns the identical error body on failure. Nothing is written, and your deployed spec and its cache are untouched.

A separate endpoint rather than a flag on POST /_spec, so that no spelling of a parameter can turn a check into a deploy.

This exists because validation otherwise happens only on the deploy path, which makes a spec that cannot ship the one class of error your test suite cannot see: everything passes locally and the release fails. Wire it into a precommit hook or CI step against a non-production environment and the failure moves to where you can fix it cheaply.

Incompatible changes and force

Specs are validated before deployment, and backward-incompatible changes (removing an aggregate or event type, tightening a required field) are rejected. Pass "force": true to waive the compatibility check:

{ "spec": { ... }, "force": true }

force is honored on non-production environments only. On production it is ignored — a prod spec can never be clobbered by a forced deploy.

Deploy a projection

The projection name is in the URL; the definition is the body.

POST /_projections/:name
Authorization: Bearer $DEPLOY_KEY
Content-Type: application/json

{
  "projection": {
    "for_each": "order",
    "sources": { "order": "$key" },
    "select": { "total": "$.order.total" }
  }
}
curl -X POST https://myapp-test.j17.dev/_projections/order_summary \
  -H "Authorization: Bearer $DEPLOY_KEY" \
  -H "Content-Type: application/json" \
  -d '{"projection": {"for_each": "order", "sources": {"order": "$key"}, "select": {"total": "$.order.total"}}}'

Response:

{ "ok": true, "environment": "test", "name": "order_summary" }

Projections are validated against the deployed spec, so deploy the spec first. See the projections guide for sources, select transforms, and SQL queries.

The name query can't be used for a projection — /_projections/query is the projection SQL endpoint.

Delete a projection

DELETE /_projections/:name
Authorization: Bearer $DEPLOY_KEY
curl -X DELETE https://myapp-test.j17.dev/_projections/order_summary \
  -H "Authorization: Bearer $DEPLOY_KEY"

Response:

{ "ok": true, "environment": "test", "name": "order_summary" }

Deleting a projection that doesn't exist returns 404 projection_not_found.

Seed a fresh environment (cold start)

A deploy key can also seed an environment — write a batch of starting events into an environment that has no data yet — with one call. This is how you provision a non-production environment from an export without a console login or a human in the loop.

POST /_cold_start
Authorization: Bearer $DEPLOY_KEY
Content-Type: application/json

{ "events": [ … ] }
curl -X POST https://myapp-test.j17.dev/_cold_start \
  -H "Authorization: Bearer $DEPLOY_KEY" \
  -H "Content-Type: application/json" \
  -d @events.json

Response:

{
  "ok": true,
  "environment": "test",
  "events_written": 128,
  "aggregates": 40,
  "consistency": {
    "estimated_seconds": 2,
    "note": "Projections and read caches materialize in the background. Queries against projection tables may be incomplete for about 2s after this call."
  }
}

Each event is a { "key", "type", "data", "metadata" } object — the same wire shape you get from an export, so an exported event stream feeds straight back in. Deploy the spec first: the events are validated against it, and the whole batch is rejected if any event fails (no partial seed).

Materialization is asynchronous. The events land synchronously, but projection tables and read caches are built by a background sweeper. The response's consistency.estimated_seconds is a rough guide to how long a query against a projection table may return partial results — scaled to the number of aggregates seeded. For a large seed the field also carries a warning; wait that long (or poll a projection count until it settles) before treating the environment as ready. This matters most right after a _reset + _cold_start cycle, when every projection table starts empty.

Cold start is once-only and non-production-only:

  • It writes only into an empty environment. If the environment already holds data it is refused with 409 — cold start never overwrites. To re-seed, reset first (below), then cold start again.
  • With a deploy key it is limited to staging and test; production returns
    1. This mirrors reset: a deploy key can seed or recycle a non-prod environment, but never touch prod data. (An operator with a console login can cold-start any environment — see the Admin API.)

Reset a non-production environment

POST /_reset wipes an environment's event data and projections (the deployed spec is kept), so a deploy key can recycle a non-prod environment between runs:

curl -X POST https://myapp-test.j17.dev/_reset \
  -H "Authorization: Bearer $DEPLOY_KEY"

Like cold start, it is staging/test only — production returns 403. The pair gives CI a full provision cycle: _reset to clear, _cold_start to seed.

Errors

Status Body Meaning
403 {"ok": false, "error": "Insufficient permissions"} Key lacks deploy scope
403 {"ok": false, "error": "cold_start with a deploy key is limited to non-production environments"} _cold_start on prod
404 {"ok": false, "error": "instance_not_found"} No such instance
404 {"ok": false, "error": "projection_not_found"} Deleting an absent projection
409 {"ok": false, "error": "Environment already initialized"} _cold_start on an environment that already holds data
422 {"ok": false, "error": "validation_failed", "details": [...]} Spec or projection rejected — details explains why
422 {"ok": false, "error": "No spec deployed for this environment"} _cold_start before a spec is deployed
502 {"ok": false, "error": "deploy_unavailable"} j17 couldn't record the deploy; retry

Validation failures return structured details, so a bad spec tells you exactly what's wrong — the surface a CI job can act on.

A CI deploy

#!/usr/bin/env bash
set -euo pipefail

host="https://myapp-test.j17.dev"

# Check before you change anything: a rejected spec fails here rather than
# halfway through the deploy.
curl -fsS -X POST "$host/_spec/validate" \
  -H "Authorization: Bearer $J17_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"spec\": $(cat j17/spec.json)}"

# Spec first — projections validate against it.
curl -fsS -X POST "$host/_spec" \
  -H "Authorization: Bearer $J17_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"spec\": $(cat j17/spec.json)}"

# Then each projection.
for f in j17/projections/*.json; do
  name=$(basename "$f" .json)
  curl -fsS -X POST "$host/_projections/$name" \
    -H "Authorization: Bearer $J17_API_KEY" \
    -H "Content-Type: application/json" \
    -d "{\"projection\": $(cat "$f")}"
done

See also

Can't find what you need? support@j17.app