Skip to content

Storing data (document store) ​

An app keeps its data on Civitai in the document store: JSON documents in named collections that the app declares in its manifest. A collection is either private, one key space per viewer, or shared, one key space every viewer of the app reads. Shared collections carry authorship, counters and per-viewer marks (a vote, a like, a report) without any code of yours on the server.

The client is app.storage.collection(name) in @civitai/sdk 0.14.0 or later. Underneath it is a plain REST API under /api/v1/blocks/storage, called with the block token, so an app that does not use the SDK can call it directly (REST reference).

A scaffolded app may pin an older SDK, and a caret range on a 0.x version never moves to the next minor, so npm install alone does not get you there. On an older version app.storage.collection does not exist. Check, and raise it if needed:

bash
npm ls @civitai/sdk
npm install @civitai/sdk@^0.14.0

This store replaces two older ones, useAppStorage and useSharedStorage (and their app-storage / shared-storage routes). Those keep working while apps move; see Moving from the legacy stores. Which client reaches which store:

Store@civitai/sdk@civitai/blocks-react (bridge)REST
Document storeapp.storage.collection(name), app.storage.info()none/api/v1/blocks/storage/*
Legacy per-viewer store (deprecated)app.storage.get / set / delete / list / getQuotauseAppStorage/api/v1/blocks/app-storage/*
Legacy shared store (deprecated)app.sharedStorageuseSharedStorage/api/v1/blocks/shared-storage/*

Declare your collections ​

A collection that is not declared in the manifest does not exist: every call to it is refused collection_not_declared. The declaration is reviewed with the rest of the manifest, and the server reads the approved version of it, so a collection you add in a new version exists once that version is approved.

json
{
  "scopes": ["apps:storage", "apps:storage:publish"],
  "scopeJustifications": {
    "apps:storage:publish": "Viewers post feature requests that every viewer of the app can read and vote on."
  },
  "storage": {
    "collections": {
      "prefs":    { "visibility": "private" },
      "requests": { "visibility": "shared", "write": "own", "marks": ["vote"],
                    "indexes": { "status": "string" }, "showAuthor": true },
      "plays":    { "visibility": "shared", "write": "none", "counters": ["plays"] }
    }
  }
}

Each collection declares:

  • visibility — "private": each viewer has their own key space, and only that viewer reads or writes it. "shared": one key space for the whole app, read by every viewer, signed-out viewers included.
  • write — shared collections only, and required on them. "own": signed-in viewers create documents, and only a document's author edits or deletes it. "none": nobody writes documents; viewers only increment the declared counters and set the declared marks, and the first increment of a key that does not exist creates an empty document. A "none" collection must declare at least one counter or mark.
  • counters — shared only. Atomic tallies the app increments, such as plays.
  • marks — shared only. A per-viewer flag on a document, at most one of each per viewer per document, such as vote. A reader sees each mark's count and whether they set it, never who else did. report is built in on every shared collection and must not be declared.
  • indexes — the fields of a document's data you can filter and sort by, as a map of field path to type (string, number, boolean or time). A path such as status or meta.round names a field inside data.
  • showAuthor — shared only. When true, readers see the author's username. Otherwise they learn only whether they wrote a document themselves. The numeric user id is never exposed either way.

Renaming a collection or changing its visibility makes it a new collection, and the old one's documents are not moved into it. They are not deleted either: they stay stored, keep counting toward the app's and each viewer's quotas, and can no longer be read or deleted: a call naming a collection that is no longer declared is refused collection_not_declared, and after a visibility change under the same name the old documents sit in a key space the collection no longer reads. Declaring the old collection again brings them back.

Clearing a collection out first is possible only for some of its documents, because there is no app-wide delete:

  • In a private collection, each viewer can delete their own documents, with their own token.
  • In a shared "own" collection, each document's author can delete it, and no other viewer can. A document a moderator has hidden cannot be deleted this way.
  • In a shared "none" collection, no viewer can delete a document: a delete is refused write_policy.
  • The app cannot delete documents on viewers' behalf: write: "owner", which would make the app a writer, is reserved.

Every document not deleted that way stays charged to those quotas, and declaring the old collection again is the only way to reach it again.

Changing an index's type is not supported; remove the index and add one under a new name.

Some of the schema is reserved for later phases, and the server refuses it with a message saying so. The limits table lists which fields are refused today, and the manifest reference has every field's bounds.

civitai app validate checks only the JSON schema, which admits the reserved fields. The server's own manifest validation, which applies the reserved-feature refusal and the scope rules, runs when a moderator approves the version (and on a direct git push), not when civitai app submit uploads it. A declaration it refuses therefore waits in review until approval fails, so check it against this page before you submit.

Two scopes:

ScopeCoversConsent
apps:storageeverything except authoring shared documents: reads, the viewer's private collections, deleting documents the viewer wrote, marks and countersconsent-exempt
apps:storage:publishcreating and editing documents in a shared collection whose write is "own"consent-gated, and needs a scopeJustifications entry

The rules the server's manifest validation enforces at approval (only the scopeJustifications entry is checked earlier, when civitai app submit uploads the version):

  • Declaring any collection requires apps:storage.
  • A shared collection with write: "own" requires apps:storage:publish. write: "none" does not: no viewer authors a document there.
  • Declaring either scope with nothing in storage for it to reach is refused.
  • auth: "oauth" cannot be declared alongside any apps:storage* scope.

apps:storage:publish reaches the token only after the viewer agrees in the host's consent dialog, so the first shared write from a viewer who has not been asked is refused consent_required. Ask with app.requestGrants and retry:

ts
import { initialize, StorageError } from '@civitai/sdk';

const app = await initialize();
const requests = app.storage.collection<{ title: string; status: 'open' | 'done' }>('requests');

async function postRequest(title: string) {
  try {
    return await requests.create({ title, status: 'open' });
  } catch (error) {
    if (!(error instanceof StorageError) || error.code !== 'consent_required') throw error;
    const granted = await app.requestGrants(['apps:storage:publish'], {
      signal: AbortSignal.timeout(60_000),
    });
    if (!granted) return null; // the viewer said no: keep the UI usable without posting
    return requests.create({ title, status: 'open' });
  }
}

A viewer who withdraws the publish scope can still delete the documents they wrote: deleting needs only apps:storage.

Reading and writing ​

app.storage.collection<T>(name) returns a handle; nothing is sent until you call an operation on it.

ts
import { initialize } from '@civitai/sdk';

interface Request {
  title: string;
  status: 'open' | 'done';
}

const app = await initialize();
const requests = app.storage.collection<Request>('requests');

const created = await requests.create({ title: 'More lighting presets', status: 'open' });
const doc = await requests.get(created.key); // null when there is none
await requests.put(created.key, { title: 'More lighting presets', status: 'done' }, { ifVersion: doc?.version });
await requests.mark(created.key, 'vote'); // { marked: true, count }

const page = await requests.list({ where: { status: 'open' }, sort: '-mark:vote', limit: 20 });
for (const item of page.items) {
  console.log(item.data.title, item.marks.vote, item.author, item.viewer?.marks);
}
CallDoes
get(key)the document, or null when there is none (a hidden document reads the same way)
getMany(keys)one round trip; the result is in the order of keys, with null for each miss
put(key, data, { ifVersion? })creates or replaces the document at key
create(data)creates a document under a key the server chooses, a sortable id
delete(key, { ifVersion? }){ deleted: false } when there was nothing to delete; calling it twice is safe
list(query?)one page; see Lists
increment(key, counter, by?)adds to a declared counter, { value }
mark(key, name) / unmark(key, name)sets or clears the viewer's mark, { marked, count }
app.storage.info()the declared collections, the limits and the usage so far; signed-in viewers only

Documents ​

A document is { key, data, version, createdAt, updatedAt, author, counters, marks, viewer }:

  • data is a JSON object. version goes up by one on every write to data; counter and mark changes do not move it. The SDK gives you createdAt and updatedAt as Dates; on the wire they are ISO strings.
  • author is { kind: 'user', isViewer, username? } for a viewer's document (username only in a showAuthor collection), { kind: 'counter' } for a document a counter's first increment created, or { kind: 'app' }. It never carries a numeric user id.
  • counters and marks hold the declared counters and mark counts of a shared document, and are {} on a private one. A report count is never exposed.
  • viewer is { marks }, the marks the reading viewer set on this document, or null for a signed-out viewer.

Reads and writes use the same database connection, so a read made after a write succeeded sees it; there is no replica delay to wait out.

The characters and length a key may have are in the limits table. In a private collection each viewer has their own key space, so two viewers can both have a document theme without colliding.

Conditional writes ​

put and delete take ifVersion: the write happens only if the stored document is at that version, and 0 means "only if it does not exist yet". A mismatch is refused version_conflict, with the stored version in details.currentVersion. Use it whenever two viewers, or two tabs, can edit the same document: read, change, write with the version you read, and on a conflict read again.

Two writes racing on one key can also be refused version_conflict without ifVersion; retrying is safe.

Lists ​

list(query) returns { items, nextCursor }. nextCursor is null exactly on the last page; pass it back as cursor with the same query for the next one.

Query fieldMeaning
whereequality on one declared index: { status: 'open' }. A time index takes an ISO-8601 string, epoch milliseconds or a Date
sortkey (the default), createdAt, updatedAt, a declared index, counter:<name> or mark:<name>; a leading - sorts descending
author: 'me'only documents the viewer wrote
markedBy: 'me' with markonly documents the viewer marked with that mark
keyPrefixonly keys starting with this prefix
limitpage size; the server applies its own default and maximum

The filters combine with AND. A filter or sort on an index lists only the documents that have a value for that index; a counter or mark sort lists every document, counting a missing tally as 0. A where or sort on anything undeclared is refused index_not_declared.

Counters and marks ​

Both exist on shared collections only, and both need a signed-in viewer.

ts
import { initialize } from '@civitai/sdk';

const app = await initialize();
const plays = app.storage.collection('plays'); // write: "none", counters: ["plays"]

const { value } = await plays.increment('model-123', 'plays'); // creates the document on first use
  • increment(key, counter, by) adds by (1 when omitted) and resolves the new value. In a write: "none" collection the first increment of a missing key creates the document.
  • mark and unmark are idempotent and resolve the mark's new count. Use a mark for anything one viewer does once per document: a vote, a like, a bookmark. viewer.marks on each document tells you which ones this viewer has set, so a toggle can render its state on load.
  • report is the built-in mark that sends a document to moderators. Its count is never returned, and a report cannot be withdrawn: unmark(key, 'report') is refused invalid_request for every caller. Reports have their own daily limit on top of the marks-and-counters one.

Who can do what ​

OperationViewerScopeOther gates
Read a shared collectionanyone, signed out includedapps:storage—
Read a private collection, info(), author: 'me' / markedBy: 'me' listssigned inapps:storage—
Write or delete in a private collectionsigned inapps:storage—
Create or edit in a shared "own" collectionsigned inapps:storage + apps:storage:publishthe minimum-trust gate, moderation, the shared write budget; editing another viewer's document is refused not_author
Delete in a shared "own" collectionsigned in, the authorapps:storage—
Write a document in a "none" collection——refused write_policy for a signed-in viewer (a signed-out one gets sign_in_required first)
increment, mark, unmarksigned inapps:storagethe minimum-trust gate

Signed-out viewers get sign_in_required for everything but shared reads, so gate write affordances on app.viewer, and render shared reads regardless. Every call also needs an approved app, and for a signed-in viewer an account that may run apps; otherwise it is refused not_eligible with details.reasonapp_not_approved or apps_disabled.

The minimum-trust gate applies to a viewer's writes into shared collections (other than deleting their own) and to marks and counters. It is about the viewer's account, not your app: a refusal is not_eligible with details.reason set to ineligible, restricted, onboarding_incomplete, email_unverified or account_too_new. Treat it as a normal outcome and show the viewer why, rather than as an error.

Moderation ​

Every create or edit in a shared collection is scanned before it is stored: every string value and object key in data, and on put the document key. In a "none" collection, every increment scans the document's key, whether or not the document already exists.

  • A refusal is content_rejected (422). details.category is coarse (link, unreviewable or blocked) and never the matched text.
  • When the scan cannot run, the write is refused moderation_unavailable (503) with a Retry-After; retry after it. In a "none" collection that includes increments to documents that already exist.

Viewers report documents with the built-in report mark. Moderators see reported shared documents across apps and can hide one (it then reads as not found to your app and to every viewer, and a write to its key is refused not_found), unhide it, delete it, or dismiss its reports and keep it. Reports filed after a dismissal reopen it.

Errors ​

Every refusal has one shape, whichever layer refused:

json
{ "code": "version_conflict", "message": "version conflict", "error": "version conflict", "details": { "currentVersion": 3 } }

Branch on code; message is prose and error repeats it. details carries what the code needs (currentVersion, reason, category, ceiling, bucket, retryAfter). A refusal that names a wait sends a Retry-After header too, and a 429 also carries rateLimit: { retryAfterSeconds, retryAt }.

In @civitai/sdk a refusal in this envelope is a StorageError, an ApiError with code, status, details and a retryAfter getter. The client also refuses a few things before sending: a collection name that cannot be declared (collection_not_declared) and a key of '', . or .. (invalid_request). A response without the envelope arrives as a plain ApiError, and a request that never got an answer as the platform's TypeError.

ts
import { initialize, StorageError } from '@civitai/sdk';

const app = await initialize();
const requests = app.storage.collection<{ title: string; status: 'open' | 'done' }>('requests');

try {
  await requests.put('r1', { title: 'Renamed', status: 'open' }, { ifVersion: 3 });
} catch (error) {
  if (!(error instanceof StorageError)) throw error;
  if (error.code === 'version_conflict') {
    // reload the document and let the viewer redo the edit
  } else if (error.code === 'rate_limited') {
    console.warn(`busy; retry in ${error.retryAfter ?? 1}s`);
  } else {
    throw error;
  }
}

Every code the routes return, generated from the server's own table:

CodeHTTPMeaning
invalid_request400The request is malformed: a bad key, body, query parameter, value or cursor, an increment or mark naming an undeclared counter or mark, a counter or mark on a private collection, or withdrawing a report.
index_not_declared400A where or sort names a field, counter or mark the collection does not declare.
unauthenticated401No usable token.
sign_in_required401The operation needs a signed-in viewer: writes, marks, counters, info, any read of a private collection, and author=me / markedBy=me lists.
insufficient_scope403The token does not carry apps:storage.
consent_required403The viewer has not granted apps:storage:publish, or withdrew a consent-gated scope. Ask with requestGrants.
not_eligible403The app or the viewer cannot use storage here. details.reason says which, for example app_not_approved or account_too_new.
not_author403Only a document's author may change or delete it.
write_policy403The collection's write policy does not let viewers write documents.
not_found404No such document (hidden documents read the same way), or no such route.
collection_not_declared404The collection is not declared in the app's approved manifest (in a moderator's review run, the pending one).
version_conflict409An ifVersion write found another version. details.currentVersion is the one stored.
index_building409Declared for a later phase; not raised today.
cursor_expired410Declared for a later phase; not raised today.
too_large413The document or the request body is over its limit.
quota_exceeded413A quota is full. details.ceiling names which one.
content_rejected422Moderation refused a shared write. details.category is coarse and never the matched text.
rate_limited429A rate limit or the shared write budget is spent. Wait Retry-After seconds.
storage_disabled503The document store is not enabled on this server.
moderation_unavailable503A shared write could not be reviewed right now. Retryable after Retry-After.
consent_unavailable503The viewer's consent state could not be read. Retryable.
service_unavailable503A dependency could not be read. Retryable.
internal_error500A server fault.
method_not_allowed405The path exists, not for this method. The Allow header lists the methods it takes.

A 503 storage_disabled answers every data call on a server where the document store is not enabled; it is not something your app caused or can fix.

Limits ​

Generated from the server's constants. Render app.storage.info().limits, never a number you copied: the server can change any of these without a new SDK, and info() is what the running server enforces.

AreaLimitValue
DocumentsOne document65,536 bytes (64 KiB), measured on the JSON text the server stores, which can be larger than the JSON you sent
DocumentsOne request body81,920 bytes (80 KiB)
DocumentsNesting depth of data16
DocumentsKey^[A-Za-z0-9._:-]{1,128}$, and not made only of dots
DocumentsA string index value128 bytes
DocumentsCounter increment (by)1 to 100
ListsPage size (limit)default 25, at most 100
ListsEquality filters (where)1, on a declared index
ListsKeys in one getMany100
ListsCursor length512 characters
DeclarationCollections per app16
DeclarationCounters / marks per collection8 / 8
DeclarationIndexes per collection4, each a field path of up to 3 segments, typed string, number, boolean, time
DeclarationBuilt-in marks (never declared)report
DeclarationRefused by the server (not available yet)write: "anyone", write: "owner", kind: "log" (and maxEntries), ttlSeconds, dailyWritesPerAuthor
QuotaPer app52,428,800 bytes (50 MiB) and 1,000,000 documents
QuotaPer viewer, within one app2,097,152 bytes (2 MiB) and 1,000 documents
RateReads (info, list, get, getMany)300 a minute per viewer, or per IP address for a signed-out viewer
RateWrites to private collections, and every delete120 a minute per viewer
RateMarks and counters30 a minute per viewer
RateReports20 a day per viewer, on top of the marks-and-counters limit
RateCreating and editing shared documentsa burst of 10 refilling 1 every 6 minutes, and 50 a day refilling 1 every ~28.8 minutes, per viewer; a write needs a token from both, and only a write that lands spends one
RateShared writes the server refusedalso charged to a bucket of 10 refilling 1 every 30 minutes; while it is empty, no shared create or edit is accepted

Three things the table does not say on its own:

  • Sizes are in the stored unit. The document limit and both quotas measure the JSON text the server keeps, which can be larger than the JSON you sent. info().usage reports bytes and document counts in that same unit; size your data against it, not against what you sent.
  • Every limit is per app. One viewer at a limit in your app is not limited in another app. A shared document counts toward its author's per-viewer quota, and a document a counter created counts toward the viewer whose increment created it.
  • The shared write budget is the legacy store's budget. Creating and editing shared documents spend the same per-viewer, per-app budget as the legacy shared store's append and update, so an app using both spends one budget.

A rate_limited refusal with details.reason: "contention" means the store was busy with other writes to the same documents, not that the viewer is over a limit; it is safe to retry after Retry-After.

Where your data lives ​

The same app sees different data depending on who is running it:

  • Production — every viewer of the approved app shares one namespace: shared collections are shared across all of them, and private collections are per viewer within it.
  • Your dev token — a token from civitai app dev-token reads and writes a namespace of your own, separate from production and from every other developer. Nothing you write there reaches a production viewer.
  • A moderator's review run — a moderator running a pending submission for real gets a namespace per moderator and submission, declared by the pending manifest, and it is deleted once the submission is no longer pending.

Quotas count each namespace separately. When a moderator wipes an account's content, that account's documents and marks are removed from every app's store too.

Local development ​

  • dev:live does not reach the document store. The page-money harness forwards the block→host bridge, and the document store is not on the bridge: @civitai/sdk calls it over HTTP, and that template does not use @civitai/sdk. (The page-elements template, which does, has no dev:live script.)
  • The dev tunnel (civitai app dev-tunnel) mints a token without either storage scope, so every call is refused insufficient_scope.
  • A civitai app dev-token token carries apps:storage and apps:storage:publish when your app's manifest declares them (the approved version's, once there is one), without a consent prompt, and the store serves it only once your app has an approved version, in your own namespace (see above) with the collections that version declares. Before approval every call is refused not_eligible with details.reason: "app_not_approved".
  • dev:harness does not reach it either. The page-money mock host answers the bridge only (the legacy storage messages included), and the page-elements harness hands the app a mock token that no Civitai route accepts.

So test against a fake: createFakeStorage.

Testing with createFakeStorage ​

@civitai/sdk/testing exports createFakeStorage({ manifest }): an in-memory document store behind the same seam the real client uses, enforcing the server's rules (scopes and consent, write policies, the minimum-trust gate, signed-out readers, keys, limits and quotas, moderation, and the error codes and statuses). The SDK runs the platform's own storage contract against it.

Write your data layer against the DocumentStorage type, pass app.storage in the app and the fake's storage in tests:

ts
import { StorageError, type DocumentStorage } from '@civitai/sdk';
import { createFakeStorage, FAKE_STORAGE_MODERATION } from '@civitai/sdk/testing';

interface Request {
  title: string;
  status: 'open' | 'done';
}
const requestsIn = (storage: DocumentStorage) => storage.collection<Request>('requests');
/** The refusal's code, or 'ok' — so a test can assert on it. */
const codeOf = (p: Promise<unknown>) =>
  p.then(() => 'ok', (e: unknown) => (e instanceof StorageError ? e.code : 'not a StorageError'));

