Collections
Sharing one document at a time stops scaling as soon as access maps to a set of documents — a project's files, a course's materials, a client folder. A collection bundles documents so they can be shared as a unit: granting a member access to the collection grants them access to every document currently in it and to any document added later. Remove a document from the collection (or a member from the collection) and the access goes with it.
Collections sit between single-document shares and groups: share a document when access is about that one thing, share a collection when access is about a set of documents, and use a group when access is about who someone is (a team, a role). Permissions a collection grants are additive, max-wins — a collection can only add access to a document, never restrict it, and a document that belongs to several collections (or has its own direct grants) resolves to whichever source grants the most. Deleting a collection revokes the access it granted; it never deletes the documents themselves.
Creating a Collection
// Create a collection and put documents in it
const collection = await client.collections.create({ name: "Project Phoenix" });
await client.collections.addDocument(collection.collectionId, designDocId);
await client.collections.addDocument(collection.collectionId, specDocId);
// One grant covers the whole set — including documents added later
await client.collections.addMember(collection.collectionId, {
email: "alice@example.com",
permission: "read-write",
});// Create a collection and put documents in it
let collection = try await client.collections.create(
params: CreateCollectionParams(name: "Project Phoenix")
)
_ = try await client.collections.addDocument(collectionId: collection.collectionId, documentId: designDocId)
_ = try await client.collections.addDocument(collectionId: collection.collectionId, documentId: specDocId)
// One grant covers the whole set — including documents added later
_ = try await client.collections.addMember(
collectionId: collection.collectionId,
params: .email("alice@example.com", permission: .readWrite)
)collections.create() returns the new collectionId, minted server-side like a document's or group's. collectionType picks which type config governs the collection — defaulting to "default" when omitted — and is immutable once set.
A collection can also be created with its first resource metadata already stamped, via initialMetadata on collections.create() — see Attrs and Resource Metadata below for the collection-specific create-time gate this enables.
From the CLI:
primitive collections create "Q1 Reports" --description "Quarterly reports"
primitive collections create "Q1 Reports" --owner user@example.com # admin token only: the named user is the creator
primitive collections get <collection-id>
primitive collections delete <collection-id> # documents are preservedMembership and Access
Add or remove documents, and share the whole collection with a user, an email, or a group — membership works like document sharing: add by user ID or by email (an email add to a non-user resolves at signup, like any deferred grant):
// Create
const collection = await client.collections.create({
name: "Q1 Reports",
description: "All quarterly report documents",
});
// Add / remove documents
await client.collections.addDocument(collection.collectionId, documentId);
await client.collections.removeDocument(collection.collectionId, documentId);
// Share with a group (fans out to every document in the collection)
await client.collections.grantGroupPermission(collection.collectionId, {
groupType: "team",
groupId: "engineering",
permission: "read-write",
});
// Share with an individual user (O(1)). Also accepts { email, ... } for a
// deferred grant that resolves on signup, exactly like documents and groups.
await client.collections.addMember(collection.collectionId, {
userId: targetUserId,
permission: "reader",
});// Create
let collection = try await client.collections.create(
params: CreateCollectionParams(name: "Q1 Reports", description: "All quarterly report documents")
)
let collectionId = collection.collectionId
// Add / remove documents
_ = try await client.collections.addDocument(collectionId: collectionId, documentId: documentId)
_ = try await client.collections.removeDocument(collectionId: collectionId, documentId: documentId)
// Share with a group (fans out to every document in the collection)
_ = try await client.collections.grantGroupPermission(
collectionId: collectionId,
params: GrantCollectionGroupPermissionParams(groupType: "team", groupId: "engineering", permission: "read-write")
)
// Share with an individual user (O(1)). `.email(...)` also works for a
// deferred grant that resolves on signup, exactly like documents and groups.
_ = try await client.collections.addMember(
collectionId: collectionId,
params: .user(targetUserId, permission: .reader)
)A collection's current members and pending invitations, in one call:
const access = await client.collections.getAccess(collectionId);
// Or fetch just the pending (not-yet-signed-up) invitations:
const pending = await client.collections.listPendingInvitations(collectionId);let access = try await client.collections.getAccess(collectionId: collectionId)
// Or fetch just the pending (not-yet-signed-up) invitations:
let pending = try await client.collections.listPendingInvitations(collectionId: collectionId)From the CLI:
primitive collections documents add <collection-id> <document-id> # also: remove, list
primitive collections for-document <document-id> # collections a document belongs to
primitive collections share <collection-id> --group team/engineering --permission read-write
primitive collections unshare <collection-id> --group team/engineering
primitive collections members add <collection-id> <user-id> --permission reader
primitive collections members list <collection-id>
primitive collections members remove <collection-id> <user-id>
primitive collections access <collection-id> # combined groups + members viewThe four verbs that take something away — collections delete, unshare, documents remove, and members remove — prompt for confirmation unless you pass -y, and accept --json beside it so a script gets a result object on stdout naming what was removed: {"success": true, "collectionId": "…"}, plus groupType and groupId, documentId, or userId for the verb that names one. --json is not a second way to skip the prompt — without -y outside an interactive shell the command still refuses — and a refusal from the server exits non-zero with the message on stderr and nothing on stdout. See the CLI reference for the full command surface.
Sharing, changing a level, and unsharing all re-resolve every document in the collection in a single request, and so does deleting the collection — that is what keeps a document's level equal to the highest its live sources grant. Those four — collections share (whether it creates the grant or changes its level), collections unshare, and collections delete — are therefore refused on a collection holding more than 200 documents, with 409 and the code COLLECTION_FANOUT_LIMIT; the message names the cap and the collection's size. A refusal changes nothing at all, rather than leaving the grant recorded with only some of its documents updated. Split an oversized collection, or change access on the documents individually — collections documents add and collections documents remove act on one document each, whatever the collection's size, and are not capped.
collections delete carries a second limit, because it re-resolves every document once per group the collection shares with — including the two internal grants behind collections members add. It is refused with the same 409 COLLECTION_FANOUT_LIMIT when documents × groups is more than 400 (the message names both counts and the product): a 200-document collection with no group shares deletes fine, the same collection with one share does not. Unshare groups from it, or remove documents, and delete it again.
To list the documents a user reaches through their collection memberships — one of the four paths to a user's accessible documents — see Working with Documents.
Collection Types
A collection type is configuration — it lives in primitive/<env>/collection-type-configs/<type>.toml — and binds a collectionType to the rule set that governs who may create, edit, delete, and manage membership on collections of that type:
# primitive/dev/collection-type-configs/class-reports.toml
[collectionTypeConfig]
collectionType = "class-reports"
ruleSetName = "class-reports-rules"primitive config push --only collection-type-config/class-reportsA collection type with no config row uses permissive defaults — any member may create, and the creator manages what they created. Attach a rule set to tighten that: the rule set's CEL namespace is collection.* (parallel to, but separate from, a group rule set's group.*), with a collection-only helper hasCollectionAccess(collectionId) — true when the caller has direct collection membership or belongs to a group holding a permission grant on the collection. See Access Control for how rule sets are defined and bound, and Users and Groups for the parallel group treatment.
Or from the client: client.collectionTypeConfigs.create({ collectionType, ruleSetId }) (parallel to client.groupTypeConfigs.create(...)).
Attrs and Resource Metadata
Like groups, collections expose a reserved, read-only attrs category projecting the resource's own fields — reference it in a rule set as md.self.attrs.<key>, no schema or write path needed:
md.self.attrs.* |
|---|
collectionId, collectionType, name, createdBy |
Beyond attrs, a collection type can declare its own resource metadata categories and read them the same way, as md.self.<category>.<key> — a category a rule references is inferred and loaded automatically, no declaration needed. This is the recommended way to key a collection's rule set on an outside entity (the class a set of reports belongs to, the project a collection tracks): store the entity's id as a metadata category value, read it in the rule set, and stamp it at create time.
Gating Collection Creation on Staged Metadata
A collection type's collection.create rule is evaluated against the initialMetadata staged in the same collections.create() call, before the collection is persisted. The staged values are already readable as md.self.<category>.<key> at that point, so a create rule can gate creation on the exact linkage the create is about to stamp:
# the collection type's create rule
create = "isMemberOf('class-teachers', md.self.classLink.classId)"With this rule, a caller may create the collection only when they stage a classLink pointing at a class they teach — the gate reads the value being stamped, not a separate claim. The projected md.self.attrs.* columns (collectionType, name, createdBy) are available in the create rule too — collectionId alone is still null there, since the id is unassigned until the create commits.
Two properties follow from this, and both are worth stating plainly:
- It is fail-closed. Once a collection type's create rule reads
md.self.<category>, a create that omits that category is denied — the unstaged value bindsnull. A create that stamps the linkage in a later write is denied for that type: the linkage must be supplied in the create call itself (atomic create-with-linkage). - A create rule cannot traverse from the staged subject. It may read the staged value directly (
md.self.<category>.<key>), but a rule that follows a declared path off it (md.<path>.*) is rejected when the rule set is saved, because the subject does not exist yet to traverse from.
Moving Collections Between Apps
A collection travels with its documents. primitive documents export-all / documents import carry Yjs state, blobs, permissions and user-scoped aliases; primitive collections export / collections import carry the collections those documents sit in — each one's name, description, type, context id, owner, group grants, direct members with their level, and which documents it holds.
# In the source app: documents and collections into ONE directory
primitive documents export-all --user-id <user-id> --output ./primitive-export
primitive collections export --output ./primitive-export
# In the target app: documents FIRST (they preserve their ids), then collections
primitive documents import ./primitive-export --owner owner@example.com
primitive collections import ./primitive-export --dry-run
primitive collections import ./primitive-exportcollections export writes <output>/collections.json beside the manifest.json and documents/ directory a document export leaves there, so one directory is one app's migration. Order matters: collections.json refers to documents by id, and documents import is what preserves them — run the collections import first and every document in every collection comes back as a per-item problem.
Both verbs are for admin tokens. Owners and members are recorded as emails wherever the server can resolve one, because app users are scoped to their app: the same person has a different user id in each one, so a source app's user id means nothing in the target. A recorded user id with no email is consulted only when the file is being imported back into the app it came from. Create the target app's users — and its groups, which do not travel — before importing.
collections import reads before it writes: it resolves every owner, member, document and group in the target app, decides create, merge, skip or refused per collection, and only then applies the plan, granting groups first, then adding members, then documents. --dry-run stops after the plan and reports exactly what the real run would do, and every problem it found. Matching is by name, so a re-run is safe: a name already in the target is skipped unless you pass --overwrite, and with it the collection is merged only when its owner, collection type and context id match the file's — none of those can be changed after creation, so a mismatch is refused with the differing field named and nothing written to it. A group grant already present at a different level is then updated to the level the file records, and what that group can reach changes with it — the per-document permission is recomputed from its live sources. The one exception is the fan-out cap above: on a collection holding more than 200 documents the server refuses a collection-wide group change whole, with COLLECTION_FANOUT_LIMIT, and the import reports that as a group problem and merges the rest. --owner <userId-or-email> gives every collection in the run the same owner. It is resolved once, before any collection is read: whichever form you pass, a user the app has no member for fails the run there, with nothing written and nothing planned.
Anything that cannot be recreated is reported per item — the collection, the kind (owner, collection, document, member or group), the identity and the reason — on stderr and under --json in problems; the collection is still created with everything else, except a missing owner, which refuses that collection alone. The run exits 1 when any problem was reported, 0 otherwise; a skip for a name that already exists is not a problem. Check the result with collections get, collections members list and collections documents list.
Collection ids are not preserved — the server mints them, and --json maps each sourceCollectionId to its targetCollectionId. Not carried: the groups themselves, collection resource metadata, pending member invitations, and the addedAt/grantedAt timestamps.
Collections from a Server Function
ctx.api.collections provisions and tears down collections from inside a function — creating and deleting them, managing a document's membership, and sharing a collection with a group — useful when a function produces or processes a document that belongs in a shared set, on the app's own authority.
Next Steps
- Working with Documents — Documents, the resource collections bundle
- Users and Groups — Groups, the parallel "who" primitive to a collection's "what set"
- Access Control — Rule sets and the CEL identity context every management operation shares
- Resource Metadata — Attaching schema'd, access-controlled data to a collection
- Invitations — App membership and deferred grants for not-yet-users