GeezerVison is showing github.com — feat: hooks for derived and checked data (`generated`, `unique`, `check`, `aggregate`, `denormalize`, …), query utils and testing helpers by fratzinger · Pull Request #67 · feathersjs/feathers-utils
Conversation
fratzinger
commented
Oct 5, 2026
•
edited
Loading
Uh oh!
There was an error while loading. Please reload this page.
Hooks for derived and checked data, built on a shared way of reading the items of a call (getMergedData), plus query utils and testing helpers.
New
Hooks
generated: fields computed from other fields of the same item, like a generated column. generate(items, context) computes all items of a call at once and may be async. A patch recomputes only when it changes a from field; missing fields are read with getMergedData. If a multi patch gives the items different values, they are written after the call with one _patch per group of equal values (patchBatch). Use as around, or as before + after.
unique: checks like a unique index. Several fields, where as a partial index, and nullsNotDistinct. It checks the items of the call among themselves and against the stored items with a single query. By default it uses _find, so hooks of find can't hide conflicts (underscore: false switches to find). Items whose values don't change are skipped. error replaces the Conflict.
check: a CHECK constraint across fields, e.g. startDate <= endDate. A patch of only endDate is checked against the stored startDate, which a validator of data can't do.
restrictQuery({ fields }): the fields external callers may filter, sort and select by, and the operators allowed on each. Covers $and/$or/$nor, $sort, $select and nested paths. Without it, a client can filter by { password: { $regex: '^a' } }.
skipUnchanged: drops the fields of a patch that keep their stored value. If nothing changes, the write is skipped: the result is the stored items and no patched event is sent. ignore: ['updatedAt'] keeps timestamps from counting as a change.
aggregate: values on parent items computed from their children, like posts.commentCount. compute(parentIds, context) gets every parent the call touched, before and after it, so a move updates both. Parents with equal values share a write.
denormalize: copies fields from a related item and keeps them in sync, like posts.authorName from users.name. It returns a target hook (a generated hook, with one query per call) and a source hook (one patchMany per changed source item, through the target's hooks and with events). Several definitions work side by side on one target.
Utils
getMergedData(context, fields, { select }) returns the given fields of every item a call writes, with the values the item has once the call is done, plus stored when they were fetched. Only the needed fields are selected (select: false fetches whole items), and the fetch is shared with stash, trigger and the hooks above.
findAll(app, path, params, { underscore }) returns all items as an array, whether or not the service paginates.
getChanges(before, after, fields?) returns the fields that differ, as { [path]: { before, after } }.
getIdField(service)
Predicates
isSelected(fields): whether $select covers the fields. The id field always counts as selected; find with $limit: 0 selects nothing.
Testing
runHook(hook, options) runs a single hook through Feathers on a stub service (or a given service/app) and returns the context.
expectNoSideEffects and deepFreeze, moved from test/utils and made independent of the test framework.
Changed
preventChanges compares with the stored item:
- On
update it always compares. A changed field gets its stored value back (or the hook throws with error). Before, it deleted the field, because update replaces the whole item, and with error it always threw.
- On
patch, the comparison is opt-in with compare: true.
Fixes
checkContext listed every given criterion in its error, not just those that did not match.
findOrCreate treated a page from a service that paginates despite paginate: false as empty and created a duplicate.
changeMany (patchMany/removeMany), zipDataResult and trigger now also respect service.options.id.
isEqual compared new Array(2) and [] as equal.
Internal
call-fetches.ts holds the fetches of a call, shared by defineStash, getMergedData and the hooks above. A fetch is reused when its params differ only by a broader (or no) $select. defineStash behaves as before.
common: path helpers in paths.ts, selectCovers, hasAllOwnProperties, findAllOf, and isDeepEqual/isUnchanged next to isEqual. Items count as unchanged by content, so an unchanged bson ObjectId counts as unchanged too.
- The 37 "does not mutate" tests of hooks and resolvers run through
runHook.
- CLAUDE.md gets a performance section.
Review notes
$-prefixed keys in patch data ($inc, …) are treated as plain keys, because their meaning depends on the adapter. getMergedData throws for a dotted patch key that overlaps a requested field.
- Writes after the call (the split writes of
generated, plus aggregate and denormalize) are not atomic with the call unless a transaction is passed in params. unique can't rule out races between concurrent calls; only a database index can.
preventChanges without compare still turns { role: { type } } into { role: {} } when role.type is protected. This is existing behavior with an existing test, so I left it unchanged; it's worth a follow-up.
Testing
pnpm test (lint, typecheck, coverage) passes: 173 test files, 2100 tests, coverage 97.8%. pnpm build passes.
🤖 Generated with Claude Code
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
…ted` and testing helpers
New:
- hooks: `generated` (fields computed from other fields of the item) and
`unique` (checks like a unique index, with `where` and `nullsNotDistinct`)
- utils: `getMergedData`, `findAll`, `getIdField`
- predicates: `isSelected`
- testing: `runHook`, `expectNoSideEffects`, `deepFreeze`
Fixes:
- `checkContext` only reports the criteria that did not match
- `findOrCreate` finds the record of a service that paginates despite
`paginate: false`
- `changeMany`, `zipDataResult` and `trigger` respect `service.options.id`
- `isEqual` tells `new Array(2)` from `[]`
Internal:
- the fetches of a call are shared via `call-fetches.ts`, also between
`defineStash` and `getMergedData`, and reuse fetches with a broader `$select`
- path and query helpers in `common`, `isDeepEqual` next to `isEqual`
- the "does not mutate" tests of hooks and resolvers run through `runHook`
- CLAUDE.md: a performance section
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Deploying feathers-utils with Cloudflare Pages
View logs
|
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
…normalize` and `getChanges`
New:
- hooks: `check` (a CHECK constraint across fields), `restrictQuery`
(fields and operators external callers may query by), `skipUnchanged`
(drops unchanged patch fields, skips no-op writes), `aggregate` (values on
parent items computed from their children) and `denormalize` (fields
copied from a related item, kept in sync)
- utils: `getChanges`
- `getMergedData`, `generated`, `unique` and `check` take `select: false`
to fetch whole items, which later hooks of the call reuse
Changed:
- `preventChanges` compares with the stored item on `update` - and restores
a changed field instead of deleting it - and on `patch` with `compare`
- `check`, `unique` and `generated` compare items by content (`isUnchanged`),
so an unchanged bson `ObjectId` counts as unchanged
Co-Authored-By: Claude Opus 5.5 <[email protected]>
fratzinger
changed the title
feat: add generated, unique, getMergedData, findAll, isSelected and testing helpers
feat: hooks for derived and checked data (generated, unique, check, aggregate, denormalize, …), query utils and testing helpers
Oct 5, 2026
Uh oh!
There was an error while loading. Please reload this page.
Assignees
No one assigned
Milestone
No milestone
Development
Successfully merging this pull request may close these issues.
Uh oh!
There was an error while loading. Please reload this page.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
|