const fake = createFakeStorage({
  manifest: {
    scopes: ['apps:storage', 'apps:storage:publish'],
    storage: { collections: { requests: { visibility: 'shared', write: 'own', marks: ['vote'] } } },
  },
  viewer: { username: 'alice' },
});

const created = await requestsIn(fake.storage).create({ title: 'Dark mode', status: 'open' });

// The same store, as other callers.
const bob = requestsIn(fake.as({ viewer: { username: 'bob' } }).storage);
await bob.mark(created.key, 'vote'); // { marked: true, count: 1 }
await codeOf(bob.put(created.key, { title: 'Light mode', status: 'open' })); // 'not_author'

const newbie = requestsIn(fake.as({ viewer: { username: 'dan', trusted: false } }).storage);
await codeOf(newbie.create({ title: 'Hi', status: 'open' })); // 'not_eligible' (details.reason 'account_too_new')

const anon = requestsIn(fake.as({ viewer: null }).storage);
(await anon.list()).items.length; // 1: shared reads work signed out

const noConsent = requestsIn(fake.as({ scopes: ['apps:storage'] }).storage);
await codeOf(noConsent.create({ title: 'Hi', status: 'open' })); // 'consent_required'

await codeOf(requestsIn(fake.storage).create({ title: FAKE_STORAGE_MODERATION.blockedText, status: 'open' })); // 'content_rejected'
  • fake.calls records every request and answer.
  • Options: limits (smaller quotas, to reach quota_exceeded), consent (revoked scopes, or 'unavailable'), appStatus, disabled (every call storage_disabled) and moderate (your own scanner). By default the scanner refuses the words in FAKE_STORAGE_MODERATION.
  • Rate limits are off by default, so your tests are never throttled. Pass enforceRateLimits: true (and drive the clock with now) to test rate_limited.
  • A declaration using a reserved feature makes createFakeStorage throw, naming it, instead of failing per request.
  • It does not model moderator hides or the review namespace.

