Skip to content

fix(nextly): let read hooks narrow the query, and run them for count - #439

Merged
mobeenabdullah merged 7 commits into
mainfrom
fix/read-path-hook-contract
Jul 31, 2026
Merged

fix(nextly): let read hooks narrow the query, and run them for count#439
mobeenabdullah merged 7 commits into
mainfrom
fix/read-path-hook-contract

Conversation

@mobeenabdullah

Copy link
Copy Markdown
Collaborator

What

The hooks that precede a where-filtered read now shape the query, and countEntries runs them.

Closes gaps G and K of tasks/094-collection-hook-lifecycle-spec.md.

The audit that decided the scope

tasks/097-hooks-remaining-gaps-plan.md records it in full. Two findings changed what this PR had to be.

1. The read-surface enumeration is now complete. Spec §3.6 enumerated the mutation service only and said the read surface needed the same treatment before it could be trusted. CollectionQueryService has exactly three public methods: listEntries and getEntry run the full chain; countEntries ran nothing. So the coverage rule can finally be stated as a rule:

Every public collection operation runs beforeOperation, its phase hooks, and its after hook. There are exactly two outliers, one per side: countEntries (read) and publishAllLocales (write, gap F).

Two omissions against an otherwise uniform convention — not a systemic hole.

2. Gap G is three defects, not one. The task file recorded only "the return is discarded". Tracing :584-621 showed the chain is disconnected from the caller's filter at every stage:

  • beforeOperation was handed args: { where: {} } — an empty filter, never params.where
  • beforeRead was handed whereFromHook ?? {} — so for any ordinary read it saw {}
  • beforeRead's return was awaited and dropped; the query was built from params.where

A hook cannot narrow a filter it was never shown. Both whereFromHook and the beforeRead return were dead, while a third mechanism (the CollectionsListQuery filter seam) was live.

The precedence, stated

params.where → beforeOperation's args.where → beforeRead's return → CollectionsListQuery seam → SQL

Each stage sees the previous stage's result. The seam stays last because it is the only one already live, so every app using it is unaffected.

Note the else branch of that seam also had to change: it fell back to params.where, which would have dropped the hooks' narrowing whenever no plugin filter was registered — the common case.

Why G and K ship together

#348 established that a list and its count must narrow identically, or the total describes rows the list correctly withheld — a disclosure, not a rounding error. Fixing G alone would have created exactly that: narrowed rows beside an unnarrowed total.

countEntries already mirrors listEntries on access, scope, status and locale, with "parity with listEntries" comments throughout — and then omitted hooks. It even declares a context parameter documented as "Arbitrary data passed to hooks via context", for hooks that never ran.

The double-execution hazard

listEntries calls countEntries for its total. Running the chain in both would fire every read hook twice per list request — an audit entry, a rate-limit tick, doubled. The nested call is told readHooksAlreadyRan: true and uses the filter the list already settled on. Covered by a test asserting exactly one run.

DRY, applied where the semantics are shared

resolveReadWhere is extracted for listEntries and countEntries, whose chains must stay identical. getEntry is deliberately not folded in: its chain is id-shaped rather than where-shaped, its beforeOperation.id seam is already live, and it shares only boilerplate. Forcing all three through one helper would abstract over a difference that matters.

Verification

  • The load-bearing test is the integration test, which drives the real service end to end. A unit test of the chain could only show the ordering is right, never that the service calls it — which is precisely what was missing.
  • I wrote such a unit test and then deleted it (second commit). It reimplemented resolveReadWhere and would have passed even if the service diverged. That is the vacuous-test pattern this program keeps finding, and shipping it would have been a worse outcome than not testing.
  • Revert-checked, with the revert proved to have applied: restoring the two lines fails the three narrowing assertions, while the no-hooks control and the run-once mirror keep passing — they must not depend on the fix.
  • Mirrors: a read with no hooks is unchanged; an absent filter stays absent; a hook returning nothing changes the filter in neither direction.
  • Failing-test name sets vs a fresh baseline at 7b0dddf1 over hooks + domains/collections: identical, 13 pre-existing, zero new. tsc 0 errors.
  • Integration suite run on SQLite locally; Postgres/MySQL run in CI.

Not in this PR

