-
Notifications
You must be signed in to change notification settings - Fork 266
perf(storage): add bulk plugin-storage upserts #1098
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
RhysSullivan
merged 14 commits into
UsefulSoftwareCo:main
from
aryasaatvik:contrib/plugin-storage-bulk-writes
Aug 28, 2026
Merged
Changes from all commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
3e990a8
feat(fumadb): add bulk upsert queries
aryasaatvik 22b67d1
perf(sdk): upsert plugin storage bulk writes
aryasaatvik 92f45b6
test(openapi): update plugin storage mock facade
aryasaatvik da20d2a
fix(fumadb): validate bulk upsert conflict shapes
aryasaatvik 1ca307d
fix(fumadb): batch bounded D1 upserts atomically
aryasaatvik aa9c25e
fix(sdk): transact bulk plugin storage writes
aryasaatvik 9576d94
Merge branch 'pr-1098' into fix-1098
RhysSullivan fcd13d1
Reconcile bulk plugin-storage upserts with the landed putMany API
RhysSullivan 0d71223
Merge remote-tracking branch 'origin/main' into fix-1098
RhysSullivan b5c57f9
Fix bulk upsert batching, predicate grouping, and memory conflict sem…
RhysSullivan 04500f7
Merge remote-tracking branch 'origin/main' into fix-1098
RhysSullivan 514b77d
Make split bulk writes atomic and fail fast on parameter budget overflow
RhysSullivan ca94936
Merge remote-tracking branch 'origin/main' into fix-1098
RhysSullivan 357e1d9
Quote excluded columns, serialize SQLite transaction scopes, and tran…
RhysSullivan File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| --- | ||
| "@executor-js/fumadb": patch | ||
| "@executor-js/sdk": patch | ||
| --- | ||
|
|
||
| Add a FumaDB bulk upsert query path and route plugin-storage bulk writes through | ||
| it so existing rows are updated without delete/reinsert churn. |
Large diffs are not rendered by default.
Oops, something went wrong.
120 changes: 120 additions & 0 deletions
120
packages/core/fumadb/src/adapters/drizzle/upsert-many-generic.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,120 @@ | ||
| import { expect, test } from "@effect/vitest"; | ||
|
|
||
| import { column, idColumn, schema, table } from "../../schema"; | ||
| import { fromDrizzle } from "./query"; | ||
|
|
||
| // The generic bulk-upsert path (providers without ON CONFLICT support, e.g. | ||
| // MySQL) issues several statements per row. The unit harness has no MySQL | ||
| // server, so a recording fake of the Drizzle handle asserts the transaction | ||
| // routing instead: every per-row statement must run on the driver | ||
| // transaction's handle, and a failing row must roll the earlier rows back. | ||
| const v1 = schema({ | ||
| version: "1.0.0", | ||
| tables: { | ||
| rows: table("generic_rows", { | ||
| id: idColumn("id", "varchar(255)"), | ||
| value: column("value", "string"), | ||
| }), | ||
| }, | ||
| }); | ||
|
|
||
| interface FakeDbOptions { | ||
| // A row whose `value` matches makes its INSERT fail. | ||
| readonly failOnValue?: string; | ||
| } | ||
|
|
||
| const createFakeMysqlDb = (options: FakeDbOptions = {}) => { | ||
| const committed: Record<string, unknown>[] = []; | ||
| const events: string[] = []; | ||
| const fakeTable = { id: { name: "id" }, value: { name: "value" } }; | ||
|
|
||
| const makeHandle = (label: string, sink: Record<string, unknown>[]) => ({ | ||
| _: { fullSchema: { rows: fakeTable } }, | ||
| select: () => { | ||
| const builder = { | ||
| from: () => builder, | ||
| limit: () => builder, | ||
| where: () => builder, | ||
| execute: async (): Promise<Record<string, unknown>[]> => [], | ||
| }; | ||
| return builder; | ||
| }, | ||
| insert: () => ({ | ||
| values: (rows: Record<string, unknown>[]) => ({ | ||
| $returningId: async () => { | ||
| events.push(`${label}:insert`); | ||
| for (const row of rows) { | ||
| if (options.failOnValue !== undefined && row["value"] === options.failOnValue) { | ||
| // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- test forces a row's statement to fail | ||
| throw new Error("statement failure"); | ||
| } | ||
| sink.push(row); | ||
| } | ||
| return rows.map((row) => ({ id: row["id"] })); | ||
| }, | ||
| }), | ||
| }), | ||
| transaction: async <T>(callback: (tx: unknown) => Promise<T>): Promise<T> => { | ||
| events.push("transaction:begin"); | ||
| const staged: Record<string, unknown>[] = []; | ||
| const tx = makeHandle("tx", staged); | ||
| // oxlint-disable-next-line executor/no-try-catch-or-throw -- fake driver mirrors commit/rollback | ||
| try { | ||
| const result = await callback(tx); | ||
| committed.push(...staged); | ||
| events.push("transaction:commit"); | ||
| return result; | ||
| } catch (error) { | ||
| events.push("transaction:rollback"); | ||
| throw error; | ||
| } | ||
| }, | ||
| }); | ||
|
|
||
| // Statements on the root handle auto-commit, exactly like a driver outside | ||
| // an explicit transaction. | ||
| return { db: makeHandle("root", committed), committed, events }; | ||
| }; | ||
|
|
||
| const upsertAll = async (db: unknown, values: readonly { id: string; value: string }[]) => { | ||
| const orm = fromDrizzle(v1, db, "mysql"); | ||
| const rows = v1.tables.rows; | ||
| expect(orm.internal.upsertMany).toBeDefined(); | ||
| await orm.internal.upsertMany?.(rows, { | ||
| target: [rows.columns.id], | ||
| update: [rows.columns.value], | ||
| values: values.map((value) => ({ ...value })), | ||
| }); | ||
| }; | ||
|
|
||
| test("generic bulk upsert runs every row inside one driver transaction", async () => { | ||
| const { db, committed, events } = createFakeMysqlDb(); | ||
|
|
||
| await upsertAll(db, [ | ||
| { id: "r1", value: "a" }, | ||
| { id: "r2", value: "b" }, | ||
| { id: "r3", value: "c" }, | ||
| ]); | ||
|
|
||
| expect(committed.map((row) => row["id"])).toEqual(["r1", "r2", "r3"]); | ||
| expect(events).toContain("transaction:begin"); | ||
| expect(events).toContain("transaction:commit"); | ||
| // No row statement may run on the auto-committing root handle. | ||
| expect(events).not.toContain("root:insert"); | ||
| }); | ||
|
|
||
| test("generic bulk upsert rolls earlier rows back when a later row fails", async () => { | ||
| const { db, committed, events } = createFakeMysqlDb({ failOnValue: "poison" }); | ||
|
|
||
| await expect( | ||
| upsertAll(db, [ | ||
| { id: "r1", value: "a" }, | ||
| { id: "r2", value: "b" }, | ||
| { id: "r3", value: "poison" }, | ||
| ]), | ||
| ).rejects.toThrow("statement failure"); | ||
|
|
||
| // A single logical bulk upsert must not leave a partial prefix committed. | ||
| expect(committed).toEqual([]); | ||
| expect(events).toContain("transaction:rollback"); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
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.
Uh oh!
There was an error while loading. Please reload this page.