REST reference ​

Base URL https://civitai.com/api/v1/blocks/storage, with Authorization: Bearer <block token>. Every route needs apps:storage on the token. Bodies and responses are JSON; refusals use the envelope. Block origins are allowed by CORS, including the opaque Origin: null of a sandboxed block, with PUT and DELETE in the preflight and Retry-After exposed.

MethodPathBodyResponse
GET(the base URL)—info: { collections, limits, usage }
GET/{collection}—a page: { items, nextCursor }
POST/{collection}{ data }201, the created document
POST/_get/{collection}{ keys }{ items }, in the order of keys, null for each miss
GET/{collection}/{key}—the document, or 404 not_found
PUT/{collection}/{key}{ data, ifVersion? }the stored document
DELETE/{collection}/{key}— (?ifVersion= in the query){ deleted }
POST/{collection}/{key}/counters/{name}{ by? }{ value }
PUT/{collection}/{key}/marks/{name}none, or {}{ marked: true, count } (no count for report)
DELETE/{collection}/{key}/marks/{name}—{ marked: false, count }

A list takes these query parameters, each at most once: where[<index>]=<value>, sort, author=me, markedBy=me with mark, keyPrefix, limit and cursor. A where value is read by the index's declared type: a number for number, true or false for boolean, and an ISO-8601 string or epoch milliseconds for time. Any other query parameter, on any route, is refused invalid_request rather than ignored. A path that exists answers a method it does not take with 405 method_not_allowed and an Allow header.