Gaps I and E were scoped into the same read-path batch and are not here — this became large enough on its own once G turned out to be three defects. They follow next, in one PR. Gaps A/F (write path) are sequenced after the mutation service quietens down; #428/#429/#434 are all sitting on it.

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@mobeenabdullah, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 18 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 4c7a4fd4-25c6-4ae2-aae8-e45a38cbbb50

📥 Commits

Reviewing files that changed from the base of the PR and between 7b0dddf and 0c27228.

⛔ Files ignored due to path filters (1)
  • .changeset/read-path-hook-contract.md is excluded by !.changeset/**
📒 Files selected for processing (2)
  • packages/nextly/src/domains/collections/__tests__/read-hooks-narrow-query.integration.test.ts
  • packages/nextly/src/domains/collections/services/collection-query-service.ts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@mobeenabdullah

Copy link
Copy Markdown
Collaborator Author

@codex please review this PR

@github-actions github-actions Bot added scope: core nextly type: docs Documentation only labels Jul 31, 2026
@pkg-pr-new

pkg-pr-new Bot commented Jul 31, 2026

Copy link
Copy Markdown

Open in StackBlitz

@nextlyhq/adapter-drizzle

npm i https://pkg.pr.new/@nextlyhq/adapter-drizzle@0c27228

@nextlyhq/adapter-mysql

npm i https://pkg.pr.new/@nextlyhq/adapter-mysql@0c27228

@nextlyhq/adapter-postgres

npm i https://pkg.pr.new/@nextlyhq/adapter-postgres@0c27228

@nextlyhq/adapter-sqlite

npm i https://pkg.pr.new/@nextlyhq/adapter-sqlite@0c27228

@nextlyhq/admin

npm i https://pkg.pr.new/@nextlyhq/admin@0c27228

@nextlyhq/admin-css

npm i https://pkg.pr.new/@nextlyhq/admin-css@0c27228

@nextlyhq/blocks-engine

npm i https://pkg.pr.new/@nextlyhq/blocks-engine@0c27228

create-nextly-app

npm i https://pkg.pr.new/create-nextly-app@0c27228

nextly

npm i https://pkg.pr.new/nextly@0c27228

@nextlyhq/plugin-form-builder

npm i https://pkg.pr.new/@nextlyhq/plugin-form-builder@0c27228

@nextlyhq/plugin-page-builder

npm i https://pkg.pr.new/@nextlyhq/plugin-page-builder@0c27228

@nextlyhq/plugin-sdk

npm i https://pkg.pr.new/@nextlyhq/plugin-sdk@0c27228

@nextlyhq/plugin-seo

npm i https://pkg.pr.new/@nextlyhq/plugin-seo@0c27228

@nextlyhq/storage-s3

npm i https://pkg.pr.new/@nextlyhq/storage-s3@0c27228

@nextlyhq/storage-uploadthing

npm i https://pkg.pr.new/@nextlyhq/storage-uploadthing@0c27228

@nextlyhq/storage-vercel-blob

npm i https://pkg.pr.new/@nextlyhq/storage-vercel-blob@0c27228

@nextlyhq/ui

npm i https://pkg.pr.new/@nextlyhq/ui@0c27228

commit: 0c27228

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4ff53f1ab3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/nextly/src/domains/collections/services/collection-query-service.ts Outdated
Comment thread packages/nextly/src/domains/collections/services/collection-query-service.ts Outdated
@mobeenabdullah

Copy link
Copy Markdown
Collaborator Author

@codex please review this PR

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9b81ebe0de

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/nextly/src/domains/collections/services/collection-query-service.ts Outdated
Comment thread packages/nextly/src/domains/collections/services/collection-query-service.ts Outdated
@mobeenabdullah

Copy link
Copy Markdown
Collaborator Author

@codex please review this PR

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: dff5cd6d18

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@mobeenabdullah

Copy link
Copy Markdown
Collaborator Author

@codex please review this PR

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 929d062531

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +369 to +370
if (CollectionQueryService.readHooksRunning.getStore()) {
return params.where;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve hooks for nested reads of other collections

When a collection-specific beforeOperation or beforeRead hook on collection A uses the documented req.nextly.find() API to read collection B, this process-wide boolean is already set, so B returns the caller's raw filter without running B's hooks. Nested list reads previously ran those hooks, and B may rely on them for tenant or soft-delete scoping; A can therefore make decisions using rows B's hook would have withheld. Track the active collection or handler instead of suppressing every nested list read.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid, and I am deliberately leaving this thread unresolved rather than patching it, because the two guard findings together say the design is wrong, not that a line is missing.

Both are real, and confirmed against the code:

  • This one (nested reads of another collection): the guard is a process-wide boolean, so a hook on A reading B via req.nextly.find() suppresses B's hooks. B may rely on them for tenant or soft-delete scoping, so A can then make decisions using rows B would have withheld. That is a worse failure than the recursion it was added to prevent — it is silent under-scoping rather than a loud stack overflow.
  • The getEntry one (count → findByID): run(true, ...) does not bypass its callback when a store already exists, so the inverse direction still recurses.

Taken together: a boolean that means "some read's hooks are running" cannot express what is actually needed, which is "this handler is already running, for this collection". Each targeted patch to it has produced the next hole — this is the third round on the same guard, and I have half-applied it twice now (list-but-not-detail, then detail-but-not-the-inverse).

The right shape is a scope carrying the collection and the handler identity, so a nested read of a different collection runs its own hooks normally while re-entry of the same handler is refused. That is a genuine design change with its own tests, not something to bolt on at the end of a long session — and the surrounding fixes on this PR already lack discriminating tests, which is the debt I do not want to add to.

So: recorded as the top item for the next session, alongside the re-diagnosis these findings make necessary anyway. Leaving both threads open so they stay visible rather than marking them addressed when they are not.

If you would rather this PR not ship a guard that under-scopes nested reads, the cleaner option is to drop the reentrancy guard from #439 entirely and land it separately once the scoped version exists — the recursion it prevents is a pre-existing hazard that only became reachable because this PR made countEntries run hooks. Happy to do that instead; it is your call.

Comment on lines +1981 to +1983
const entryId = await CollectionQueryService.readHooksRunning.run(
true,
async () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Honor the reentrancy guard in getEntry

When a hook newly started by countEntries() calls ctx.req.nextly.findByID(), getEntry() unconditionally starts another guarded context but still executes the same global or collection-specific hook, which can call findByID() again indefinitely. The fresh evidence beyond the earlier getEntry-to-count report is this inverse count-to-getEntry path: run(true, ...) does not bypass its callback when a store already exists. Check the current store and skip the detail read hooks for a hook-originated read.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid, and I am deliberately leaving this thread unresolved rather than patching it, because the two guard findings together say the design is wrong, not that a line is missing.

Both are real, and confirmed against the code:

  • This one (nested reads of another collection): the guard is a process-wide boolean, so a hook on A reading B via req.nextly.find() suppresses B's hooks. B may rely on them for tenant or soft-delete scoping, so A can then make decisions using rows B would have withheld. That is a worse failure than the recursion it was added to prevent — it is silent under-scoping rather than a loud stack overflow.
  • The getEntry one (count → findByID): run(true, ...) does not bypass its callback when a store already exists, so the inverse direction still recurses.

Taken together: a boolean that means "some read's hooks are running" cannot express what is actually needed, which is "this handler is already running, for this collection". Each targeted patch to it has produced the next hole — this is the third round on the same guard, and I have half-applied it twice now (list-but-not-detail, then detail-but-not-the-inverse).

The right shape is a scope carrying the collection and the handler identity, so a nested read of a different collection runs its own hooks normally while re-entry of the same handler is refused. That is a genuine design change with its own tests, not something to bolt on at the end of a long session — and the surrounding fixes on this PR already lack discriminating tests, which is the debt I do not want to add to.

So: recorded as the top item for the next session, alongside the re-diagnosis these findings make necessary anyway. Leaving both threads open so they stay visible rather than marking them addressed when they are not.

If you would rather this PR not ship a guard that under-scopes nested reads, the cleaner option is to drop the reentrancy guard from #439 entirely and land it separately once the scoped version exists — the recursion it prevents is a pre-existing hazard that only became reachable because this PR made countEntries run hooks. Happy to do that instead; it is your call.

Comment thread packages/nextly/src/domains/collections/services/collection-query-service.ts Outdated
@mobeenabdullah

Copy link
Copy Markdown
Collaborator Author

@codex please review this PR

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Breezy!

Reviewed commit: 0c27228376

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@mobeenabdullah
mobeenabdullah merged commit f2c6e97 into main Jul 31, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

scope: core nextly type: docs Documentation only

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant