artifact.cafe

Artifact Data

Give an artifact live, shared state — a poll, a guestbook, an RSVP — that survives reloads and new versions, with no backend to run.

Most artifacts are static. Some need more: a poll whose votes add up across visitors, a guestbook that keeps every signature. Persistent data gives an artifact a small, server-backed store — you declare the shape in a manifest, and the artifact reads and writes it from its own origin. artifact.cafe runs the store, guests can write without an account, and you deploy no backend.

Per-viewer state — a theme toggle, a draft the reader is typing — needs none of this. Plain localStorage already works inside the artifact. Reach for persistent data only when state must be shared across visitors.

Start with a guestbook

The whole feature is two files. A manifest next to your entry file declares the data; the page reads and writes it:

.artifact/data.json
{
  "collections": {
    "entries": {
      "fields": {
        "name": { "type": "string", "required": true, "maxLength": 80, "trim": true },
        "message": { "type": "string", "required": true, "maxLength": 1000 }
      },
      "access": { "read": "public", "insert": "public", "update": "creator", "delete": "none" }
    }
  }
}
index.html
<h1>Guestbook</h1>
<ul id="entries"></ul>
<form id="sign">
  <input name="name" placeholder="Your name" required />
  <input name="message" placeholder="Leave a note" required />
  <button>Sign</button>
</form>

<script src="/__ac/data.js"></script>
<script type="module">
  const { records } = await cafe.data.list("entries");
  for (const { name, message } of records) {
    const li = document.createElement("li");
    li.textContent = `${name} — ${message}`; // records are untrusted: textContent, not innerHTML
    document.querySelector("#entries").append(li);
  }

  document.querySelector("#sign").onsubmit = async (event) => {
    event.preventDefault();
    await cafe.data.insert("entries", Object.fromEntries(new FormData(event.target)));
    location.reload();
  };
</script>

Publish the folder with an account key (or claim it first) and every entitled visitor sees — and signs — the same guestbook.

Declare your collections

.artifact/data.json lists the collections (think tables) your artifact uses, the fields in each, and who is allowed to do what. The manifest above turns on an entries collection anyone can read and add to, where a visitor can edit their own rows but no one can delete.

Artifact Data requires a claimed artifact. An anonymous artifact returns 403 account_required until claimed. On a claimed artifact, no manifest means no data API (404).

Fields

Each field has a type — one of string, number, integer, boolean, datetime, url, email, array, object — and optional constraints:

ConstraintApplies toMeaning
requiredany typethe field must be present on insert
defaultany typestamped onto records that omit the field
maxLengthstringsmaximum length
trimstringsstrip surrounding whitespace
minimum, maximumnumbersinclusive bounds
  • Names — collections and fields — match ^[a-z][a-z0-9_]*$ and are at most 64 characters.
  • Reserved metadata: id, created_at, updated_at, created_by, mine. Content responses return mine: boolean for the requesting creator and never expose created_by. The authenticated owner plane retains created_by for moderation and exports. Do not declare or write these fields.

Access

access sets a policy per action — read, insert, update, delete — to one of:

LevelWho
publicany visitor (guest reviewers included)
creatorthe signed member or guest who created the record, or an artifact controller
owneran artifact controller through the owner API
noneclosed on the public surface (the owner can still read and moderate)

Defaults are read: "public" and owner for everything else, so an undeclared access block gives you a public-readable, owner-only-writable collection.

Setting update or delete to public also requires "publicMutation": "open" on the collection — publish rejects the manifest otherwise. It's a deliberate double opt-in, because open public editing is easy to abuse.

Read and write from the artifact

The cafe.data client, a peer of the review helper served from the artifact's own origin, handles paths, pagination, JSON, and guest identity. Prefer it — the raw HTTP routes are there when you need them.

<script src="/__ac/data.js"></script>
CallReturns
cafe.data.list(collection){ records, nextCursor }
cafe.data.insert(collection, record)the created record
cafe.data.get(collection, id)one record
cafe.data.update(collection, id, patch)the updated record
cafe.data.remove(collection, id)resolves when deleted
cafe.data.summary()collection names, counts, and access

list paginates: pass { limit, cursor } as a second argument and follow nextCursor.