bash
curl -s "https://civitai.com/api/v1/blocks/storage/requests?where%5Bstatus%5D=open&sort=-mark:vote&limit=20" \
  -H "Authorization: Bearer $BLOCK_TOKEN"

Moving from the legacy stores ​

useAppStorage and useSharedStorage in @civitai/blocks-react, the matching app.storage.get/set/delete/list/getQuota and app.sharedStorage methods in @civitai/sdk, and the routes under /api/v1/blocks/app-storage/* and /api/v1/blocks/shared-storage/* are deprecated. They keep working, with their scopes (apps:storage:read, apps:storage:write, apps:storage:shared:read, apps:storage:shared:write), while apps move. No removal date has been set.

Nothing is copied for you. The document store starts empty; data in the legacy stores stays there. If your app has data viewers care about, read it from the legacy store and write it into a collection yourself, or keep reading the legacy store for old data while new data goes to the collection.

Per-viewer storage (useAppStorage) ​

Declare a private collection and the apps:storage scope.

LegacyDocument store
set(key, value)put(key, data) — data must be a JSON object, so wrap a scalar: { value }
get(key)get(key) — the value is the document's data
delete(key)delete(key)
list({ prefix, cursor })list({ keyPrefix, cursor }) — whole documents, not just keys
getQuota()app.storage.info() — usage.viewer and limits
the legacy byte, row and key-length messageserror codes

Keys may not carry over as they are. A legacy key is any string of 1 to 200 characters, while a document key is limited to the characters and length in the limits table. A legacy key with a /, a space or any other character outside that set, or one longer than the limit, is refused invalid_request, so map such keys (hash them, for example) when you copy. A viewer's legacy data can be read only with that viewer's own token, so the copy runs per viewer, inside the app, as each viewer opens it.

Shared storage (useSharedStorage) ​

Declare a shared collection with write: "own", a vote mark if you had voting, and both scopes; apps:storage:publish replaces apps:storage:shared:write and needs a scopeJustifications entry.

LegacyDocument store
append({ title, body?, data? })create(data) — put what you stored in title, body and data into the document's data; every string in it is moderated
list({ limit, cursor }) (newest first)list({ sort: '-createdAt', limit, cursor })
list({ mine: true }) (REST)list({ author: 'me' })
get(key)get(key)
update(key, value)put(key, data), by the author
withdraw(key)delete(key), by the author
vote(key) / unvote(key)mark(key, 'vote') / unmark(key, 'vote')
count on an item, getCount(key), getCounts(keys)marks.vote on the document; getMany(keys) for several
viewerVoted on an itemviewer.marks.includes('vote')
authorUserId on an itemauthor (isViewer, and username when the collection sets showAuthor); no user id
report(key, reason?)mark(key, 'report') — no free-text reason, and it cannot be withdrawn

Two things do not carry over from a legacy board:

  • Authorship. A shared document is authored by the viewer whose token created it, and there is no app-owned write yet (write: "owner" is reserved). Copying a legacy board makes whoever runs the copy the author of every copied document: each one counts toward that viewer's quota and spends their shared write budget, and no other viewer can edit or delete it. Keep reading old rows from the legacy store, or have each author post again.
  • Votes. A mark belongs to the viewer who set it, so legacy vote counts cannot become vote marks. They start at zero.

Two things still read only the legacy shared store, so keep using it for them:

Civitai Developer Documentation