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. Responses to visitors return mine: boolean for the requesting creator and never expose created_by. The authenticated owner API 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, 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.

A temporary artifact's expiry also removes its entire data store. New data access stops at expiry; background deletion retries if the store is unavailable.

Trust and safety

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

  • Creator identity. Signed-in reviewers are recognized by their account. Everyone else gets a per-browser guest id that cafe.data keeps in localStorage and sends as x-ac-guest. Responses to visitors carry mine: boolean instead of creator ids; only the owner API returns created_by. A guest id proves possession, not 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. Anyone who can't open the artifact can't read or write its data: visibility, workspace membership, per-person shares, and password protection all apply. A public collection never bypasses them.
  • Owners and agents reach the same data through the authenticated owner API (signed-in session, ack_ account key, scoped aat_ agent token, or the artifact's publish token) for browsing, seeding, 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 insert <collection> --record '{"name":"Ada"}'   # add a record
npx artifact-cafe data insert <collection> --file seed.json   # add a JSON array of records
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 insert writes as the owner, so it works even when the collection's insert is owner or none; fields are still validated and count toward the record limit. An array is sent in batches of 100; each batch is all-or-nothing, so one invalid record (reported by index) rejects its whole batch. 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)

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
Owner batch insert100 records/request
Shared reads per artifact (owner API exempt)10,000/hour
Shared writes per artifact (owner API 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.