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>.
| Rule | Detail |
|---|---|
| Merge | Named indexes / date fields dedupe; schema, codec, and collection-setting maps merge by field |
| Conflict | Conflicting scalars / validators throw |
| Forbidden | Cannot change name, scope, collection, or nest plugins |
| Purity | Synchronous 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 }.
| Phase | Input / return |
|---|---|
| Before insert/upsert/replace | Receive and return T |
| Before patch | Receive/return PatchByIdArgs |
| Before delete | Receive id; return nothing |
| After write | Parsed data (after delete: id) |
afterRead | Partial<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
ModelAfterHookErroron a non-transaction write. - Treating scopes as write scopes or auth boundaries.
- Expecting bulk helpers to be transactional.
See also
- Models and definitions — where plugins attach
- Validation and codecs — validators
- Relationships and hydration —
withDocumentMethods - CRUD and queries — underlying reads/writes
- Provisioning and transactions — transaction retries vs hooks
- API reference — plugin and extension helpers