feat(nextly): a plugin can update many entries in one call - #1819
Conversation
`ctx.services.collections` ended at `createMany`. Plugin code could write many
rows in one call and then had no way to change them in one, elevated or not, so
the only batch update available to it was a loop of `updateEntry` calls, each
with its own transaction, its own access pass and its own cache flush. The
engine path was already there: `updateEntries` takes `overrideAccess`.
`updateMany(slug, entries, opts?)` takes one `{ id, data }` per row, so a single
call can apply a different patch to each row, and applying one patch to many
rows is the same call with the patch repeated. It returns the
`BatchOperationResult` `createMany` returns, so a failed row leaves the rest
committed and is reported by the index of the entry the caller passed.
Researched first. Payload, Strapi's query engine, Directus and Sanity all take a
filter plus ONE shared patch, which cannot express a different patch per row and
whose documented failure is a filter matching more than its author meant;
Payload had to patch exactly that. WordPress's batch framework and this engine
already take a list of per-item patches. A by-filter form would also need a
second access, hook and revalidation pass to compute what it changed, and
`listEntries` composes with this method to the same capability with the rows
named, so there is one method rather than two.
A `locale` is refused by name, as on `createMany`: the bulk pipeline takes no
locale at all, and `forwardedFromContext` spreads one in where a spread
suppresses excess-property checking, so an accepted locale would be dropped and
every row written to the default language under a reported success.
The harness note on the D56 suite said bulk paths could not run end to end in
`createTestNextly`. They can: the new suite seeds with `createMany` and patches
the result with `updateMany` through a real boot. The note is corrected rather
than carried.
|
Warning Review limit reachedNext included review available in 46 minutes. View limit detailsLimit details: You’ve used the included review currently available. You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Review configuration: ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Advanced Run ID: ⛔ Files ignored due to path filters (4)
📒 Files selected for processing (9)
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. Comment |
|
@codex review |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
@nextlyhq/adapter-drizzle
@nextlyhq/adapter-mysql
@nextlyhq/adapter-postgres
@nextlyhq/adapter-sqlite
@nextlyhq/admin
@nextlyhq/admin-css
@nextlyhq/blocks-engine
@nextlyhq/blocks-react
@nextlyhq/builder
create-nextly-app
@nextlyhq/eslint-plugin
nextly
@nextlyhq/plugin-form-builder
@nextlyhq/plugin-mcp
@nextlyhq/plugin-page-builder
@nextlyhq/plugin-sdk
@nextlyhq/plugin-seo
@nextlyhq/storage-s3
@nextlyhq/storage-uploadthing
@nextlyhq/storage-vercel-blob
@nextlyhq/ui
commit: |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e526e10857
ℹ️ 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".
…d page the docs recipe Three review findings on the new `updateMany`. The end-to-end suite typed its services fixture as `any`, so every call in it compiled whether or not `PluginCollectionService` declared the method: the suite existed to pin the published surface and would have stayed green while that surface lost it. Typed as `PluginContext["services"]` instead, which immediately refused a plain string where `AuthUserId` is required and an unknown property on `AuthUser`, both of which the `any` had been hiding. `BulkUpdateEntry` is the input half of a public method and was exported from neither the `nextly` root nor the SDK, so a plugin author could not name the contract without an internal path or a `Parameters<>` extraction. Exported beside `BatchOperationResult`, through both. The by-filter recipe in the docs read one page and presented itself as the way to update every match, so it silently stopped at 100 rows. It now loops, and says why re-reading page one is right for a patch that changes what the filter matches, and what to do instead when it does not.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
@codex review |
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
…update-many # Conflicts: # scripts/doc-samples-baseline.json
…update-many # Conflicts: # scripts/doc-samples-baseline.json
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 015f8729f6
ℹ️ 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".
| updated += res.successful; | ||
| // Nothing moved: the rest are failing rather than draining, so stop rather | ||
| // than read the same page forever. | ||
| if (res.successful === 0) break; |
There was a problem hiding this comment.
Stop when the filtered page does not drain
When a beforeUpdate hook rewrites or removes the status patch—supported behavior because these hooks may modify incoming changes—updateMany still reports successful rows while those same rows continue matching status: "draft". This revised page-1 retry therefore loops forever because its only escape checks for zero successes; success does not prove the filtered page drained. Collect the matching IDs before mutating, or detect repeated page IDs instead.
Useful? React with 👍 / 👎.
| QueryOptions, | ||
| PaginatedResult, | ||
| BatchOperationResult, | ||
| BulkUpdateEntry, |
There was a problem hiding this comment.
Record the exported input type in the stability ledger
The fresh evidence after exporting the canonical input type is that BulkUpdateEntry now sits inside this @public export block, while the authoritative STABILITY.md stable row omits it and the new experimental row names only ctx.services.collections.updateMany. Because that ledger explicitly wins when annotations disagree, plugin authors and maintainers cannot determine whether this exported type is semver-protected or experimental. Add BulkUpdateEntry to the intended ledger row and align its release tag.
AGENTS.md reference: AGENTS.md:L30-L30
Useful? React with 👍 / 👎.
…update-many # Conflicts: # .changeset/a-component-is-judged-by-what-it-draws.md # .changeset/a-single-answers-as-one-row.md # .changeset/a-super-admins-key-copies-the-catalogue.md # .changeset/an-instance-shows-what-it-exposes.md
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 48f68e25d2
ℹ️ 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".
| return this.entryService.updateEntries( | ||
| { | ||
| collectionName, | ||
| ...forwardedFromContext(context), | ||
| }, |
There was a problem hiding this comment.
Distinguish committed hook failures from rejected updates
When an afterUpdate or field afterChange hook throws after the row has been written, updateSingleEntryInTransaction converts that exception into an unsuccessful result while retaining the revalidation intent; the bulk loop then commits the shared transaction but excludes the row from successful and ids. Returning that result unchanged through this new plugin method tells callers the update failed even though its patch is durable, so retrying errors[] can apply hook transformations or side effects twice. Preserve a committed-success marker/ID for this case or surface the hook failure separately as a warning.
Useful? React with 👍 / 👎.
| When the patch does NOT change what the filter matches, page with | ||
| `pagination: { page: n }` instead and stop when `pagination.hasMore` is false, or the | ||
| loop above would re-read the same rows. |
There was a problem hiding this comment.
Stabilize pagination before mutating each page
When the matching set spans multiple pages and the patch leaves the filter unchanged, incrementing an offset page while updating the same table can skip or repeat rows. listEntries adds no ORDER BY unless sort is explicitly supplied, and each update rewrites updated_at, so database row order is not stable across iterations. The recipe should require an immutable unique sort such as id, or collect the matching IDs before issuing updates.
Useful? React with 👍 / 👎.
Closes the ledger's
plugin-services-have-no-batch-update.The gap
ctx.services.collectionsended atcreateMany. A plugin could write many rowsin one call and then had no way to change them in one, elevated or not: the only
batch update available to plugin code was a loop of
updateEntrycalls, eachwith its own transaction, its own access pass and its own cache flush.
The engine path was already there.
updateEntriestakesoverrideAccesssince#1801; nothing exposed it.
What this adds
One
{ id, data }per row, so a single call can apply a different patch toeach row; applying one patch to many rows is the same call with the patch
repeated. Partial success, as
createManyhas: a failed row leaves the restcommitted.
Why this shape, and not a
wherefilterResearched before building (ledger node
research:batch-update-shape).where+ ONE shared patch{docs, errors}wherefield once matched the whole collection (#2412), fixed by refusing the filterupdateMany({where,data}){count}{keys|query, data}, one shared patch; per-item patches explicitly unsupported/batch/v1{id, data}BatchOperationResultbatchSizechunksTwo clusters: a filter plus one patch for "reclassify rows by a filter", a list
of per-item patches for "commit many independently known edits". No surveyed
product offers both on one verb.
Nextly's engine already picked the second, and this exposes it rather than
inventing the first, because:
by repeating the patch; the reverse is impossible, which is why Directus had to
rule the case out rather than support it partially.
computes per-row revalidation intents and outbox events, so a by-filter form
has to enumerate the matched rows internally anyway, paying the per-item cost
without giving the caller per-item visibility.
listEntriesplusupdateManyalready compose to the same capability with therows named. What is lost is convenience; what is avoided is the unbounded-filter
failure every product in that column has had to deal with.
createMany's list-in convention on the same facade, so there is oneconvention for plugin authors rather than two with different failure semantics.
The cost, stated
BatchOperationResultkeys failures by index, andcreateMany's own commentcalls that "the natural shape for creates, which have no caller-supplied ids to
key failures by". An update does have ids. Rather than invent a third result
shape, the docs say plainly that
errors[].indexindexes the array the callerpassed, so the failing row is
entries[index].id. Keeping one result type acrossboth bulk verbs is worth more than saving callers that lookup.
Locale
Refused by name, as
createManyrefuses it. The bulk pipeline takes no locale atall, and
forwardedFromContextspreads one into a params object whose type has nosuch key, where a spread suppresses excess-property checking. So an accepted locale
would be silently dropped and every row written to the default language under a
reported success.
A stale note corrected
The D56 integration suite's header said the in-memory
createTestNextlybootcannot reach the entry-service hook seam, so
createManycould not be covered endto end. It can. The new suite seeds with
createManyand patches the result withupdateManythrough a real boot, and the note is corrected rather than carried.Evidence
service-update-many.integration.test.ts, through a real plugin boot:{as:'system'}, and neither row takes the other's;{as:'user'}judged as the caller, and the row not written;createManythenupdateMany, the pairing a plugin actually writes.Control run against
origin/main: all five fail withservices.collections.updateMany is not a function.Gates run locally:
check-types,lint,check:comments,check:doc-samples,check:docs-claims,check:test-lanes, and theplugin-sdksurface suite.Surface
@experimentalon the plugin surface, with its own row inpackages/plugin-sdk/STABILITY.md, graduating per D55 once a first-party pluginexercises it.
PluginCollectionServiceitself is@public, so the member iscalled out rather than the type.
Two follow-ups this deliberately does not decide, raised in the research node:
no cap on
entries.lengthon any batch path (batchSizeonly chunks; WordPresscaps at 25, Sanity at 10,000), and the plugin wrapper opens a warnings collector
only for single writes, so a post-commit hook failure during a plugin batch write
at boot has no scope to report through.