Store data
Document collections with a small API, scoped automatically to the work that uses them.
Start with a collection
Call db.collection(name) inside a work. Collections are schemaless: store JSON without defining field types or required fields. A collection appears when its first record is written; there is no separate provisioning step. Feature and collection names start with a lowercase letter and contain lowercase letters, digits, underscores, or hyphens, up to 48 characters.
const notes = db.collection("notes");
const saved = await notes.create({ message: "Ready to go", status: "new" });
const sameNote = await notes.get(saved.id);Each document is { id, data, revision, createdAt, updatedAt, expiresAt }. expiresAt is null unless set. A missing or expired get(id) returns null. You can provide a stable ID with create(data, { id }); an existing unexpired ID produces a write conflict.
Understand the data scope
Records are scoped by store + feature. Two works with the same feature in the same store share collections; a different feature has separate data. Test runs and real runs read and write the same documents. Your code cannot select another store’s namespace.
Test records
A document created by a test run is a test record. read_collection shows it with test: true, list_collections counts it in testCount, and the Data page marks it. When a real run later updates it, it becomes an ordinary record. delete_test_records with an optional feature (CLI: littleworks test-records delete [feature], or Delete test records in Data) removes every test record, including expired ones, and reports how many it deleted. Agents ask the merchant before deleting test records. delete_record with { feature, collection, id, revision } (CLI: littleworks record-delete, or Delete record on a record’s page) deletes one real or test record; it fails with WRITE_CONFLICT if the record changed since that revision was read and DOCUMENT_NOT_FOUND if it no longer exists.
- Only creation is tracked. A test run that updates or deletes a document that already existed changes real data, and cleanup does not restore it. Test against records the test created.
- Setup data written by a test run, such as settings or a first counter document, is a test record and is removed by cleanup. Write real setup data with a real run of a published work.
- Test records count toward the document allowance and uniqueness rules until they are deleted, so a leftover test record can make a real save with the same unique value fail.
- Cleanup covers Littleworks documents only. Shopify and external effects of test runs are real.
Browse data in Shopify
Open Data in Littleworks to find collections by name or group. Open a collection to choose table columns, filter by typed field values, sort records, or find an exact record ID. Filters and sorting apply to the entire collection, with 50 records per page. Column suggestions are sampled from records; they don’t define or enforce a schema.
Open a record to inspect its fields and nested values, switch to formatted JSON, and see its revision, timestamps, and expiration. You can copy or export that record, or export the current collection page as JSON. Collection details show uniqueness rules and works that share its group. These views inspect data; changes still run through your works.
Read and filter
const page = await db.collection("notes").list({
where: { status: "new" },
limit: 25,
});
// Continue only when nextCursor is non-null.
if (page.nextCursor) {
const nextPage = await db.collection("notes").list({
where: { status: "new" },
limit: 25,
after: page.nextCursor,
});
}Lists return { items, nextCursor }, with at most 100 documents per page; a page of large documents stops early at 1 MiB, so keep following nextCursor. Filter on up to five fields and 50 comparison values in total. A field is a top-level name or a dotted path into nested data of up to four parts, such as shipping.city; names start with a letter or underscore, up to 64 characters. Equality shorthand remains supported; operators are $eq, $ne, $gt, $gte, $lt, $lte, $in (up to 20 values), and $exists. Conditions are combined with AND. Comparisons preserve types: false is different from 0, and null is different from a missing field. $ne matches existing fields only; use $exists: false for missing fields. Range comparisons work on numbers or strings of the same type. For text, search: {text, fields} matches a case-insensitive substring in up to five fields, and count, sum and average aggregate a collection with the same filters. There are no joins or SQL.
const page = await db.collection("reviews").list({
where: { status: "approved", rating: { $gte: 4 } },
orderBy: { field: "$createdAt", direction: "desc" },
limit: 20,
});Default ordering is document ID ascending. orderBy accepts a field or dotted path, or $id, $createdAt, $updatedAt; direction is asc or desc. ID breaks ties in the same direction. Ascending data-field ordering is missing/object/array, null, boolean, number, then string; objects and arrays are not compared internally. Strings use binary ordering, not locale-aware sorting. Pass the returned cursor unchanged with the same collection, filters, and ordering. A sorted cursor still works if its anchor record is deleted. Pagination is not a snapshot: concurrent edits to sort values can move records between pages.
Keep data consistent
Storage gives you the guarantees a store feature usually needs from a database transaction:
- Revisions. Every document has a revision. Pass the one you read when you update or delete, and a concurrent change fails with
WRITE_CONFLICTinstead of being overwritten. - Atomic batches.
db.batchapplies up to 50 creates, updates and deletes together, or none of them. - Uniqueness rules.
ensureUnique(["customerId", "productId"])makes every write respect a composite unique key, which also makes repeated deliveries safe. - Counters.
patch(id, {increment: {points: 50}})updates a number without a read-modify-write race.
Update and delete with a revision
const notes = db.collection("notes");
const current = await notes.get(input.id);
if (!current) return Response.json({ error: "Not found" }, { status: 404 });
return notes.update(current.id, {
...current.data,
status: "reviewed",
}, { revision: current.revision });An update replaces the entire data object. Keep existing fields explicitly if you want to preserve them. Updates and deletes require the revision you read. A stale revision produces WRITE_CONFLICT; reload and reassess before retrying.
await notes.delete(current.id, { revision: current.revision });Patch fields and increment counters
const updated = await db.collection("reviews").patch(input.id, {
set: { status: "approved" },
unset: ["moderationNote"],
increment: { helpfulVotes: 1 },
});patch(id, { set?, unset?, increment? }, { revision?, expiresAt? }) preserves other fields. All changes to that document commit together. An increment initializes a missing field to zero, but rejects an existing non-number. A field can appear in only one operation. Without an explicit revision, Littleworks retries up to five conflicting storage attempts against the latest record; continued contention returns WRITE_CONFLICT. With a revision, a stale write fails immediately. These retries never repeat Shopify calls or rerun the work. Repeating a successful patch yourself increments again.
Opt in to unique values
const reviews = db.collection("reviews");
await reviews.ensureUnique(["customerId", "productId"]);
await reviews.create({ customerId: input.customerId, productId: input.productId, rating: 5 });
const rules = await reviews.uniqueRules();A uniqueness rule persists for this store, feature, and collection and is enforced on every create, update, patch, and batch—even when another work omits ensureUnique. Repeating ensureUnique with the same fields is safe. Existing duplicates prevent adding the rule with UNIQUE_RULE_CONFLICT. Duplicate writes return WRITE_CONFLICT and leave the batch unchanged. Each collection supports three rules of up to three fields. Fields remain optional and untyped: a rule participates only when all its fields contain non-null scalar values. Missing fields, nulls, objects, and arrays are ignored. This is not a collection schema or identity check; validate required values and caller identity in your work.
Inspect uniqueRules() before changing a rule. removeUnique(fields) removes that exact rule for every work sharing the collection; use it only when the build calls for changing the invariant, not to bypass a conflicting write. Rules apply to test and real runs alike. Rules constrain values but do not create custom database indexes; filtered queries remain bounded to the collection.
Expire temporary records
await db.collection("invitations").create({ status: "pending" }, {
expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
});expiresAt accepts an ISO timestamp with a timezone or null. Expired records immediately disappear from reads, collection counts, and exports and stop reserving unique values. A bounded periodic cleanup removes their stored rows; physical deletion can happen later under load. Updates and patches preserve expiration when omitted; pass null to remove it. An expired record cannot be updated, but its ID can be reused by a new create. Expiration is not a timer and does not run code.
Write an atomic batch
await db.batch([
{ type: "create", collection: "notes", id: "note-001", data: { message: "Hello" } },
{ type: "create", collection: "receipts", id: "receipt-001", data: { noteId: "note-001" } },
]);A batch supports up to 50 operations on distinct documents within the current scope, about 600 KB of data in all. Every operation succeeds or the entire batch fails. A database batch does not include Shopify requests; it cannot roll back a Shopify mutation.