Skip to content

feat(nextly): a plugin can update many entries in one call - #1819

Merged
mobeenabdullah merged 7 commits into
mainfrom
feat/plugin-services-update-many
Sep 12, 2026
Merged

mobeenabdullah merged 7 commits into
mainfrom
feat/plugin-services-update-many

Conversation

@mobeenabdullah

Copy link
Copy Markdown
Collaborator

Closes the ledger's plugin-services-have-no-batch-update.

The gap

ctx.services.collections ended at createMany. A plugin could write many rows
in 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 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 since
#1801; nothing exposed it.

What this adds

const res = await ctx.services.collections.updateMany(
  slug,
  [
    { id: firstId, data: { status: "published" } },
    { id: secondId, data: { status: "archived", pinned: false } },
  ],
  { as: "system" }
);
// { successful, failed, ids: string[], errors: [{ index, error }] }

One { id, data } per row, so a single call can apply a different patch to
each row; applying one patch to many rows is the same call with the patch
repeated. Partial success, as createMany has: a failed row leaves the rest
committed.

Why this shape, and not a where filter

Researched before building (ledger node research:batch-update-shape).

Product Batch update shape Returns Guard
Payload v3 where + ONE shared patch {docs, errors} none; a mistyped where field once matched the whole collection (#2412), fixed by refusing the filter
Strapi 5 none on the Document Service; only the unmanaged Query Engine updateMany({where,data}) {count} none, and no hooks, validation or sanitization
Directus {keys|query, data}, one shared patch; per-item patches explicitly unsupported items + counts CVE-2025-27089 was a per-row policy under-scope on update
Sanity GROQ query + one patch, fully atomic ids hard cap of 10,000 docs
WordPress /batch/v1 LIST of per-item requests per-item responses hard cap of 25
Nextly engine (already built) LIST of per-item {id, data} BatchOperationResult batchSize chunks

Two 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:

  • The per-item shape is strictly more expressive. It can express ids-plus-one-patch
    by repeating the patch; the reverse is impossible, which is why Directus had to
    rule the case out rather than support it partially.
  • A by-filter verb needs its own access, hook and revalidation pass. This engine
    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.
  • listEntries plus updateMany already compose to the same capability with the
    rows named. What is lost is convenience; what is avoided is the unbounded-filter
    failure every product in that column has had to deal with.
  • It matches createMany's list-in convention on the same facade, so there is one
    convention for plugin authors rather than two with different failure semantics.

The cost, stated

BatchOperationResult keys failures by index, and createMany's own comment
calls 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[].index indexes the array the caller
passed, so the failing row is entries[index].id. Keeping one result type across
both bulk verbs is worth more than saving callers that lookup.

Locale

Refused by name, as createMany refuses it. The bulk pipeline takes no locale at
all, and forwardedFromContext spreads one into a params object whose type has no
such 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 createTestNextly boot
cannot reach the entry-service hook seam, so createMany could not be covered end
to end. It can. The new suite seeds with createMany and patches the result with
updateMany through a real boot, and the note is corrected rather than carried.

Evidence

service-update-many.integration.test.ts, through a real plugin boot:

  • a different patch per row under {as:'system'}, and neither row takes the other's;
  • a failed row reported at the index of the entry passed, the rest committed;
  • a locale refused by name;
  • {as:'user'} judged as the caller, and the row not written;
  • createMany then updateMany, the pairing a plugin actually writes.

Control run against origin/main: all five fail with
services.collections.updateMany is not a function.

Gates run locally: check-types, lint, check:comments, check:doc-samples,
check:docs-claims, check:test-lanes, and the plugin-sdk surface suite.

Surface

@experimental on the plugin surface, with its own row in
packages/plugin-sdk/STABILITY.md, graduating per D55 once a first-party plugin
exercises it. PluginCollectionService itself is @public, so the member is
called out rather than the type.

Two follow-ups this deliberately does not decide, raised in the research node:
no cap on entries.length on any batch path (batchSize only chunks; WordPress
caps 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.

`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.
@coderabbitai

coderabbitai Bot commented Sep 12, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 46 minutes.

Check out review usage here.

View limit details

Limit 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.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Advanced

Run ID: befb4dc8-d345-4926-970a-8b89d43d88dc

📥 Commits

Reviewing files that changed from the base of the PR and between a0e1c03 and 48f68e2.

⛔ Files ignored due to path filters (4)
  • .changeset/a-plugin-can-update-many-entries-at-once.md is excluded by !.changeset/**
  • .changeset/a-single-publish-revalidates-its-draft.md is excluded by !.changeset/**
  • .changeset/the-checklist-offers-what-a-reader-can-do.md is excluded by !.changeset/**
  • packages/plugin-sdk/src/__snapshots__/plugin-surface.test.ts.snap is excluded by !**/*.snap
📒 Files selected for processing (9)
  • docs/plugins/services.mdx
  • packages/nextly/src/domains/collections/services/collection-service.ts
  • packages/nextly/src/index.ts
  • packages/nextly/src/plugins/__tests__/service-d56.integration.test.ts
  • packages/nextly/src/plugins/__tests__/service-update-many.integration.test.ts
  • packages/nextly/src/plugins/service-opts.ts
  • packages/plugin-sdk/STABILITY.md
  • packages/plugin-sdk/src/index.ts
  • scripts/doc-samples-baseline.json

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 review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 12, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-12T10:32:03.630429Z 48f68e2 New commits
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@github-actions

github-actions Bot commented Sep 12, 2026 •

Copy link
Copy Markdown
Contributor

Whole-Repository Code Hygiene Summary

Full dead-code, duplication, and complexity report for the PR branch as it stands now. Playground is excluded. Quality gate enforcement on introduced issues is performed by the Changed files job.

🌿 Fallow

Warning

Review needed

⚠️ 73 code issues · ⚠️ 681 clone groups · ⚠️ 1033 health findings

See inline review comments for per-finding details.

Code issues (73)
Category Count
Unused files 2
Unused exports 5
Unused dependencies 19
Unused devDependencies 6
Unresolved imports 2
Unlisted dependencies 1
Circular dependencies 38
Duplication (681 groups · 28663 lines · 4%)
Locations Lines Tokens
schemas/_dialect-bundles/mysql.relations.ts:40-134
schemas/_dialect-bundles/postgres.relations.ts:40-134
schemas/_dialect-bundles/sqlite.relations.ts:40-134
95 593
cli/commands/db-sync-demote.ts:70-75
cli/commands/db-sync-promote.ts:38-43
cli/commands/dev-build.ts:100-105
cli/commands/dev-build.ts:179-184
cli/commands/dev-build.ts:299-304
cli/commands/dev-build.ts:411-416
cli/commands/dev-build.ts:552-557
cli/commands/dev-server.ts:575-580
cli/commands/dev-server.ts:840-845
cli/commands/dev-server.ts:1143-1148
cli/commands/migrate-field-groups.ts:110-115
6 70
entries/EntryList/EntryTableSkeleton.tsx:74-98
collection/components/CollectionTableSkeleton.tsx:94-118
field-group/components/FieldGroupTableSkeleton.tsx:90-114
plugins/components/PluginsTableSkeleton.tsx:86-110
singles/components/SinglesTableSkeleton.tsx:77-101
src/components/table-skeleton.tsx:100-124
25 89
collections/config/validate-config.ts:380-433
field-groups/config/validate-field-group.ts:185-238
singles/config/validate-single.ts:190-243
54 152
dispatcher/handlers/collection-dispatcher.ts:925-967
field-groups/services/field-group-table-provisioning.ts:186-236
singles/services/reconcile-single-companion.ts:110-160
51 149

… and 676 more groups.

Across 423 files.

Complexity (1033 functions above threshold)
File Function Severity Cyclomatic Cognitive CRAP Lines
singles/services/single-mutation-service.ts:1009 <arrow> critical 245 ! 307 ! 13210.4 ! 1589
collections/services/collection-mutation-service.ts:6117 <arrow> critical 169 ! 160 ! 6338.2 ! 1261
src/init/reload-config.ts:1417 applyReload critical 137 ! 208 ! 4191.1 ! 1421
shared/lib/entry-validation.ts:223 validateFieldValue critical 109 ! 157 ! 2675.3 ! 432
blocks-engine/src/measure-bytes.ts:646 surveyDocument critical 102 ! 250 ! 137.1 ! 658

5053 files, 77968 functions analyzed (thresholds: cyclomatic > 20, cognitive > 15, CRAP >= 30)

Codebase health

Metric Value
Maintainability 91.7 / 100
Avg complexity 1.8

Tip

Run fallow fix --dry-run to preview auto-fixes.
Add /** @public */ above exports to preserve them.

@pkg-pr-new

pkg-pr-new Bot commented Sep 12, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

@nextlyhq/adapter-drizzle

npm i https://pkg.pr.new/@nextlyhq/adapter-drizzle@015f872

@nextlyhq/adapter-mysql

npm i https://pkg.pr.new/@nextlyhq/adapter-mysql@015f872

@nextlyhq/adapter-postgres

npm i https://pkg.pr.new/@nextlyhq/adapter-postgres@015f872

@nextlyhq/adapter-sqlite

npm i https://pkg.pr.new/@nextlyhq/adapter-sqlite@015f872

@nextlyhq/admin

npm i https://pkg.pr.new/@nextlyhq/admin@015f872

@nextlyhq/admin-css

npm i https://pkg.pr.new/@nextlyhq/admin-css@015f872

@nextlyhq/blocks-engine

npm i https://pkg.pr.new/@nextlyhq/blocks-engine@015f872

@nextlyhq/blocks-react

npm i https://pkg.pr.new/@nextlyhq/blocks-react@015f872

@nextlyhq/builder

npm i https://pkg.pr.new/@nextlyhq/builder@015f872

create-nextly-app

npm i https://pkg.pr.new/create-nextly-app@015f872

@nextlyhq/eslint-plugin

npm i https://pkg.pr.new/@nextlyhq/eslint-plugin@015f872

nextly

npm i https://pkg.pr.new/nextly@015f872

@nextlyhq/plugin-form-builder

npm i https://pkg.pr.new/@nextlyhq/plugin-form-builder@015f872

@nextlyhq/plugin-mcp

npm i https://pkg.pr.new/@nextlyhq/plugin-mcp@015f872

@nextlyhq/plugin-page-builder

npm i https://pkg.pr.new/@nextlyhq/plugin-page-builder@015f872

@nextlyhq/plugin-sdk

npm i https://pkg.pr.new/@nextlyhq/plugin-sdk@015f872

@nextlyhq/plugin-seo

npm i https://pkg.pr.new/@nextlyhq/plugin-seo@015f872

@nextlyhq/storage-s3

npm i https://pkg.pr.new/@nextlyhq/storage-s3@015f872

@nextlyhq/storage-uploadthing

npm i https://pkg.pr.new/@nextlyhq/storage-uploadthing@015f872

@nextlyhq/storage-vercel-blob

npm i https://pkg.pr.new/@nextlyhq/storage-vercel-blob@015f872

@nextlyhq/ui

npm i https://pkg.pr.new/@nextlyhq/ui@015f872

commit: 015f872

@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: 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".

Comment thread packages/nextly/src/plugins/__tests__/service-update-many.integration.test.ts Outdated
Comment thread docs/plugins/services.mdx Outdated
@github-actions github-actions Bot added scope: core nextly type: docs Documentation only scope: plugin @nextlyhq/plugin-* packages labels Sep 12, 2026
…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.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@mobeenabdullah

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@mobeenabdullah

Copy link
Copy Markdown
Collaborator Author

@codex review

@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: 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".

Comment thread docs/plugins/services.mdx
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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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
@mobeenabdullah
mobeenabdullah merged commit 39c96f3 into main Sep 12, 2026
8 checks passed

@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: 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".

Comment on lines +726 to +730
return this.entryService.updateEntries(
{
collectionName,
...forwardedFromContext(context),
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Comment thread docs/plugins/services.mdx
Comment on lines +202 to +204
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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

scope: core nextly scope: plugin @nextlyhq/plugin-* packages type: docs Documentation only

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant