CouchSet

Plugins, hooks, scopes, and operations

Compose definitions and add local behavior without changing global prototypes.

Extensions let you compose definitions, observe lifecycle events, build typed query views, and attach domain methods — all without mutating global prototypes.

Plugins

defineModelPlugin() synchronously composes options during defineModel(). Plugins receive a deeply frozen copy of options accumulated so far and return only their contributions.

import { defineModel, defineModelPlugin } from 'couchset/next';

const timestamps = defineModelPlugin<User>((definition) => ({
  dateFields: [...(definition.dateFields ?? []), 'lastLoginAt'],
  indexes: [{ name: 'idx_user_email', fields: ['email'] }],
}));

const users = defineModel<User>({ name: 'User', plugins: [timestamps] });

Signature: (definition: Readonly<ModelDefinition<T>>) => ModelPluginContribution<T>.

RuleDetail
MergeNamed indexes / date fields dedupe; schema, codec, and collection-setting maps merge by field
ConflictConflicting scalars / validators throw
ForbiddenCannot change name, scope, collection, or nest plugins
PuritySynchronous plain objects only; mutable class instances and cycles are rejected; no I/O

Resolution removes the plugin list. Reuse the resolved definition for repeated registration. A plugin that allocates fresh functions each run should be resolved once.

withModelValidator(schema) adapts a Standard Schema v1 validator into a plugin (sync/async, structured issues). Create/replace validate; patches and raw mutations do not. See Validation and codecs.

Hooks

Client definitions accept hooks for before/after Insert, Upsert, Replace, Patch, Delete, and afterRead. No hook is enabled by default.

const users = defineModel<User>({
  name: 'User',
  hooks: {
    beforeInsert: async (data, context) => ({
      ...data,
      email: data.email.toLowerCase(),
    }),
    afterRead: async (partial, { operation, transaction }) => {
      metrics.read(operation, transaction); // projections may make `partial` incomplete
    },
  },
});

Every hook receives { operation: string, transaction: boolean }.

PhaseInput / return
Before insert/upsert/replaceReceive and return T
Before patchReceive/return PatchByIdArgs
Before deleteReceive id; return nothing
After writeParsed data (after delete: id)
afterReadPartial<T> once per returned row

Before-hook failure prevents the operation. After-hook failure becomes ModelAfterHookError(cause, context): an ordinary mutation already succeeded; a transaction attempt may roll back. Do not blindly retry an ordinary mutation after this error.

Transaction hooks run inside the SDK's retryable attempt and may repeat — they are not post-commit callbacks. Avoid irreversible side effects.

Covered vs bypassed

Hooks cover model insert/upsert, replaceById, patchById, deleteById, and reads. Direct softDeleteById / restoreById go through patchById, so they run Patch hooks. Soft delete via deleteById runs beforeDelete, then beforePatch, the mutation, afterPatch, then afterDelete.

Bypass: raw queries, mutateById, incrementById, consumeOnce, and manual parse/hydrate. Definition hooks apply to client-bound models; plain new Model() is unchanged.

Scopes and domain methods

import { withModelMethods, withModelScopes } from 'couchset/next';

const directory = withModelScopes(users, {
  forTenant: (id: string) => ({ where: { tenantId: id } }),
  summary: () => ({ select: ['name'] as const }),
});

await directory.scopes.forTenant('acme').scopes.summary().findMany({ limit: 20 });

Predicates combine with AND. Other options: later declaration wins, then call arguments. queryOptions is replaced as a whole (incompatible consistency modes are never merged). Scope views expose reads, not scoped writes or raw SQL++.

withDeleted() / onlyDeleted() / withoutDefaultWhere() preserve named scope predicates while forwarding to the base model. These filters are conveniences, not authorization.

const directory = withModelMethods(users, {
  findByName(name: string) {
    return this.findOne({ where: { name } });
  },
});
await directory.findByName('Jane');

Methods get a typed model this, refuse collisions, and leave the original model / global prototypes alone. Detaching a method loses this. For hydrated documents, use withDocumentMethods (Relationships and hydration).

Bulk and instrumentation

bulkMap(items, operation, options) returns input-ordered fulfilled / rejected / skipped results. Default: ordered serial, stop after failure. { ordered: false } completes all work with default concurrency 8. Work is non-atomic and not silently retried.

withModelBulk(model) adds insertMany, getMany, and deleteMany while preserving keys, validators, and hooks. getMany includes CAS; insertMany follows ordinary insert results (no fabricated mutation tokens). Use bulkMap with SDK ops when you need exact SDK mutation results (that path bypasses model hooks).

Client instrumentation supports onOperation, onQuery, onMutation, onSlowOperation, onRetry, and slowOperationMs (default 1,000 ms). Normal events are { model, operation, durationMs, outcome: 'success' | 'failure' }; retry events are { operation: 'transaction', attempt }. Events never contain documents, credentials, parameters, or raw errors. Observers are not awaited and cannot change outcomes. Nested ops may emit separate events (e.g. soft-delete includes a patch).

Pitfalls

  • Side effects or I/O inside plugins.
  • Blindly retrying after ModelAfterHookError on a non-transaction write.
  • Treating scopes as write scopes or auth boundaries.
  • Expecting bulk helpers to be transactional.

See also

On this page