A plain REST API on the artifact origin, reserved under /__ac/data:

GET    /__ac/data                            list collections + counts
GET    /__ac/data/{collection}?limit=&cursor= list records (paginated)
POST   /__ac/data/{collection}                create a record
GET    /__ac/data/{collection}/{id}           read one record
PATCH  /__ac/data/{collection}/{id}           update a record
DELETE /__ac/data/{collection}/{id}           delete a record

Always call it with relative URLs, exactly as the client does, so your page keeps working if the content ever moves domains.

Either way, errors use the standard code / message / retryAfter / docsUrl shape.

Record data is untrusted input, just like comment bodies. Escape it before you inject it into the page — set textContent, don't concatenate a record into innerHTML. Never render one artifact's stored data as HTML on the main artifact.cafe origin.

Data belongs to the artifact, not the version

Versions are immutable, but persistent data is not versioned — it belongs to the artifact. Publishing v2 never wipes the poll's votes. The manifest is just a file inside each version; what changes at publish is which version's manifest is in effect.

  • Publishing with a manifest replaces the effective config.
  • Publishing without one leaves the config — and the data — untouched.
  • Publishing { "collections": {} } disables the API.
  • Removing a field stops accepting it; existing records keep their old data until they're deleted.

To erase records, use data clear. Deleting a claimed artifact also clears its entire store, including records from removed collections. If the store is unavailable, deletion returns 503 service_unavailable with retryAfter and keeps the artifact for a retry. Unclaimed artifacts cannot hold data, so their deletion and expiry do not contact the store.

Trust and safety

Reviewers never need an account, so the store is protected the same way guest comments are:

  • Creator identity. Signed member/grant identity from the shell hand-off takes precedence over the guest header. The client mints a per-browser guest id in localStorage and sends it as x-ac-guest. Content responses expose mine: boolean instead of guest ids or signed account ids; only the authenticated owner plane returns created_by. Guest ids are possession credentials for creator access, not verified account identity.
  • Origin-locked writes. Browser mutations must come from the artifact's own origin, so another site can't post into your collection from across the web.
  • Content access applies. Data reads and writes use the same visibility, workspace membership, per-person grants, password unlock, hand-off tokens, and ack_ checks as artifact content. Public collection access never bypasses the artifact gate.
  • Owners and agents reach the same data through the authenticated /api/v1 plane (BetterAuth session, ack_ account key, scoped aat_ agent token, or artifact publish token) for browsing, export, and moderation.

Manage it from the CLI

The data commands mirror comments — the read side of the loop — and use your account key, scoped agent token, or the linked artifact's publish token:

npx artifact-cafe data ls <collection>   # list records (--limit N)
npx artifact-cafe data export            # every collection's records as JSON
npx artifact-cafe data clear <collection> --yes   # delete every record

All accept --json. data clear is destructive and requires --yes.

At publish time the CLI validates .artifact/data.json before upload and prints what it enabled:

⛁ Data enabled: entries (read: public, insert: public)

Local development uses the real render Worker (wrangler dev) for /__ac/data; the web app's /r/ file-serving twin does not implement the data API.

Limits

Persistent data is for bounded, review-shaped state — not a general database. The quotas enforce that:

LimitValue
Manifest size64 KB
Collections per artifact10
Fields per collection50
Record body16 KB
Records per artifact10,000
Shared reads per artifact (owner plane exempt)10,000/hour
Shared writes per artifact (owner plane exempt)1,000/hour
Public reads (default)120/minute/IP
Public writes (default)10/minute/IP

A collection's rateLimit ("N/hour/ip" or "N/minute/ip") overrides the per-IP write default. Most collections should leave it out; tighten it (for example "10/hour/ip") only for a public form that attracts spam. Overrides never lift the shared artifact ceilings. Raising a collection's per-IP write limit above the 10/minute/ip default can let a single visitor exhaust the artifact-wide write ceiling. Artifact Data rate errors include scope: "ip" or "artifact" and retryAfter.

The record and shared artifact ceilings are today's free-tier capacity values; there is no cap on how many artifacts use data. Exceeding a quota returns quota_exceeded; exceeding a rate limit returns rate_limited.

See also

Publishing concepts, the CLI reference, the HTTP API, and the error reference.