Skip to content

Configuring Collections

A collection declaration has three parts:

ts
products = this.collection(productSchema)
  .scope((p, params) => p.tenantId === params.tenantId, { tenantId })
  .softDelete(p => p.deletedAt)
  .proxy()
  .create();
  1. collection(schema) selects the schema.
  2. Builder features and a change-tracking mode configure behavior.
  3. create() constructs the collection.

There is deliberately no default mode: create() is unavailable until you choose one.

Change-tracking modes

ModeValues returned by readsHow to updateBest fitTrade-off
.proxy()Tracked proxiesMutate properties, then savePrecise ordinary editsPer-write proxy overhead; nested in-place mutations have limits
.diff()Plain canonical objectsMutate properties, then saveProxy-free mutable modelsSave compares snapshots and writes the whole changed entity
.immutable()Deeply frozen valuescollection.update(entity, patchOrUpdater)UI/state architectures and safe stale referencesUpdates must use the collection API
.readonly()Read-only collection surfaceNot availableReference data and read modelsNo add, update, or remove API
ts
class AppStore extends DataStore {
  proxied = this.collection(productSchema).proxy().create();
  diffed = this.collection(productSchemaV2).diff().create();
  immutable = this.collection(productSchemaV3).immutable().create();
  lookup = this.collection(countrySchema).readonly().create();
}

A store cannot contain two collections or views with the same compiled schema. Use separate schemas (or a separate store) when you need two configurations over one physical database.

Immutable updates

ts
const current = store.products.update(product, { price: 12 });
const incremented = store.products.update(product, p => ({ ...p, stock: p.stock + 1 }));

store.products.current(product);   // latest generation of this row
store.products.isCurrent(product); // whether this reference is current

The entity argument only identifies the row. An updater receives the latest value, so a stale reference does not overwrite a newer update.

Builder features

The following features work before or after the mode call. Their order does not change behavior.

ts
// Equivalent builder order
this.collection(schema).scope(filter).audit(logSchema).derive(derive).proxy().create();
this.collection(schema).proxy().scope(filter).audit(logSchema).derive(derive).create();
FeaturePurposeRequirements
.scope(filter)AND a filter into every queryPlain or parameterized filter
.softDelete(selector)Stamp instead of deleting and hide stamped rowsNullable/optional date or boolean property; writable mode
.audit(schema).derive(fn)Append caller-shaped audit rows during the same saveAudit schema; callback emits zero or more rows
.fullTextSearch(options?)Maintain an index for .search()At least one .searchable() string and one stable key

Features compose. This is a supported declaration:

ts
articles = this.collection(articleSchema)
  .scope((a, p) => a.tenantId === p.tenantId, { tenantId })
  .softDelete(a => a.deletedAt)
  .audit(articleAuditSchema)
  .derive((changes, emit) => emit(changes.map(change => ({
    id: crypto.randomUUID(),
    articleId: String(change.id),
    operation: change.operation,
    before: JSON.stringify(change.previous ?? null),
    after: JSON.stringify(change.delta ?? null),
    at: change.at,
  }))))
  .fullTextSearch({ stopWords: "english", minTokenLength: 2 })
  .immutable()
  .create();

Scope

ts
.scope(p => p.active === true)
.scope((p, params) => p.tenantId === params.tenantId, { tenantId })

Scopes apply to normal queries, joins when this collection is the inner side, search results, and subscriptions. Use them for tenant boundaries and for backends such as PouchDB that keep several logical collections in one physical store.

Soft delete

ts
const schema = s.define("products", {
  id: s.string().key(),
  deletedAt: s.date().nullable().default(() => null),
}).compile();

products = this.collection(schema).softDelete(p => p.deletedAt).proxy().create();

removeAsync() writes the current date (or true for a boolean property). The generated scope treats both null and a missing value as not deleted. To inspect deleted rows, open a separate store whose declaration omits .softDelete().

Audit

derive receives the complete batch for this collection once per save. Each change contains collection, operation, id, entity, at, plus delta and previous for updates. Call emit(rows) to append rows; emit nothing to skip the batch. Database-assigned IDs are unavailable for audit rows describing new entities.

Audit rows join the same persistence batch. They are atomic with the source changes only when the underlying plugin makes a save atomic.

See Full-Text Search for search, tokenizer, score, repair, and key restrictions.

Extending a collection

create(factory) receives the selected collection constructor and its dependencies:

ts
products = this.collection(productSchema).proxy().create(
  (CollectionType, dependencies) => new ProductCollection(dependencies)
);

Your extension must extend the collection type selected by the mode. See Extending Collections.

Views use a different builder

ts
activeProducts = this.view(activeProductSchema)
  .scope(p => p.tenantId === tenantId)
  .derive(emit => store.products.subscribe().toArray(rows => emit(rows.filter(p => p.active))))
  .create();

A view supports .scope(), .derive(), and .create(). It does not choose a tracking mode and does not support collection-only features. A view schema cannot use an identity key: use a known key for a materialized mirror, or a computed key for append-only history. See Views.

Store-wide options

ts
super(plugin, {
  crossTabSync: false,
  semiJoinKeyThreshold: 250,
});
  • crossTabSync defaults to true. Disable it only when no other tab or worker must receive save notifications.
  • semiJoinKeyThreshold defaults to 500. It controls when a join sends outer keys as an IN (...) prefilter; it changes cost, not results.

Released under the MIT License.