Skip to content

feat: hooks for derived and checked data (generated, unique, check, aggregate, denormalize, …), query utils and testing helpers - #67

Open
fratzinger wants to merge 2 commits into
mainfrom
feat/generated-unique-testing
Open

fratzinger wants to merge 2 commits into
mainfrom
feat/generated-unique-testing

Conversation

@fratzinger

@fratzinger fratzinger commented Oct 5, 2026 •

Copy link
Copy Markdown
Member

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

…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 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Deploying feathers-utils with  Cloudflare Pages  Cloudflare Pages

Latest commit: 50c6f76
Status: ✅  Deploy successful!
Preview URL: https://4c5f2314.feathers-utils.pages.dev
Branch Preview URL: https://feat-generated-unique-testin.feathers-utils.pages.dev

View logs

…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 <noreply@anthropic.com>
@fratzinger 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant