Data
Your application's own document store - a dedicated Firestore database, partitioned per workspace, with Connect doing the authorization and the audit.
Your application's own document store: a dedicated Firestore database, provisioned when the application was registered, that only Connect can reach.
You never hold a Firebase credential, and there are no security rules to write. Connect does the authorization and the audit, and your code talks HTTPS.
Collections are declared, not created
A collection exists because it was declared in the Console, on your application's Store tab. Writing to an undeclared name is a 403, not an implicit create - the declaration is the allowlist, which is what makes this API safe to expose without a rules language.
Each declaration names two things:
| Field | Meaning |
|---|---|
readPermission / writePermission | A permission id from your own permission catalog. A signed-in user must hold it. null means no per-user gate. |
queryableFields | The only fields a filter, an orderBy, an aggregation or an index may name. |
indexes | Composite indexes Connect builds in your database - what lets a query use more than one field. |
An API key acts as the application itself, so the declared permissions do not apply to it - your data:read/data:write scopes are its gate. They apply to a user reaching the store through the SDK bridge, whose permissions Connect resolves for your client_id.
Every document lives under a workspace
workspaceId is required on every call, and it is a path segment, not a filter - documents live at workspaces/{workspaceId}/{collection}/{id}. A query cannot accidentally cross tenants because there is no query without the prefix.
const ticket = await connect.data.create({
workspaceId,
collection: "tickets",
data: { subject: "Card declined", status: "open" },
});
const open = await connect.data.query({
workspaceId,
collection: "tickets",
filters: [{ field: "status", op: "==", value: "open" }],
orderBy: { field: "createdAt", direction: "desc" },
limit: 50,
});A query needs an index, and there are two ways to have one
Firestore maintains a single-field index automatically. That covers one filter, or an orderBy on that same field, and it needs no declaration.
Anything wider - two filters, or a filter ordered by a different field - needs a composite index, and composite indexes are per-database. Your database is ours, so there is no firestore.indexes.json deploy you could make and none we could make on your behalf. Declaring one on the Store tab is therefore also the act that builds it: Connect reconciles the declared list into your database on every save, creating what is new and removing what is gone (an index nothing queries is still written on every document write).
A query with no matching index is a 400 that names the index to declare, in the syntax the Store tab takes:
ticketshas no index for this query. Declare one over status (asc), createdAt (desc) on this application's Store tab, then retry - index builds take a few minutes.
So the usual way to fill that field in is to run the query and paste the error. Field order matters and is not the order you wrote the query in - Firestore's rule is equality fields first, then the array-contains field, then the field you order by, and the refusal above always states it correctly.
Page with cursor (the previous page's nextCursor) rather than a growing limit.
Dates are ISO-8601 strings
The store writes what you send it, and JSON has no date type - so send date.toISOString(). That is not a compromise: UTC ISO strings sort lexicographically in chronological order, so ordering and range filters behave exactly as they would on a timestamp. Connect's own _createdAt/_updatedAt come back the same way.
Counting and summing
Never page rows into your own process to add them up. GET /data-aggregate/{collection} counts, sums and averages in the database, billed by index entries scanned rather than documents returned - so a dashboard tile stays a handful of reads as the collection grows into six figures.
const { values } = await connect.data.aggregate({
workspaceId,
collection: "deals",
filters: [{ field: "outcome", op: "==", value: "won" }],
aggregations: [
{ op: "count", alias: "count" },
{ op: "sum", field: "value", alias: "total" },
],
});A sum or avg over nothing answers null, not 0 - "nothing matched" and "the total is zero" are different facts, and a tile that renders them the same way is lying about one of them.
Writing several documents at once
POST /data-commit applies up to 200 writes as one transaction, across as many of your collections as they name. All of them land, or none.
You never hold a transaction. A transaction handle passed across an HTTP boundary is a server-side lock whose duration your network controls, with Firestore's sixty-second ceiling and lock contention as the failure mode. Instead you read, decide, and commit conditional on nothing having moved - and Connect runs the whole set as one short transaction on its own side.
import { increment, unchangedSince } from "@cosmoops/connect-sdk";
await connect.data.commit({
workspaceId,
operations: [
{ op: "create", collection: "ledger", id: entryId, data: entry },
{
op: "merge",
collection: "funds",
id: entry.fundId,
data: { balancePaise: increment(entry.amountPaise) },
},
],
});That example needs no precondition at all, and the reason is worth internalising: increment is commutative, so two officers recording receipts against one fund both land correctly. Read-add-write from your own process loses one of them however carefully it retries, because the read and the write are two round trips with a gap in between.
Reach for a precondition when the new value depends on the old one in a way addition cannot express. unchangedSince(doc) builds one from the _updateTime a read returned; a stale one fails the whole commit with a 409 and writes nothing. connect.data.transaction(build) is that loop with the retry written once - it re-runs build against fresh reads, and retries only a 409, because a 403 means the operation was wrong rather than late.
The other transforms are serverTimestamp(), arrayUnion(), arrayRemove() and deleteField().
Connect's own fields
Every document carries _createdAt, _updatedAt, _createdBy and _updatedBy, written by Connect, plus _updateTime on anything that came from a read - Firestore's own write timestamp, and the value a { updateTime } precondition is compared against. _createdBy is "application" for a server-to-server write, or the Connect user id when a signed-in user drove it. You cannot write a _-prefixed field - that is what makes the attribution trustworthy.
Three ways to write
| Call | If it exists | If it does not |
|---|---|---|
create | 409 | Creates |
replace | Overwrites every field | Creates |
update | Merges top-level fields | 404 |
commit is the fourth, and the only one that is atomic across documents.
Writes and deletes are audited. Reads are not - the volume would swamp the trail for very little signal, and a refused read is recorded by the refusal itself.
Declaring the collection
Nothing below works until the collection exists. In the Console, open your application, go to the Store tab and add a collection:
| Field | For the example below |
|---|---|
| Name | tickets |
| Scope | Per workspace |
readPermission | tickets:read - a permission id from your own catalog, or null for no per-user gate |
writePermission | tickets:write |
queryableFields | status, createdAt - the only fields a filter, an orderBy, an aggregation or an index may name |
| Composite indexes | status asc, createdAt desc - needed for the query in the example above, which filters on one field and orders by another |
Three things follow from that form, and all three are refusals rather than surprises later. A collection name you never declared is a 403, not an implicit create. A filter on a field you never made queryable is a 400, not a slow scan. A query shape with no declared index is a 400 naming the index, not a runtime failure you cannot act on.
Deriving the declaration instead of transcribing it
Filling that composite-index row in by hand works for one query. A screen with a dozen filter combinations is a dozen chances to transcribe one wrong - and the SDK ships storeIndexFor, which computes the index a query shape needs (or null when the automatic single-field index already covers it) from the same filters/orderBy your code actually sends:
import { storeIndexFor, storeIndexSignature } from "@cosmoops/connect-sdk";
const QUERY_SHAPES = [
{ filters: [{ field: "status", op: "==", value: "open" }], orderBy: { field: "createdAt", direction: "desc" } },
// ...every other filter/sort combination this screen makes
];
const indexes = [...new Map(
QUERY_SHAPES.map(storeIndexFor)
.filter((fields) => fields !== null)
.map((fields) => [storeIndexSignature(fields), fields]),
).values()];Dedupe on storeIndexSignature before declaring - a dozen query shapes across a screen usually collapse to two or three distinct indexes, and declaring one shape twice in two field orders builds two indexes where one is dead weight on every document write. Connect runs the same derivation server-side and is the authority, so a disagreement here surfaces on the first call that index would have served rather than silently.
A worked slice
The layering is the one in Domain modelling with the SDK: schema, use-case, procedure, hook. Connect sits at the bottom of it and nowhere else.
1. The schema is the contract. A store document is { id, data } with data typed Record<string, unknown> on the wire - deliberately, because Connect does not know your shapes. Parse it once, at the boundary, and everything above it is typed.
import { z } from "zod";
export const TicketStatus = z.enum(["open", "pending", "closed"]);
/** Connect writes these and refuses them from a caller - so they are read-only here. */
const ConnectFields = z.object({
_createdAt: z.string(),
_updatedAt: z.string(),
_createdBy: z.string(),
});
export const Ticket = ConnectFields.extend({
id: z.string(),
subject: z.string().min(1).max(200),
status: TicketStatus,
});
export const CreateTicketInput = z.object({
subject: z.string().min(1).max(200),
});
export type Ticket = z.infer<typeof Ticket>;
export type CreateTicketInput = z.infer<typeof CreateTicketInput>;2. The use-case holds the rules. It is the only layer that touches Connect, and the only one that decides anything.
import type { ConnectClient } from "@cosmoops/connect-sdk/server";
import { Ticket, type CreateTicketInput } from "./ticket.schema";
export async function createTicket(
connect: ConnectClient,
workspaceId: string,
input: CreateTicketInput,
): Promise<Ticket> {
const created = await connect.data.create({
workspaceId,
collection: "tickets",
data: { ...input, status: "open" },
});
// Parse at the boundary. `created.data` is `Record<string, unknown>`;
// everything above this line is a typed `Ticket`.
return Ticket.parse({ id: created.id, ...created.data });
}
export async function listOpenTickets(
connect: ConnectClient,
workspaceId: string,
cursor?: string | null,
): Promise<{ tickets: Ticket[]; nextCursor: string | null }> {
const page = await connect.data.query({
workspaceId,
collection: "tickets",
filter: { field: "status", op: "==", value: "open" },
// Same field as the filter - see "Queries are deliberately narrow" above.
orderBy: { field: "status", direction: "asc" },
limit: 50,
cursor,
});
return {
tickets: page.items.map((doc) => Ticket.parse({ id: doc.id, ...doc.data })),
// `null` is the end. Page with this rather than a growing `limit`.
nextCursor: page.nextCursor,
};
}3. The procedure is thin, and the workspace comes from the session.
export const router = {
createTicket: authed
.input(CreateTicketInput)
.output(Ticket)
.handler(({ input, context }) =>
// Never `input.workspaceId`. A workspace a caller can supply is a
// cross-tenant write one forgotten check away.
createTicket(connect, context.session.user.workspaceId, input),
),
};4. The hook owns cache behaviour. Keys in one place, invalidation by prefix, and no optimistic patch on a write the server may refuse for a quota or a permission.
export const ticketKeys = { all: ["tickets"] as const };
export function useCreateTicket() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: client.createTicket,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ticketKeys.all }),
});
}Two callers, two gates
The same method serves both, and which gate applies is decided by whether you pass asUser:
// Your application acting as itself. Gated by the API key's `data:write` scope;
// the collection's declared permissions do not apply.
await connect.data.create({ workspaceId, collection: "tickets", data });
// On behalf of a signed-in user, through the SDK bridge. Connect resolves that
// user's permissions for your `client_id` and checks `writePermission`.
await connect.data.create({ collection: "tickets", data, asUser: sessionToken });With asUser, workspaceId is derived from the session and does not need passing - which is the same property that makes the browser path safe: there is no parameter to tamper with.
Master data: collections that belong to no workspace
A collection can be declared application-scoped instead of per-workspace. Then there is one copy, owned by you, belonging to no tenant - country lists, pricing tables, prompt templates, a catalogue of device models. Every workspace reads it; none of them owns it.
Omit workspaceId entirely for those calls. The collection's declaration decides which shape is correct, and passing the wrong one is refused rather than quietly writing somewhere else.
const countries = await connect.data.query({ collection: "countries", limit: 200 });Master data is written with your API key only, never on behalf of a user. A write permission is something a workspace grants, so honouring one here would let one tenant's admin edit reference data every other tenant reads. Reads still follow readPermission, because "every tenant may read the country list" and "only support staff may read the escalation matrix" are both reasonable.
Pick the scope deliberately. Tenant data in an application-scoped collection is a leak; reference data copied into every workspace is a synchronisation problem nobody asked for.
Reading across every workspace
GET /data-scan/{collection} returns one collection across all of them, each row carrying its workspaceId - see the KV section's neighbour in the sidebar for the full contract. It needs data:read:all, the collection must be marked cross-workspace, and it is never available on behalf of a signed-in user.
Endpoints
GET /api/v1/application/data/{collection}
List and filter a declared collection
Scope data:read · Rate limit 300 requests / min per key
Pages one workspace's documents in a collection you declared on your application's Store tab.
filters is a list, ANDed. A query needs an index for its shape, and the two ways to have one are different: Firestore maintains a single-field index automatically, which covers one filter, or an orderBy on that same field. Anything wider - two filters, or a filter ordered by a different field - needs a composite index you declare on the Store tab, and Connect builds it in your database when you save. A query with no matching index is a 400 naming the index to declare, rather than a runtime index error pointing at a console you cannot open.
Every field named in filters or orderBy must appear in the collection's queryableFields. limit defaults to 50 and caps at 200; page with cursor, taking each page's nextCursor.
Documents come back with _updateTime, Firestore's own write timestamp. That is the value a {"updateTime": …} precondition on POST /data-commit is compared against.
curl -G https://connect.cosmoops.com/api/v1/application/data/tickets \
-H "Authorization: Bearer YOUR_API_KEY_PLAINTEXT" \
--data-urlencode 'workspaceId=WORKSPACE_ID' \
--data-urlencode 'filters=[{"field":"status","op":"==","value":"open"}]' \
--data-urlencode 'orderBy={"field":"createdAt","direction":"desc"}' \
--data-urlencode 'limit=50'The singular filter parameter is still accepted as a one-element list, for integrations built against SDK 0.13 and earlier.
Errors
validation_error (400 - unqueryable field, or no declared index for this query shape), forbidden (403 - key missing data:read, or the collection is not declared), failed_precondition (409 - the database is still provisioning).
Parameters
| Name | In | Required | Notes |
|---|---|---|---|
collection | path | yes | A collection declared on the Store tab |
workspaceId | query | yes | |
filters | query | no | JSON: [{"field","op","value"}] - ANDed |
filter | query | no | Deprecated. JSON: {"field","op","value"} - accepted as a one-element filters list |
orderBy | query | no | JSON: {"field","direction"} |
limit | query | no | |
cursor | query | no | The previous page's nextCursor |
Responses
| Status | Meaning |
|---|---|
200 | A page of documents |
400 | validation_error |
403 | forbidden - key missing data:read, or collection not declared |
409 | failed_precondition - database not ready |
429 | rate_limited |
Response body
| Field | Type | Required | Notes |
|---|---|---|---|
items | array | yes | |
nextCursor | any | yes |
POST /api/v1/application/data/{collection}
Create a document
Scope data:write · Rate limit 300 requests / min per key
Creates a document in a declared collection, under one workspace.
Pass id to choose the document id, or omit it and Firestore allocates one. An id that already exists is a 409 - use PUT to overwrite deliberately.
_createdAt, _updatedAt, _createdBy and _updatedBy are written by Connect; sending any _-prefixed field is a 400. Documents cap at ~900 KB.
Errors
validation_error (400 - reserved field, or document too large), forbidden (403), conflict (409 - id already exists, or database not ready).
Parameters
| Name | In | Required | Notes |
|---|---|---|---|
collection | path | yes |
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
workspaceId | any | no | |
id | any | no | |
data | object | yes |
Responses
| Status | Meaning |
|---|---|
201 | Created |
400 | validation_error |
403 | forbidden |
409 | conflict - id already exists |
429 | rate_limited |
Response body
| Field | Type | Required | Notes |
|---|---|---|---|
id | string | yes | |
data | object | yes |
GET /api/v1/application/data/{collection}/{documentId}
Read one document
Scope data:read · Rate limit 300 requests / min per key
Reads a single document by id, under one workspace.
Reads are not written to the audit trail - the volume would swamp it. Writes and deletes are.
Errors
not_found (404), forbidden (403 - key missing data:read, or the collection is not declared).
Parameters
| Name | In | Required | Notes |
|---|---|---|---|
collection | path | yes | |
documentId | path | yes | |
workspaceId | query | yes |
Responses
| Status | Meaning |
|---|---|
200 | The document |
403 | forbidden |
404 | not_found |
429 | rate_limited |
Response body
| Field | Type | Required | Notes |
|---|---|---|---|
id | string | yes | |
data | object | yes |
POST /api/v1/application/data-get-many
Read many documents
Scope data:read · Rate limit 300 requests / min per key
Reads up to 200 documents of one collection by id, in one request.
Gated exactly like reading one document - data:read, a declared collection, and the signed-in user's read permission when the request carries a session token - and it spends one request of your rate limit, however many ids it names. Reach for it wherever you would otherwise loop over GET /data/{collection}/{documentId}: the contacts on a page of deals, the plans behind a list of subscriptions.
{ "workspaceId": "WORKSPACE_ID", "collection": "contacts", "ids": ["c_1", "c_2", "c_9"] }{ "items": [{ "id": "c_1", "data": { "name": "Asha" } }, null, { "id": "c_9", "data": { "name": "Ravi" } }] }items is aligned with ids, with null where a document does not exist - a missing document is an ordinary answer here, not a 404. A POST because the ids travel in the body; it writes nothing.
Errors
validation_error (400 - no ids, or more than 200), forbidden (403 - key missing data:read, or the collection is not declared).
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
workspaceId | any | no | |
collection | string | yes | |
ids | array | yes |
Responses
| Status | Meaning |
|---|---|
200 | The documents, aligned with ids |
400 | validation_error |
403 | forbidden |
429 | rate_limited |
Response body
| Field | Type | Required | Notes |
|---|---|---|---|
items | array | yes |
PUT /api/v1/application/data/{collection}/{documentId}
Replace or merge a document
Scope data:write · Rate limit 300 requests / min per key
Writes a document at a known id.
PUT replaces every field. Send the header X-Store-Write-Mode: merge to merge top-level fields into an existing document instead - a merge into a document that does not exist is a 404, because a partial document that looks whole is worse than a refusal.
_createdAt and _createdBy survive a replace; only _updatedAt/_updatedBy move.
Errors
validation_error (400), forbidden (403), not_found (404 - merge into a missing document).
Parameters
| Name | In | Required | Notes |
|---|---|---|---|
collection | path | yes | |
documentId | path | yes |
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
workspaceId | any | no | |
data | object | yes |
Responses
| Status | Meaning |
|---|---|
200 | Written |
400 | validation_error |
403 | forbidden |
404 | not_found - merge into a missing document |
429 | rate_limited |
Response body
| Field | Type | Required | Notes |
|---|---|---|---|
id | string | yes | |
data | object | yes |
DELETE /api/v1/application/data/{collection}/{documentId}
Delete a document
Scope data:write · Rate limit 300 requests / min per key
Deletes a document. Idempotent - deleting a document that is not there succeeds, because the end state is what was asked for.
Audited by collection and id, never by contents.
Errors
forbidden (403 - key missing data:write, or the collection is not declared).
Parameters
| Name | In | Required | Notes |
|---|---|---|---|
collection | path | yes | |
documentId | path | yes | |
workspaceId | query | yes |
Responses
| Status | Meaning |
|---|---|
204 | Deleted |
403 | forbidden |
429 | rate_limited |
GET /api/v1/application/data-scan/{collection}
Read one collection across every workspace
Scope data:read:all · Rate limit 300 requests / min per key
Your own back-office view: one collection, every workspace, one page at a time. Each row carries the workspaceId it came from.
This is the only read in the store that leaves the per-workspace path everything else is built on, so it is fenced four ways:
| Fence | Where |
|---|---|
data:read:all on the key | This endpoint |
| The collection is marked cross-workspace | Store tab, per collection |
| The caller is your application, never one of your users | Always - there is no permission that grants it |
| The scan is written to the audit trail | Always - ordinary reads are not |
The third is the one worth understanding. A workspace admin is an admin of their workspace; letting a role unlock this would mean one customer reading another's data. So there is no asUser form of this call and no permission that enables it - only your own server credential.
Cursors and indexes
A scan cursor is {workspaceId}/{documentId}, not a bare id - an id alone does not identify a document across workspaces.
Filtering and ordering follow the same index rules as a scoped query, and additionally need collection-group scope. Connect creates the single-field ones when you mark a field queryable on a cross-workspace collection, and creates a collection-group copy of every composite index the collection declares. A scan issued in the minutes after that declaration can fail while the index builds.
Omit workspaceId - passing one is a 400, because a scan that named a workspace would be a scoped read wearing the wrong name.
Errors
validation_error (400 - a workspaceId was passed, or the query needs a composite index), forbidden (403 - key missing data:read:all, the collection is not marked cross-workspace, or the request was made on behalf of a user).
Parameters
| Name | In | Required | Notes |
|---|---|---|---|
collection | path | yes | A collection marked cross-workspace on the Store tab |
filters | query | no | JSON: [{"field","op","value"}] - ANDed |
filter | query | no | Deprecated. JSON: {"field","op","value"} |
orderBy | query | no | JSON: {"field","direction"} |
limit | query | no | |
cursor | query | no | The previous page's nextCursor - {workspaceId}/{documentId} |
Responses
| Status | Meaning |
|---|---|
200 | A page of documents from across every workspace |
400 | validation_error |
403 | forbidden - not scannable, or acting for a user |
429 | rate_limited |
Response body
| Field | Type | Required | Notes |
|---|---|---|---|
items | array | yes | |
nextCursor | any | yes |
GET /api/v1/application/data-aggregate/{collection}
Count, sum and average a collection
Scope data:read · Rate limit 300 requests / min per key
Aggregates a filtered collection in Firestore, and answers with one number per alias.
This is the endpoint that keeps a dashboard from being an unbounded read. Firestore bills an aggregation by the index entries it scans rather than by the documents it would have returned, so a pipeline total over ten thousand deals costs a handful of reads - where fetching the rows and summing them in your own process costs ten thousand, and gets slower every month.
aggregations is JSON, up to five per request:
curl -G https://connect.cosmoops.com/api/v1/application/data-aggregate/deals \
-H "Authorization: Bearer YOUR_API_KEY_PLAINTEXT" \
--data-urlencode 'workspaceId=WORKSPACE_ID' \
--data-urlencode 'filters=[{"field":"outcome","op":"==","value":"won"}]' \
--data-urlencode 'aggregations=[{"op":"count","field":null,"alias":"count"},{"op":"sum","field":"value","alias":"total"}]'{ "values": { "count": 42, "total": 1875000 } }count takes no field and sum/avg require one - a sum with no field has no meaning, so it is refused rather than defaulted to a count. A sum or avg over nothing answers null rather than 0, because "nothing matched" and "the total is zero" are different facts.
Indexes
An aggregation needs the index its filters would need, plus the aggregated field: Firestore reads a sum out of the index rather than out of the documents. Declaring [outcome asc, value asc] is what serves the example above. A count needs nothing beyond what its filters already require.
There is no orderBy, limit or cursor: the aggregate of an ordered page is the aggregate of the whole filtered set, so they would be three arguments that change nothing.
Errors
validation_error (400 - malformed aggregations, an unqueryable field, or no declared index), forbidden (403 - key missing data:read, or the collection is not declared).
Parameters
| Name | In | Required | Notes |
|---|---|---|---|
collection | path | yes | |
workspaceId | query | yes | |
aggregations | query | yes | JSON: [{"op","field","alias"}] - op is count | sum | avg |
filters | query | no | JSON: [{"field","op","value"}] - ANDed |
Responses
| Status | Meaning |
|---|---|
200 | One number per alias |
400 | validation_error |
403 | forbidden |
429 | rate_limited |
Response body
| Field | Type | Required | Notes |
|---|---|---|---|
values | object | yes |
POST /api/v1/application/data-commit
Apply writes atomically
Scope data:write · Rate limit 300 requests / min per key
Applies up to 200 writes as one transaction, across as many of your declared collections as they name. All of them land, or none.
You never hold a transaction. Handing one out across an HTTP boundary would be a server-side lock whose duration your network controls, with Firestore's sixty-second ceiling and lock contention as the failure mode. Instead you read, decide, and commit conditional on nothing having moved:
{
"workspaceId": "WORKSPACE_ID",
"operations": [
{ "op": "create", "collection": "ledger", "id": "e_1", "data": { "amountPaise": 50000 } },
{
"op": "merge",
"collection": "funds",
"id": "general",
"data": { "balancePaise": { "__transform": "increment", "by": 50000 } },
"precondition": { "updateTime": "2026-09-04T10:11:12.345Z" }
}
]
}A precondition that no longer holds is a 409 and nothing is written - re-read, rebuild, retry. updateTime is the _updateTime a read returned; {"exists": true|false} is the other form.
Transforms
A value may be a transform sentinel instead of a literal, at the top level of data:
| Sentinel | Effect |
|---|---|
{"__transform":"increment","by":N} | Adds N. Commutative - concurrent increments all land, with no precondition and no retry. |
{"__transform":"serverTimestamp"} | Connect's write time. |
{"__transform":"arrayUnion","values":[…]} | Adds the values that are not already there. |
{"__transform":"arrayRemove","values":[…]} | Removes them. |
{"__transform":"delete"} | Removes the field. merge only - a replace already drops what it does not list. |
increment is the one that genuinely cannot be done any other way: read-modify-write from your own process loses one of two concurrent updates however carefully it retries.
Rules
create takes no precondition - it already means "and it must not exist". merge requires the document to exist. The same document may not be written twice in one commit, because Firestore would apply both in argument order and the outcome would depend on it. Every collection named is authorized on its own, so a commit cannot reach one you could not write to a document at a time.
Errors
validation_error (400 - malformed operations, a reserved field, or the same document twice), forbidden (403 - key missing data:write, or a collection is not declared), conflict (409 - a precondition no longer holds).
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
workspaceId | any | no | |
operations | array | yes |
Responses
| Status | Meaning |
|---|---|
200 | The id each operation wrote, in order |
400 | validation_error |
403 | forbidden |
409 | conflict - a precondition no longer holds |
429 | rate_limited |
Response body
| Field | Type | Required | Notes |
|---|---|---|---|
ids | array | yes |