Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .changeset/driver-memory-bulk-update-delete-atomicity.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
---
"@objectstack/driver-memory": patch
---

fix(driver-memory): make `bulkUpdate` and `bulkDelete` all-or-nothing, so a refused row leaves the table exactly as it found it (#13435)

`bulkUpdate` and `bulkDelete` were still `Promise.all(map(...))` over
`update`/`delete`, and both of those write into the table synchronously. So a
mid-batch refusal — a `UNIQUE_VIOLATION`/409 on `bulkUpdate`, a missing-id
throw under `strictMode` on `bulkDelete` — left every row processed *before*
it already mutated, and the caller got a rejection describing a batch that
had partly landed. #13340 fixed the identical shape on `bulkCreate`; this is
the third and fourth batch door.

`bulkUpdate` now builds and checks every pending row's post-image — each id
keeps its own patch, so this is new construction (a projected row set per
pending row, generalized from `updateMany`'s single-shared-patch posture)
rather than a copy of either sibling door — before writing any of them.
`bulkDelete` resolves every id to a table index first, refusing the whole
batch under `strictMode` before touching the table if one is missing, and
only then splices. Both follow `update`/`delete`'s own existing contract for
a missing id (skip under non-strict, refuse under strict) rather than a third
posture. `bulkDelete` still returns `void` — no current caller reads a
per-row outcome, so widening the return type stays out of scope.

All four batch doors of `InMemoryDriver` (`bulkCreate`, `updateMany`,
`bulkUpdate`, `bulkDelete`) now agree that a batch is atomic.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#13435] `bulkUpdate` and `bulkDelete` are ALL-OR-NOTHING: a refused row
* leaves the table exactly as it found it.
*
* ## The defect this pins, and why "it still throws" would not have caught it
*
* Both doors used to be `Promise.all(map(...))` over `update`/`delete`, and
* both of THOSE write into the table synchronously. So when one row of a
* batch was refused — `UNIQUE_VIOLATION`/409 on `bulkUpdate`, a missing-id
* throw under `strictMode` on `bulkDelete` — every row processed BEFORE it
* stayed mutated, and the caller got a rejection describing a batch that had
* partly landed. #13340 measured the identical shape on `bulkCreate`; these
* are the third and fourth batch doors it did not reach.
*
* ⛔ Asserting "the refusal still happens" proves NOTHING here — the refusal
* was already correct (#13197/#13239 pin `assertUnique`, `delete`'s own
* strict-mode throw predates this file). **The discriminating fact is that
* the TABLE DOES NOT MOVE**, so every test below reads the store back after
* the refusal — full rows, not just a count — rather than stopping at the
* envelope.
*
* ## The construction, and why it is NOT `updateMany`'s shape copied over
*
* `updateMany` stamps ONE shared `data` onto every matched row and has no
* per-row pre-image to exclude. `bulkCreate` has no pre-image at all (every
* row is new). `bulkUpdate` is neither: each id in the batch carries its OWN
* patch, so the fix needed new construction — per pending row, its own
* `exceptId` AND a projected row set holding the OTHER rows' post-images
* while dropping their pre-images — not a transcription of either sibling.
*
* ## What this does NOT claim
*
* Atomicity here is the driver refusing before it writes — not a
* transaction, and no rollback: nothing is written until the whole batch has
* been checked (`bulkUpdate`) or resolved to indices (`bulkDelete`).
*/

import { describe, it, expect, beforeEach } from 'vitest';
import { InMemoryDriver } from './memory-driver.js';

interface WireBearingError extends Error {
code?: string;
status?: number;
}

/** Run `fn`, requiring it to reject; hand back the rejection for inspection. */
async function refusalOf(fn: () => Promise<unknown>): Promise<WireBearingError> {
try {
await fn();
} catch (e) {
return e as WireBearingError;
}
throw new Error('expected the driver to refuse this write, but it resolved');
}

const DOC_SCHEMA = {
name: 'doc',
fields: {
id: { type: 'text' },
doc_no: { type: 'text', unique: 'global' },
title: { type: 'text' },
},
} as any;

/** The whole table, sorted by `id` — used to prove BYTE-IDENTITY, not just a count. */
async function snapshot(driver: InMemoryDriver, object: string) {
const rows = await driver.find(object, {});
return rows.slice().sort((a: any, b: any) => String(a.id).localeCompare(String(b.id)));
}

describe('[#13435] bulkUpdate refuses BEFORE writing — no surviving prefix', () => {
let driver: InMemoryDriver;

beforeEach(async () => {
driver = new InMemoryDriver();
await driver.syncSchema('doc', DOC_SCHEMA);
await driver.create('doc', { id: '1', doc_no: 'D-0001', title: 'One' });
await driver.create('doc', { id: '2', doc_no: 'D-0002', title: 'Two' });
await driver.create('doc', { id: '3', doc_no: 'D-0003', title: 'Three' });
});

it('a batch colliding WITHIN itself leaves the table byte-identical', async () => {
const before = await snapshot(driver, 'doc');

const err = await refusalOf(() =>
driver.bulkUpdate('doc', [
{ id: '1', data: { doc_no: 'D-0100' } },
{ id: '2', data: { doc_no: 'D-0100' } }, // collides with the row above, not with the table
]),
);
expect(err.code).toBe('UNIQUE_VIOLATION');
expect(err.status).toBe(409);

// Named explicitly: it is row '1' — accepted BEFORE the refusal under the
// old `Promise.all` shape — whose survival used to be the defect.
const after = await snapshot(driver, 'doc');
expect(after).toEqual(before);
expect((await driver.find('doc', { where: { id: '1' } }))[0].doc_no).toBe('D-0001');
});

it('a batch colliding with a STORED, UNTOUCHED row leaves the table byte-identical', async () => {
const before = await snapshot(driver, 'doc');
// Row '1' would be accepted (no collision on its own) before row '2'
// collides with row '3' — untouched by this batch, so it sits in
// `settled` — under the old `Promise.all` shape row '1' would have
// landed as a survivor before the second `update()` call threw.
const err = await refusalOf(() =>
driver.bulkUpdate('doc', [
{ id: '1', data: { doc_no: 'D-0100' } },
{ id: '2', data: { doc_no: 'D-0003' } }, // row '3's stored value — untouched by this batch
]),
);
expect(err.code).toBe('UNIQUE_VIOLATION');
expect(err.status).toBe(409);

const after = await snapshot(driver, 'doc');
expect(after).toEqual(before);
});

it('the FIRST row colliding refuses the batch too — the check is not order-dependent', async () => {
const before = await snapshot(driver, 'doc');
// Row '1' collides with STORED untouched row '3' on the very first
// iteration; row '2's patch is unrelated and would still land under a
// check that bailed out of the loop but had already pushed nothing.
const err = await refusalOf(() =>
driver.bulkUpdate('doc', [
{ id: '1', data: { doc_no: 'D-0003' } }, // row '3's stored value — collides immediately
{ id: '2', data: { doc_no: 'D-0300' } },
]),
);
expect(err.code).toBe('UNIQUE_VIOLATION');
const after = await snapshot(driver, 'doc');
expect(after).toEqual(before);
// The row AFTER the refusal must not land either — named directly, not
// just inferred from the full-snapshot equality above.
expect((await driver.find('doc', { where: { id: '2' } }))[0].doc_no).toBe('D-0002');
});

it('a clean batch still lands in FULL and returns every updated row', async () => {
// The non-vacuity control: a fix that refused everything would pass every
// assertion above.
const out = await driver.bulkUpdate('doc', [
{ id: '1', data: { doc_no: 'D-0100' } },
{ id: '2', data: { doc_no: 'D-0200' } },
]);
expect(out.map((r: any) => r.doc_no)).toEqual(['D-0100', 'D-0200']);
expect((await driver.find('doc', { where: { id: '1' } }))[0].doc_no).toBe('D-0100');
expect((await driver.find('doc', { where: { id: '2' } }))[0].doc_no).toBe('D-0200');
});

it('a row that keeps its OWN unique value (untouched field) does not collide with itself', async () => {
// exceptId must still exclude a row from its own pre-image when the patch
// does not touch the unique field.
const out = await driver.bulkUpdate('doc', [{ id: '1', data: { title: 'Renamed' } }]);
expect(out[0].doc_no).toBe('D-0001');
expect(out[0].title).toBe('Renamed');
});

it('an empty batch is a no-op that resolves to an empty array', async () => {
const before = await snapshot(driver, 'doc');
expect(await driver.bulkUpdate('doc', [])).toEqual([]);
expect(await snapshot(driver, 'doc')).toEqual(before);
});

describe('non-strict missing id (default `strictMode`)', () => {
it('a missing id is SKIPPED (no placeholder); the rest of the batch still lands', async () => {
// `IDataDriver.bulkUpdate` is declared `Promise<Record<string, unknown>[]>`
// — no `null` member — so a skipped id is OMITTED, not padded, mirroring
// `SqlDriver.bulkUpdate`'s own `if (updated) results.push(updated)`.
const out = await driver.bulkUpdate('doc', [
{ id: 'ghost', data: { doc_no: 'D-9999' } },
{ id: '1', data: { doc_no: 'D-0100' } },
]);
expect(out).toHaveLength(1);
expect(out[0].doc_no).toBe('D-0100');
expect((await driver.find('doc', { where: { id: '1' } }))[0].doc_no).toBe('D-0100');
});
});

describe('strictMode: true', () => {
it('a missing id refuses the WHOLE batch — table byte-identical, valid rows included', async () => {
const strict = new InMemoryDriver({ strictMode: true });
await strict.syncSchema('doc', DOC_SCHEMA);
await strict.create('doc', { id: '1', doc_no: 'D-0001', title: 'One' });
await strict.create('doc', { id: '2', doc_no: 'D-0002', title: 'Two' });
const before = await snapshot(strict, 'doc');

await expect(
strict.bulkUpdate('doc', [
{ id: '1', data: { doc_no: 'D-0100' } }, // would have been valid alone
{ id: 'ghost', data: { doc_no: 'D-9999' } },
]),
).rejects.toThrow();

expect(await snapshot(strict, 'doc')).toEqual(before);
});
});
});

describe('[#13435] bulkDelete refuses BEFORE writing — no surviving prefix', () => {
let driver: InMemoryDriver;

beforeEach(async () => {
driver = new InMemoryDriver({ strictMode: true });
await driver.syncSchema('doc', DOC_SCHEMA);
await driver.create('doc', { id: '1', doc_no: 'D-0001' });
await driver.create('doc', { id: '2', doc_no: 'D-0002' });
await driver.create('doc', { id: '3', doc_no: 'D-0003' });
});

it('strictMode: a missing id refuses the WHOLE batch — table byte-identical, valid ids included', async () => {
const before = await snapshot(driver, 'doc');

await expect(driver.bulkDelete('doc', ['1', 'ghost', '2'])).rejects.toThrow();

// Id '1' would have been removed BEFORE the refusal under the old
// `Promise.all` shape. Named explicitly, not just via count.
const after = await snapshot(driver, 'doc');
expect(after).toEqual(before);
expect(await driver.count('doc')).toBe(3);
});

it('strictMode: a clean batch still removes every named row', async () => {
await driver.bulkDelete('doc', ['1', '3']);
expect(await driver.count('doc')).toBe(1);
expect((await driver.find('doc', { where: {} }))[0].id).toBe('2');
});

describe('non-strict (default `strictMode`)', () => {
it('a missing id is SKIPPED; the rest of the batch still lands', async () => {
const loose = new InMemoryDriver();
await loose.syncSchema('doc', DOC_SCHEMA);
await loose.create('doc', { id: '1', doc_no: 'D-0001' });
await loose.create('doc', { id: '2', doc_no: 'D-0002' });

await loose.bulkDelete('doc', ['1', 'ghost']);
expect(await loose.count('doc')).toBe(1);
expect((await loose.find('doc', { where: {} }))[0].id).toBe('2');
});
});

it('an empty batch is a no-op', async () => {
const before = await snapshot(driver, 'doc');
await driver.bulkDelete('doc', []);
expect(await snapshot(driver, 'doc')).toEqual(before);
});

it('duplicate ids in one batch delete the row once, not twice', async () => {
await driver.bulkDelete('doc', ['1', '1']);
expect(await driver.count('doc')).toBe(2);
});
});

describe('[#13435] all FOUR batch doors of this driver now agree that a batch is atomic', () => {
it('bulkCreate, updateMany, bulkUpdate and bulkDelete all refuse without moving the table', async () => {
const driver = new InMemoryDriver({ strictMode: true });
await driver.syncSchema('doc', DOC_SCHEMA);
await driver.create('doc', { id: '1', doc_no: 'D-0001' });
await driver.create('doc', { id: '2', doc_no: 'D-0002' });
const before = await snapshot(driver, 'doc');

const createErr = await refusalOf(() =>
driver.bulkCreate('doc', [
{ id: 'a', doc_no: 'D-0100' },
{ id: 'b', doc_no: 'D-0100' },
]),
);
const updateManyErr = await refusalOf(() => driver.updateMany('doc', { where: {} }, { doc_no: 'D-0009' }));
const bulkUpdateErr = await refusalOf(() =>
driver.bulkUpdate('doc', [
{ id: '1', data: { doc_no: 'D-0100' } },
{ id: '2', data: { doc_no: 'D-0100' } }, // collides with the row above, not the table
]),
);
const bulkDeleteErr = await refusalOf(() => driver.bulkDelete('doc', ['1', 'ghost']));

expect(createErr.code).toBe('UNIQUE_VIOLATION');
expect(updateManyErr.code).toBe('UNIQUE_VIOLATION');
expect(bulkUpdateErr.code).toBe('UNIQUE_VIOLATION');
expect(bulkDeleteErr).toBeInstanceOf(Error);

// None of the four doors moved the store.
expect(await snapshot(driver, 'doc')).toEqual(before);
});
});

describe('[#13435] non-regression — updateMany and bulkCreate still behave as #13197/#13340 left them', () => {
let driver: InMemoryDriver;

beforeEach(async () => {
driver = new InMemoryDriver();
await driver.syncSchema('doc', DOC_SCHEMA);
await driver.create('doc', { id: '1', doc_no: 'D-0001' });
await driver.create('doc', { id: '2', doc_no: 'D-0002' });
});

it('updateMany still refuses a colliding shared patch and leaves the table untouched', async () => {
const before = await snapshot(driver, 'doc');
const err = await refusalOf(() => driver.updateMany('doc', { where: {} }, { doc_no: 'D-SAME' }));
expect(err.code).toBe('UNIQUE_VIOLATION');
expect(await snapshot(driver, 'doc')).toEqual(before);
});

it('updateMany still applies a clean shared patch to every matched row', async () => {
const count = await driver.updateMany('doc', { where: {} }, { title: 'Bulk' });
expect(count).toBe(2);
expect((await driver.find('doc', { where: { id: '1' } }))[0].title).toBe('Bulk');
expect((await driver.find('doc', { where: { id: '2' } }))[0].title).toBe('Bulk');
});

it('bulkCreate still refuses a self-colliding batch and leaves the table untouched', async () => {
const before = await snapshot(driver, 'doc');
const err = await refusalOf(() =>
driver.bulkCreate('doc', [
{ id: 'a', doc_no: 'D-0100' },
{ id: 'b', doc_no: 'D-0100' },
]),
);
expect(err.code).toBe('UNIQUE_VIOLATION');
expect(await snapshot(driver, 'doc')).toEqual(before);
});

it('bulkCreate still lands a clean batch in full', async () => {
const out = await driver.bulkCreate('doc', [{ id: 'a', doc_no: 'D-0100' }]);
expect(out).toHaveLength(1);
expect(await driver.count('doc')).toBe(3);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
fix(driver-memory): make bulkUpdate and bulkDelete all-or-nothing by zhuangjianguo · Pull Request #13875 · objectstack-ai/objectstack · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .changeset/driver-memory-bulk-update-delete-atomicity.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
---
"@objectstack/driver-memory": patch
---

fix(driver-memory): make `bulkUpdate` and `bulkDelete` all-or-nothing, so a refused row leaves the table exactly as it found it (#13435)

`bulkUpdate` and `bulkDelete` were still `Promise.all(map(...))` over
`update`/`delete`, and both of those write into the table synchronously. So a
mid-batch refusal — a `UNIQUE_VIOLATION`/409 on `bulkUpdate`, a missing-id
throw under `strictMode` on `bulkDelete` — left every row processed *before*
it already mutated, and the caller got a rejection describing a batch that
had partly landed. #13340 fixed the identical shape on `bulkCreate`; this is
the third and fourth batch door.

`bulkUpdate` now builds and checks every pending row's post-image — each id
keeps its own patch, so this is new construction (a projected row set per
pending row, generalized from `updateMany`'s single-shared-patch posture)
rather than a copy of either sibling door — before writing any of them.
`bulkDelete` resolves every id to a table index first, refusing the whole
batch under `strictMode` before touching the table if one is missing, and
only then splices. Both follow `update`/`delete`'s own existing contract for
a missing id (skip under non-strict, refuse under strict) rather than a third
posture. `bulkDelete` still returns `void` — no current caller reads a
per-row outcome, so widening the return type stays out of scope.

All four batch doors of `InMemoryDriver` (`bulkCreate`, `updateMany`,
`bulkUpdate`, `bulkDelete`) now agree that a batch is atomic.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#13435] `bulkUpdate` and `bulkDelete` are ALL-OR-NOTHING: a refused row
* leaves the table exactly as it found it.
*
* ## The defect this pins, and why "it still throws" would not have caught it
*
* Both doors used to be `Promise.all(map(...))` over `update`/`delete`, and
* both of THOSE write into the table synchronously. So when one row of a
* batch was refused — `UNIQUE_VIOLATION`/409 on `bulkUpdate`, a missing-id
* throw under `strictMode` on `bulkDelete` — every row processed BEFORE it
* stayed mutated, and the caller got a rejection describing a batch that had
* partly landed. #13340 measured the identical shape on `bulkCreate`; these
* are the third and fourth batch doors it did not reach.
*
* ⛔ Asserting "the refusal still happens" proves NOTHING here — the refusal
* was already correct (#13197/#13239 pin `assertUnique`, `delete`'s own
* strict-mode throw predates this file). **The discriminating fact is that
* the TABLE DOES NOT MOVE**, so every test below reads the store back after
* the refusal — full rows, not just a count — rather than stopping at the
* envelope.
*
* ## The construction, and why it is NOT `updateMany`'s shape copied over
*
* `updateMany` stamps ONE shared `data` onto every matched row and has no
* per-row pre-image to exclude. `bulkCreate` has no pre-image at all (every
* row is new). `bulkUpdate` is neither: each id in the batch carries its OWN
* patch, so the fix needed new construction — per pending row, its own
* `exceptId` AND a projected row set holding the OTHER rows' post-images
* while dropping their pre-images — not a transcription of either sibling.
*
* ## What this does NOT claim
*
* Atomicity here is the driver refusing before it writes — not a
* transaction, and no rollback: nothing is written until the whole batch has
* been checked (`bulkUpdate`) or resolved to indices (`bulkDelete`).
*/

import { describe, it, expect, beforeEach } from 'vitest';
import { InMemoryDriver } from './memory-driver.js';

interface WireBearingError extends Error {
code?: string;
status?: number;
}

/** Run `fn`, requiring it to reject; hand back the rejection for inspection. */
async function refusalOf(fn: () => Promise<unknown>): Promise<WireBearingError> {
try {
await fn();
} catch (e) {
return e as WireBearingError;
}
throw new Error('expected the driver to refuse this write, but it resolved');
}

const DOC_SCHEMA = {
name: 'doc',
fields: {
id: { type: 'text' },
doc_no: { type: 'text', unique: 'global' },
title: { type: 'text' },
},
} as any;

/** The whole table, sorted by `id` — used to prove BYTE-IDENTITY, not just a count. */
async function snapshot(driver: InMemoryDriver, object: string) {
const rows = await driver.find(object, {});
return rows.slice().sort((a: any, b: any) => String(a.id).localeCompare(String(b.id)));
}

describe('[#13435] bulkUpdate refuses BEFORE writing — no surviving prefix', () => {
let driver: InMemoryDriver;

beforeEach(async () => {
driver = new InMemoryDriver();
await driver.syncSchema('doc', DOC_SCHEMA);
await driver.create('doc', { id: '1', doc_no: 'D-0001', title: 'One' });
await driver.create('doc', { id: '2', doc_no: 'D-0002', title: 'Two' });
await driver.create('doc', { id: '3', doc_no: 'D-0003', title: 'Three' });
});

it('a batch colliding WITHIN itself leaves the table byte-identical', async () => {
const before = await snapshot(driver, 'doc');

const err = await refusalOf(() =>
driver.bulkUpdate('doc', [
{ id: '1', data: { doc_no: 'D-0100' } },
{ id: '2', data: { doc_no: 'D-0100' } }, // collides with the row above, not with the table
]),
);
expect(err.code).toBe('UNIQUE_VIOLATION');
expect(err.status).toBe(409);

// Named explicitly: it is row '1' — accepted BEFORE the refusal under the
// old `Promise.all` shape — whose survival used to be the defect.
const after = await snapshot(driver, 'doc');
expect(after).toEqual(before);
expect((await driver.find('doc', { where: { id: '1' } }))[0].doc_no).toBe('D-0001');
});

it('a batch colliding with a STORED, UNTOUCHED row leaves the table byte-identical', async () => {
const before = await snapshot(driver, 'doc');
// Row '1' would be accepted (no collision on its own) before row '2'
// collides with row '3' — untouched by this batch, so it sits in
// `settled` — under the old `Promise.all` shape row '1' would have
// landed as a survivor before the second `update()` call threw.
const err = await refusalOf(() =>
driver.bulkUpdate('doc', [
{ id: '1', data: { doc_no: 'D-0100' } },
{ id: '2', data: { doc_no: 'D-0003' } }, // row '3's stored value — untouched by this batch
]),
);
expect(err.code).toBe('UNIQUE_VIOLATION');
expect(err.status).toBe(409);

const after = await snapshot(driver, 'doc');
expect(after).toEqual(before);
});

it('the FIRST row colliding refuses the batch too — the check is not order-dependent', async () => {
const before = await snapshot(driver, 'doc');
// Row '1' collides with STORED untouched row '3' on the very first
// iteration; row '2's patch is unrelated and would still land under a
// check that bailed out of the loop but had already pushed nothing.
const err = await refusalOf(() =>
driver.bulkUpdate('doc', [
{ id: '1', data: { doc_no: 'D-0003' } }, // row '3's stored value — collides immediately
{ id: '2', data: { doc_no: 'D-0300' } },
]),
);
expect(err.code).toBe('UNIQUE_VIOLATION');
const after = await snapshot(driver, 'doc');
expect(after).toEqual(before);
// The row AFTER the refusal must not land either — named directly, not
// just inferred from the full-snapshot equality above.
expect((await driver.find('doc', { where: { id: '2' } }))[0].doc_no).toBe('D-0002');
});

it('a clean batch still lands in FULL and returns every updated row', async () => {
// The non-vacuity control: a fix that refused everything would pass every
// assertion above.
const out = await driver.bulkUpdate('doc', [
{ id: '1', data: { doc_no: 'D-0100' } },
{ id: '2', data: { doc_no: 'D-0200' } },
]);
expect(out.map((r: any) => r.doc_no)).toEqual(['D-0100', 'D-0200']);
expect((await driver.find('doc', { where: { id: '1' } }))[0].doc_no).toBe('D-0100');
expect((await driver.find('doc', { where: { id: '2' } }))[0].doc_no).toBe('D-0200');
});

it('a row that keeps its OWN unique value (untouched field) does not collide with itself', async () => {
// exceptId must still exclude a row from its own pre-image when the patch
// does not touch the unique field.
const out = await driver.bulkUpdate('doc', [{ id: '1', data: { title: 'Renamed' } }]);
expect(out[0].doc_no).toBe('D-0001');
expect(out[0].title).toBe('Renamed');
});

it('an empty batch is a no-op that resolves to an empty array', async () => {
const before = await snapshot(driver, 'doc');
expect(await driver.bulkUpdate('doc', [])).toEqual([]);
expect(await snapshot(driver, 'doc')).toEqual(before);
});

describe('non-strict missing id (default `strictMode`)', () => {
it('a missing id is SKIPPED (no placeholder); the rest of the batch still lands', async () => {
// `IDataDriver.bulkUpdate` is declared `Promise<Record<string, unknown>[]>`
// — no `null` member — so a skipped id is OMITTED, not padded, mirroring
// `SqlDriver.bulkUpdate`'s own `if (updated) results.push(updated)`.
const out = await driver.bulkUpdate('doc', [
{ id: 'ghost', data: { doc_no: 'D-9999' } },
{ id: '1', data: { doc_no: 'D-0100' } },
]);
expect(out).toHaveLength(1);
expect(out[0].doc_no).toBe('D-0100');
expect((await driver.find('doc', { where: { id: '1' } }))[0].doc_no).toBe('D-0100');
});
});

describe('strictMode: true', () => {
it('a missing id refuses the WHOLE batch — table byte-identical, valid rows included', async () => {
const strict = new InMemoryDriver({ strictMode: true });
await strict.syncSchema('doc', DOC_SCHEMA);
await strict.create('doc', { id: '1', doc_no: 'D-0001', title: 'One' });
await strict.create('doc', { id: '2', doc_no: 'D-0002', title: 'Two' });
const before = await snapshot(strict, 'doc');

await expect(
strict.bulkUpdate('doc', [
{ id: '1', data: { doc_no: 'D-0100' } }, // would have been valid alone
{ id: 'ghost', data: { doc_no: 'D-9999' } },
]),
).rejects.toThrow();

expect(await snapshot(strict, 'doc')).toEqual(before);
});
});
});

describe('[#13435] bulkDelete refuses BEFORE writing — no surviving prefix', () => {
let driver: InMemoryDriver;

beforeEach(async () => {
driver = new InMemoryDriver({ strictMode: true });
await driver.syncSchema('doc', DOC_SCHEMA);
await driver.create('doc', { id: '1', doc_no: 'D-0001' });
await driver.create('doc', { id: '2', doc_no: 'D-0002' });
await driver.create('doc', { id: '3', doc_no: 'D-0003' });
});

it('strictMode: a missing id refuses the WHOLE batch — table byte-identical, valid ids included', async () => {
const before = await snapshot(driver, 'doc');

await expect(driver.bulkDelete('doc', ['1', 'ghost', '2'])).rejects.toThrow();

// Id '1' would have been removed BEFORE the refusal under the old
// `Promise.all` shape. Named explicitly, not just via count.
const after = await snapshot(driver, 'doc');
expect(after).toEqual(before);
expect(await driver.count('doc')).toBe(3);
});

it('strictMode: a clean batch still removes every named row', async () => {
await driver.bulkDelete('doc', ['1', '3']);
expect(await driver.count('doc')).toBe(1);
expect((await driver.find('doc', { where: {} }))[0].id).toBe('2');
});

describe('non-strict (default `strictMode`)', () => {
it('a missing id is SKIPPED; the rest of the batch still lands', async () => {
const loose = new InMemoryDriver();
await loose.syncSchema('doc', DOC_SCHEMA);
await loose.create('doc', { id: '1', doc_no: 'D-0001' });
await loose.create('doc', { id: '2', doc_no: 'D-0002' });

await loose.bulkDelete('doc', ['1', 'ghost']);
expect(await loose.count('doc')).toBe(1);
expect((await loose.find('doc', { where: {} }))[0].id).toBe('2');
});
});

it('an empty batch is a no-op', async () => {
const before = await snapshot(driver, 'doc');
await driver.bulkDelete('doc', []);
expect(await snapshot(driver, 'doc')).toEqual(before);
});

it('duplicate ids in one batch delete the row once, not twice', async () => {
await driver.bulkDelete('doc', ['1', '1']);
expect(await driver.count('doc')).toBe(2);
});
});

describe('[#13435] all FOUR batch doors of this driver now agree that a batch is atomic', () => {
it('bulkCreate, updateMany, bulkUpdate and bulkDelete all refuse without moving the table', async () => {
const driver = new InMemoryDriver({ strictMode: true });
await driver.syncSchema('doc', DOC_SCHEMA);
await driver.create('doc', { id: '1', doc_no: 'D-0001' });
await driver.create('doc', { id: '2', doc_no: 'D-0002' });
const before = await snapshot(driver, 'doc');

const createErr = await refusalOf(() =>
driver.bulkCreate('doc', [
{ id: 'a', doc_no: 'D-0100' },
{ id: 'b', doc_no: 'D-0100' },
]),
);
const updateManyErr = await refusalOf(() => driver.updateMany('doc', { where: {} }, { doc_no: 'D-0009' }));
const bulkUpdateErr = await refusalOf(() =>
driver.bulkUpdate('doc', [
{ id: '1', data: { doc_no: 'D-0100' } },
{ id: '2', data: { doc_no: 'D-0100' } }, // collides with the row above, not the table
]),
);
const bulkDeleteErr = await refusalOf(() => driver.bulkDelete('doc', ['1', 'ghost']));

expect(createErr.code).toBe('UNIQUE_VIOLATION');
expect(updateManyErr.code).toBe('UNIQUE_VIOLATION');
expect(bulkUpdateErr.code).toBe('UNIQUE_VIOLATION');
expect(bulkDeleteErr).toBeInstanceOf(Error);

// None of the four doors moved the store.
expect(await snapshot(driver, 'doc')).toEqual(before);
});
});

describe('[#13435] non-regression — updateMany and bulkCreate still behave as #13197/#13340 left them', () => {
let driver: InMemoryDriver;

beforeEach(async () => {
driver = new InMemoryDriver();
await driver.syncSchema('doc', DOC_SCHEMA);
await driver.create('doc', { id: '1', doc_no: 'D-0001' });
await driver.create('doc', { id: '2', doc_no: 'D-0002' });
});

it('updateMany still refuses a colliding shared patch and leaves the table untouched', async () => {
const before = await snapshot(driver, 'doc');
const err = await refusalOf(() => driver.updateMany('doc', { where: {} }, { doc_no: 'D-SAME' }));
expect(err.code).toBe('UNIQUE_VIOLATION');
expect(await snapshot(driver, 'doc')).toEqual(before);
});

it('updateMany still applies a clean shared patch to every matched row', async () => {
const count = await driver.updateMany('doc', { where: {} }, { title: 'Bulk' });
expect(count).toBe(2);
expect((await driver.find('doc', { where: { id: '1' } }))[0].title).toBe('Bulk');
expect((await driver.find('doc', { where: { id: '2' } }))[0].title).toBe('Bulk');
});

it('bulkCreate still refuses a self-colliding batch and leaves the table untouched', async () => {
const before = await snapshot(driver, 'doc');
const err = await refusalOf(() =>
driver.bulkCreate('doc', [
{ id: 'a', doc_no: 'D-0100' },
{ id: 'b', doc_no: 'D-0100' },
]),
);
expect(err.code).toBe('UNIQUE_VIOLATION');
expect(await snapshot(driver, 'doc')).toEqual(before);
});

it('bulkCreate still lands a clean batch in full', async () => {
const out = await driver.bulkCreate('doc', [{ id: 'a', doc_no: 'D-0100' }]);
expect(out).toHaveLength(1);
expect(await driver.count('doc')).toBe(3);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(driver-memory): make bulkUpdate and bulkDelete all-or-nothing by zhuangjianguo · Pull Request #13875 · objectstack-ai/objectstack · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .changeset/driver-memory-bulk-update-delete-atomicity.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
---
"@objectstack/driver-memory": patch
---

fix(driver-memory): make `bulkUpdate` and `bulkDelete` all-or-nothing, so a refused row leaves the table exactly as it found it (#13435)

`bulkUpdate` and `bulkDelete` were still `Promise.all(map(...))` over
`update`/`delete`, and both of those write into the table synchronously. So a
mid-batch refusal — a `UNIQUE_VIOLATION`/409 on `bulkUpdate`, a missing-id
throw under `strictMode` on `bulkDelete` — left every row processed *before*
it already mutated, and the caller got a rejection describing a batch that
had partly landed. #13340 fixed the identical shape on `bulkCreate`; this is
the third and fourth batch door.

`bulkUpdate` now builds and checks every pending row's post-image — each id
keeps its own patch, so this is new construction (a projected row set per
pending row, generalized from `updateMany`'s single-shared-patch posture)
rather than a copy of either sibling door — before writing any of them.
`bulkDelete` resolves every id to a table index first, refusing the whole
batch under `strictMode` before touching the table if one is missing, and
only then splices. Both follow `update`/`delete`'s own existing contract for
a missing id (skip under non-strict, refuse under strict) rather than a third
posture. `bulkDelete` still returns `void` — no current caller reads a
per-row outcome, so widening the return type stays out of scope.

All four batch doors of `InMemoryDriver` (`bulkCreate`, `updateMany`,
`bulkUpdate`, `bulkDelete`) now agree that a batch is atomic.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#13435] `bulkUpdate` and `bulkDelete` are ALL-OR-NOTHING: a refused row
* leaves the table exactly as it found it.
*
* ## The defect this pins, and why "it still throws" would not have caught it
*
* Both doors used to be `Promise.all(map(...))` over `update`/`delete`, and
* both of THOSE write into the table synchronously. So when one row of a
* batch was refused — `UNIQUE_VIOLATION`/409 on `bulkUpdate`, a missing-id
* throw under `strictMode` on `bulkDelete` — every row processed BEFORE it
* stayed mutated, and the caller got a rejection describing a batch that had
* partly landed. #13340 measured the identical shape on `bulkCreate`; these
* are the third and fourth batch doors it did not reach.
*
* ⛔ Asserting "the refusal still happens" proves NOTHING here — the refusal
* was already correct (#13197/#13239 pin `assertUnique`, `delete`'s own
* strict-mode throw predates this file). **The discriminating fact is that
* the TABLE DOES NOT MOVE**, so every test below reads the store back after
* the refusal — full rows, not just a count — rather than stopping at the
* envelope.
*
* ## The construction, and why it is NOT `updateMany`'s shape copied over
*
* `updateMany` stamps ONE shared `data` onto every matched row and has no
* per-row pre-image to exclude. `bulkCreate` has no pre-image at all (every
* row is new). `bulkUpdate` is neither: each id in the batch carries its OWN
* patch, so the fix needed new construction — per pending row, its own
* `exceptId` AND a projected row set holding the OTHER rows' post-images
* while dropping their pre-images — not a transcription of either sibling.
*
* ## What this does NOT claim
*
* Atomicity here is the driver refusing before it writes — not a
* transaction, and no rollback: nothing is written until the whole batch has
* been checked (`bulkUpdate`) or resolved to indices (`bulkDelete`).
*/

import { describe, it, expect, beforeEach } from 'vitest';
import { InMemoryDriver } from './memory-driver.js';

interface WireBearingError extends Error {
code?: string;
status?: number;
}

/** Run `fn`, requiring it to reject; hand back the rejection for inspection. */
async function refusalOf(fn: () => Promise<unknown>): Promise<WireBearingError> {
try {
await fn();
} catch (e) {
return e as WireBearingError;
}
throw new Error('expected the driver to refuse this write, but it resolved');
}

const DOC_SCHEMA = {
name: 'doc',
fields: {
id: { type: 'text' },
doc_no: { type: 'text', unique: 'global' },
title: { type: 'text' },
},
} as any;

/** The whole table, sorted by `id` — used to prove BYTE-IDENTITY, not just a count. */
async function snapshot(driver: InMemoryDriver, object: string) {
const rows = await driver.find(object, {});
return rows.slice().sort((a: any, b: any) => String(a.id).localeCompare(String(b.id)));
}

describe('[#13435] bulkUpdate refuses BEFORE writing — no surviving prefix', () => {
let driver: InMemoryDriver;

beforeEach(async () => {
driver = new InMemoryDriver();
await driver.syncSchema('doc', DOC_SCHEMA);
await driver.create('doc', { id: '1', doc_no: 'D-0001', title: 'One' });
await driver.create('doc', { id: '2', doc_no: 'D-0002', title: 'Two' });
await driver.create('doc', { id: '3', doc_no: 'D-0003', title: 'Three' });
});

it('a batch colliding WITHIN itself leaves the table byte-identical', async () => {
const before = await snapshot(driver, 'doc');

const err = await refusalOf(() =>
driver.bulkUpdate('doc', [
{ id: '1', data: { doc_no: 'D-0100' } },
{ id: '2', data: { doc_no: 'D-0100' } }, // collides with the row above, not with the table
]),
);
expect(err.code).toBe('UNIQUE_VIOLATION');
expect(err.status).toBe(409);

// Named explicitly: it is row '1' — accepted BEFORE the refusal under the
// old `Promise.all` shape — whose survival used to be the defect.
const after = await snapshot(driver, 'doc');
expect(after).toEqual(before);
expect((await driver.find('doc', { where: { id: '1' } }))[0].doc_no).toBe('D-0001');
});

it('a batch colliding with a STORED, UNTOUCHED row leaves the table byte-identical', async () => {
const before = await snapshot(driver, 'doc');
// Row '1' would be accepted (no collision on its own) before row '2'
// collides with row '3' — untouched by this batch, so it sits in
// `settled` — under the old `Promise.all` shape row '1' would have
// landed as a survivor before the second `update()` call threw.
const err = await refusalOf(() =>
driver.bulkUpdate('doc', [
{ id: '1', data: { doc_no: 'D-0100' } },
{ id: '2', data: { doc_no: 'D-0003' } }, // row '3's stored value — untouched by this batch
]),
);
expect(err.code).toBe('UNIQUE_VIOLATION');
expect(err.status).toBe(409);

const after = await snapshot(driver, 'doc');
expect(after).toEqual(before);
});

it('the FIRST row colliding refuses the batch too — the check is not order-dependent', async () => {
const before = await snapshot(driver, 'doc');
// Row '1' collides with STORED untouched row '3' on the very first
// iteration; row '2's patch is unrelated and would still land under a
// check that bailed out of the loop but had already pushed nothing.
const err = await refusalOf(() =>
driver.bulkUpdate('doc', [
{ id: '1', data: { doc_no: 'D-0003' } }, // row '3's stored value — collides immediately
{ id: '2', data: { doc_no: 'D-0300' } },
]),
);
expect(err.code).toBe('UNIQUE_VIOLATION');
const after = await snapshot(driver, 'doc');
expect(after).toEqual(before);
// The row AFTER the refusal must not land either — named directly, not
// just inferred from the full-snapshot equality above.
expect((await driver.find('doc', { where: { id: '2' } }))[0].doc_no).toBe('D-0002');
});

it('a clean batch still lands in FULL and returns every updated row', async () => {
// The non-vacuity control: a fix that refused everything would pass every
// assertion above.
const out = await driver.bulkUpdate('doc', [
{ id: '1', data: { doc_no: 'D-0100' } },
{ id: '2', data: { doc_no: 'D-0200' } },
]);
expect(out.map((r: any) => r.doc_no)).toEqual(['D-0100', 'D-0200']);
expect((await driver.find('doc', { where: { id: '1' } }))[0].doc_no).toBe('D-0100');
expect((await driver.find('doc', { where: { id: '2' } }))[0].doc_no).toBe('D-0200');
});

it('a row that keeps its OWN unique value (untouched field) does not collide with itself', async () => {
// exceptId must still exclude a row from its own pre-image when the patch
// does not touch the unique field.
const out = await driver.bulkUpdate('doc', [{ id: '1', data: { title: 'Renamed' } }]);
expect(out[0].doc_no).toBe('D-0001');
expect(out[0].title).toBe('Renamed');
});

it('an empty batch is a no-op that resolves to an empty array', async () => {
const before = await snapshot(driver, 'doc');
expect(await driver.bulkUpdate('doc', [])).toEqual([]);
expect(await snapshot(driver, 'doc')).toEqual(before);
});

describe('non-strict missing id (default `strictMode`)', () => {
it('a missing id is SKIPPED (no placeholder); the rest of the batch still lands', async () => {
// `IDataDriver.bulkUpdate` is declared `Promise<Record<string, unknown>[]>`
// — no `null` member — so a skipped id is OMITTED, not padded, mirroring
// `SqlDriver.bulkUpdate`'s own `if (updated) results.push(updated)`.
const out = await driver.bulkUpdate('doc', [
{ id: 'ghost', data: { doc_no: 'D-9999' } },
{ id: '1', data: { doc_no: 'D-0100' } },
]);
expect(out).toHaveLength(1);
expect(out[0].doc_no).toBe('D-0100');
expect((await driver.find('doc', { where: { id: '1' } }))[0].doc_no).toBe('D-0100');
});
});

describe('strictMode: true', () => {
it('a missing id refuses the WHOLE batch — table byte-identical, valid rows included', async () => {
const strict = new InMemoryDriver({ strictMode: true });
await strict.syncSchema('doc', DOC_SCHEMA);
await strict.create('doc', { id: '1', doc_no: 'D-0001', title: 'One' });
await strict.create('doc', { id: '2', doc_no: 'D-0002', title: 'Two' });
const before = await snapshot(strict, 'doc');

await expect(
strict.bulkUpdate('doc', [
{ id: '1', data: { doc_no: 'D-0100' } }, // would have been valid alone
{ id: 'ghost', data: { doc_no: 'D-9999' } },
]),
).rejects.toThrow();

expect(await snapshot(strict, 'doc')).toEqual(before);
});
});
});

describe('[#13435] bulkDelete refuses BEFORE writing — no surviving prefix', () => {
let driver: InMemoryDriver;

beforeEach(async () => {
driver = new InMemoryDriver({ strictMode: true });
await driver.syncSchema('doc', DOC_SCHEMA);
await driver.create('doc', { id: '1', doc_no: 'D-0001' });
await driver.create('doc', { id: '2', doc_no: 'D-0002' });
await driver.create('doc', { id: '3', doc_no: 'D-0003' });
});

it('strictMode: a missing id refuses the WHOLE batch — table byte-identical, valid ids included', async () => {
const before = await snapshot(driver, 'doc');

await expect(driver.bulkDelete('doc', ['1', 'ghost', '2'])).rejects.toThrow();

// Id '1' would have been removed BEFORE the refusal under the old
// `Promise.all` shape. Named explicitly, not just via count.
const after = await snapshot(driver, 'doc');
expect(after).toEqual(before);
expect(await driver.count('doc')).toBe(3);
});

it('strictMode: a clean batch still removes every named row', async () => {
await driver.bulkDelete('doc', ['1', '3']);
expect(await driver.count('doc')).toBe(1);
expect((await driver.find('doc', { where: {} }))[0].id).toBe('2');
});

describe('non-strict (default `strictMode`)', () => {
it('a missing id is SKIPPED; the rest of the batch still lands', async () => {
const loose = new InMemoryDriver();
await loose.syncSchema('doc', DOC_SCHEMA);
await loose.create('doc', { id: '1', doc_no: 'D-0001' });
await loose.create('doc', { id: '2', doc_no: 'D-0002' });

await loose.bulkDelete('doc', ['1', 'ghost']);
expect(await loose.count('doc')).toBe(1);
expect((await loose.find('doc', { where: {} }))[0].id).toBe('2');
});
});

it('an empty batch is a no-op', async () => {
const before = await snapshot(driver, 'doc');
await driver.bulkDelete('doc', []);
expect(await snapshot(driver, 'doc')).toEqual(before);
});

it('duplicate ids in one batch delete the row once, not twice', async () => {
await driver.bulkDelete('doc', ['1', '1']);
expect(await driver.count('doc')).toBe(2);
});
});

describe('[#13435] all FOUR batch doors of this driver now agree that a batch is atomic', () => {
it('bulkCreate, updateMany, bulkUpdate and bulkDelete all refuse without moving the table', async () => {
const driver = new InMemoryDriver({ strictMode: true });
await driver.syncSchema('doc', DOC_SCHEMA);
await driver.create('doc', { id: '1', doc_no: 'D-0001' });
await driver.create('doc', { id: '2', doc_no: 'D-0002' });
const before = await snapshot(driver, 'doc');

const createErr = await refusalOf(() =>
driver.bulkCreate('doc', [
{ id: 'a', doc_no: 'D-0100' },
{ id: 'b', doc_no: 'D-0100' },
]),
);
const updateManyErr = await refusalOf(() => driver.updateMany('doc', { where: {} }, { doc_no: 'D-0009' }));
const bulkUpdateErr = await refusalOf(() =>
driver.bulkUpdate('doc', [
{ id: '1', data: { doc_no: 'D-0100' } },
{ id: '2', data: { doc_no: 'D-0100' } }, // collides with the row above, not the table
]),
);
const bulkDeleteErr = await refusalOf(() => driver.bulkDelete('doc', ['1', 'ghost']));

expect(createErr.code).toBe('UNIQUE_VIOLATION');
expect(updateManyErr.code).toBe('UNIQUE_VIOLATION');
expect(bulkUpdateErr.code).toBe('UNIQUE_VIOLATION');
expect(bulkDeleteErr).toBeInstanceOf(Error);

// None of the four doors moved the store.
expect(await snapshot(driver, 'doc')).toEqual(before);
});
});

describe('[#13435] non-regression — updateMany and bulkCreate still behave as #13197/#13340 left them', () => {
let driver: InMemoryDriver;

beforeEach(async () => {
driver = new InMemoryDriver();
await driver.syncSchema('doc', DOC_SCHEMA);
await driver.create('doc', { id: '1', doc_no: 'D-0001' });
await driver.create('doc', { id: '2', doc_no: 'D-0002' });
});

it('updateMany still refuses a colliding shared patch and leaves the table untouched', async () => {
const before = await snapshot(driver, 'doc');
const err = await refusalOf(() => driver.updateMany('doc', { where: {} }, { doc_no: 'D-SAME' }));
expect(err.code).toBe('UNIQUE_VIOLATION');
expect(await snapshot(driver, 'doc')).toEqual(before);
});

it('updateMany still applies a clean shared patch to every matched row', async () => {
const count = await driver.updateMany('doc', { where: {} }, { title: 'Bulk' });
expect(count).toBe(2);
expect((await driver.find('doc', { where: { id: '1' } }))[0].title).toBe('Bulk');
expect((await driver.find('doc', { where: { id: '2' } }))[0].title).toBe('Bulk');
});

it('bulkCreate still refuses a self-colliding batch and leaves the table untouched', async () => {
const before = await snapshot(driver, 'doc');
const err = await refusalOf(() =>
driver.bulkCreate('doc', [
{ id: 'a', doc_no: 'D-0100' },
{ id: 'b', doc_no: 'D-0100' },
]),
);
expect(err.code).toBe('UNIQUE_VIOLATION');
expect(await snapshot(driver, 'doc')).toEqual(before);
});

it('bulkCreate still lands a clean batch in full', async () => {
const out = await driver.bulkCreate('doc', [{ id: 'a', doc_no: 'D-0100' }]);
expect(out).toHaveLength(1);
expect(await driver.count('doc')).toBe(3);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(driver-memory): make bulkUpdate and bulkDelete all-or-nothing by zhuangjianguo · Pull Request #13875 · objectstack-ai/objectstack · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .changeset/driver-memory-bulk-update-delete-atomicity.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
---
"@objectstack/driver-memory": patch
---

fix(driver-memory): make `bulkUpdate` and `bulkDelete` all-or-nothing, so a refused row leaves the table exactly as it found it (#13435)

`bulkUpdate` and `bulkDelete` were still `Promise.all(map(...))` over
`update`/`delete`, and both of those write into the table synchronously. So a
mid-batch refusal — a `UNIQUE_VIOLATION`/409 on `bulkUpdate`, a missing-id
throw under `strictMode` on `bulkDelete` — left every row processed *before*
it already mutated, and the caller got a rejection describing a batch that
had partly landed. #13340 fixed the identical shape on `bulkCreate`; this is
the third and fourth batch door.

`bulkUpdate` now builds and checks every pending row's post-image — each id
keeps its own patch, so this is new construction (a projected row set per
pending row, generalized from `updateMany`'s single-shared-patch posture)
rather than a copy of either sibling door — before writing any of them.
`bulkDelete` resolves every id to a table index first, refusing the whole
batch under `strictMode` before touching the table if one is missing, and
only then splices. Both follow `update`/`delete`'s own existing contract for
a missing id (skip under non-strict, refuse under strict) rather than a third
posture. `bulkDelete` still returns `void` — no current caller reads a
per-row outcome, so widening the return type stays out of scope.

All four batch doors of `InMemoryDriver` (`bulkCreate`, `updateMany`,
`bulkUpdate`, `bulkDelete`) now agree that a batch is atomic.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#13435] `bulkUpdate` and `bulkDelete` are ALL-OR-NOTHING: a refused row
* leaves the table exactly as it found it.
*
* ## The defect this pins, and why "it still throws" would not have caught it
*
* Both doors used to be `Promise.all(map(...))` over `update`/`delete`, and
* both of THOSE write into the table synchronously. So when one row of a
* batch was refused — `UNIQUE_VIOLATION`/409 on `bulkUpdate`, a missing-id
* throw under `strictMode` on `bulkDelete` — every row processed BEFORE it
* stayed mutated, and the caller got a rejection describing a batch that had
* partly landed. #13340 measured the identical shape on `bulkCreate`; these
* are the third and fourth batch doors it did not reach.
*
* ⛔ Asserting "the refusal still happens" proves NOTHING here — the refusal
* was already correct (#13197/#13239 pin `assertUnique`, `delete`'s own
* strict-mode throw predates this file). **The discriminating fact is that
* the TABLE DOES NOT MOVE**, so every test below reads the store back after
* the refusal — full rows, not just a count — rather than stopping at the
* envelope.
*
* ## The construction, and why it is NOT `updateMany`'s shape copied over
*
* `updateMany` stamps ONE shared `data` onto every matched row and has no
* per-row pre-image to exclude. `bulkCreate` has no pre-image at all (every
* row is new). `bulkUpdate` is neither: each id in the batch carries its OWN
* patch, so the fix needed new construction — per pending row, its own
* `exceptId` AND a projected row set holding the OTHER rows' post-images
* while dropping their pre-images — not a transcription of either sibling.
*
* ## What this does NOT claim
*
* Atomicity here is the driver refusing before it writes — not a
* transaction, and no rollback: nothing is written until the whole batch has
* been checked (`bulkUpdate`) or resolved to indices (`bulkDelete`).
*/

import { describe, it, expect, beforeEach } from 'vitest';
import { InMemoryDriver } from './memory-driver.js';

interface WireBearingError extends Error {
code?: string;
status?: number;
}

/** Run `fn`, requiring it to reject; hand back the rejection for inspection. */
async function refusalOf(fn: () => Promise<unknown>): Promise<WireBearingError> {
try {
await fn();
} catch (e) {
return e as WireBearingError;
}
throw new Error('expected the driver to refuse this write, but it resolved');
}

const DOC_SCHEMA = {
name: 'doc',
fields: {
id: { type: 'text' },
doc_no: { type: 'text', unique: 'global' },
title: { type: 'text' },
},
} as any;

/** The whole table, sorted by `id` — used to prove BYTE-IDENTITY, not just a count. */
async function snapshot(driver: InMemoryDriver, object: string) {
const rows = await driver.find(object, {});
return rows.slice().sort((a: any, b: any) => String(a.id).localeCompare(String(b.id)));
}

describe('[#13435] bulkUpdate refuses BEFORE writing — no surviving prefix', () => {
let driver: InMemoryDriver;

beforeEach(async () => {
driver = new InMemoryDriver();
await driver.syncSchema('doc', DOC_SCHEMA);
await driver.create('doc', { id: '1', doc_no: 'D-0001', title: 'One' });
await driver.create('doc', { id: '2', doc_no: 'D-0002', title: 'Two' });
await driver.create('doc', { id: '3', doc_no: 'D-0003', title: 'Three' });
});

it('a batch colliding WITHIN itself leaves the table byte-identical', async () => {
const before = await snapshot(driver, 'doc');

const err = await refusalOf(() =>
driver.bulkUpdate('doc', [
{ id: '1', data: { doc_no: 'D-0100' } },
{ id: '2', data: { doc_no: 'D-0100' } }, // collides with the row above, not with the table
]),
);
expect(err.code).toBe('UNIQUE_VIOLATION');
expect(err.status).toBe(409);

// Named explicitly: it is row '1' — accepted BEFORE the refusal under the
// old `Promise.all` shape — whose survival used to be the defect.
const after = await snapshot(driver, 'doc');
expect(after).toEqual(before);
expect((await driver.find('doc', { where: { id: '1' } }))[0].doc_no).toBe('D-0001');
});

it('a batch colliding with a STORED, UNTOUCHED row leaves the table byte-identical', async () => {
const before = await snapshot(driver, 'doc');
// Row '1' would be accepted (no collision on its own) before row '2'
// collides with row '3' — untouched by this batch, so it sits in
// `settled` — under the old `Promise.all` shape row '1' would have
// landed as a survivor before the second `update()` call threw.
const err = await refusalOf(() =>
driver.bulkUpdate('doc', [
{ id: '1', data: { doc_no: 'D-0100' } },
{ id: '2', data: { doc_no: 'D-0003' } }, // row '3's stored value — untouched by this batch
]),
);
expect(err.code).toBe('UNIQUE_VIOLATION');
expect(err.status).toBe(409);

const after = await snapshot(driver, 'doc');
expect(after).toEqual(before);
});

it('the FIRST row colliding refuses the batch too — the check is not order-dependent', async () => {
const before = await snapshot(driver, 'doc');
// Row '1' collides with STORED untouched row '3' on the very first
// iteration; row '2's patch is unrelated and would still land under a
// check that bailed out of the loop but had already pushed nothing.
const err = await refusalOf(() =>
driver.bulkUpdate('doc', [
{ id: '1', data: { doc_no: 'D-0003' } }, // row '3's stored value — collides immediately
{ id: '2', data: { doc_no: 'D-0300' } },
]),
);
expect(err.code).toBe('UNIQUE_VIOLATION');
const after = await snapshot(driver, 'doc');
expect(after).toEqual(before);
// The row AFTER the refusal must not land either — named directly, not
// just inferred from the full-snapshot equality above.
expect((await driver.find('doc', { where: { id: '2' } }))[0].doc_no).toBe('D-0002');
});

it('a clean batch still lands in FULL and returns every updated row', async () => {
// The non-vacuity control: a fix that refused everything would pass every
// assertion above.
const out = await driver.bulkUpdate('doc', [
{ id: '1', data: { doc_no: 'D-0100' } },
{ id: '2', data: { doc_no: 'D-0200' } },
]);
expect(out.map((r: any) => r.doc_no)).toEqual(['D-0100', 'D-0200']);
expect((await driver.find('doc', { where: { id: '1' } }))[0].doc_no).toBe('D-0100');
expect((await driver.find('doc', { where: { id: '2' } }))[0].doc_no).toBe('D-0200');
});

it('a row that keeps its OWN unique value (untouched field) does not collide with itself', async () => {
// exceptId must still exclude a row from its own pre-image when the patch
// does not touch the unique field.
const out = await driver.bulkUpdate('doc', [{ id: '1', data: { title: 'Renamed' } }]);
expect(out[0].doc_no).toBe('D-0001');
expect(out[0].title).toBe('Renamed');
});

it('an empty batch is a no-op that resolves to an empty array', async () => {
const before = await snapshot(driver, 'doc');
expect(await driver.bulkUpdate('doc', [])).toEqual([]);
expect(await snapshot(driver, 'doc')).toEqual(before);
});

describe('non-strict missing id (default `strictMode`)', () => {
it('a missing id is SKIPPED (no placeholder); the rest of the batch still lands', async () => {
// `IDataDriver.bulkUpdate` is declared `Promise<Record<string, unknown>[]>`
// — no `null` member — so a skipped id is OMITTED, not padded, mirroring
// `SqlDriver.bulkUpdate`'s own `if (updated) results.push(updated)`.
const out = await driver.bulkUpdate('doc', [
{ id: 'ghost', data: { doc_no: 'D-9999' } },
{ id: '1', data: { doc_no: 'D-0100' } },
]);
expect(out).toHaveLength(1);
expect(out[0].doc_no).toBe('D-0100');
expect((await driver.find('doc', { where: { id: '1' } }))[0].doc_no).toBe('D-0100');
});
});

describe('strictMode: true', () => {
it('a missing id refuses the WHOLE batch — table byte-identical, valid rows included', async () => {
const strict = new InMemoryDriver({ strictMode: true });
await strict.syncSchema('doc', DOC_SCHEMA);
await strict.create('doc', { id: '1', doc_no: 'D-0001', title: 'One' });
await strict.create('doc', { id: '2', doc_no: 'D-0002', title: 'Two' });
const before = await snapshot(strict, 'doc');

await expect(
strict.bulkUpdate('doc', [
{ id: '1', data: { doc_no: 'D-0100' } }, // would have been valid alone
{ id: 'ghost', data: { doc_no: 'D-9999' } },
]),
).rejects.toThrow();

expect(await snapshot(strict, 'doc')).toEqual(before);
});
});
});

describe('[#13435] bulkDelete refuses BEFORE writing — no surviving prefix', () => {
let driver: InMemoryDriver;

beforeEach(async () => {
driver = new InMemoryDriver({ strictMode: true });
await driver.syncSchema('doc', DOC_SCHEMA);
await driver.create('doc', { id: '1', doc_no: 'D-0001' });
await driver.create('doc', { id: '2', doc_no: 'D-0002' });
await driver.create('doc', { id: '3', doc_no: 'D-0003' });
});

it('strictMode: a missing id refuses the WHOLE batch — table byte-identical, valid ids included', async () => {
const before = await snapshot(driver, 'doc');

await expect(driver.bulkDelete('doc', ['1', 'ghost', '2'])).rejects.toThrow();

// Id '1' would have been removed BEFORE the refusal under the old
// `Promise.all` shape. Named explicitly, not just via count.
const after = await snapshot(driver, 'doc');
expect(after).toEqual(before);
expect(await driver.count('doc')).toBe(3);
});

it('strictMode: a clean batch still removes every named row', async () => {
await driver.bulkDelete('doc', ['1', '3']);
expect(await driver.count('doc')).toBe(1);
expect((await driver.find('doc', { where: {} }))[0].id).toBe('2');
});

describe('non-strict (default `strictMode`)', () => {
it('a missing id is SKIPPED; the rest of the batch still lands', async () => {
const loose = new InMemoryDriver();
await loose.syncSchema('doc', DOC_SCHEMA);
await loose.create('doc', { id: '1', doc_no: 'D-0001' });
await loose.create('doc', { id: '2', doc_no: 'D-0002' });

await loose.bulkDelete('doc', ['1', 'ghost']);
expect(await loose.count('doc')).toBe(1);
expect((await loose.find('doc', { where: {} }))[0].id).toBe('2');
});
});

it('an empty batch is a no-op', async () => {
const before = await snapshot(driver, 'doc');
await driver.bulkDelete('doc', []);
expect(await snapshot(driver, 'doc')).toEqual(before);
});

it('duplicate ids in one batch delete the row once, not twice', async () => {
await driver.bulkDelete('doc', ['1', '1']);
expect(await driver.count('doc')).toBe(2);
});
});

describe('[#13435] all FOUR batch doors of this driver now agree that a batch is atomic', () => {
it('bulkCreate, updateMany, bulkUpdate and bulkDelete all refuse without moving the table', async () => {
const driver = new InMemoryDriver({ strictMode: true });
await driver.syncSchema('doc', DOC_SCHEMA);
await driver.create('doc', { id: '1', doc_no: 'D-0001' });
await driver.create('doc', { id: '2', doc_no: 'D-0002' });
const before = await snapshot(driver, 'doc');

const createErr = await refusalOf(() =>
driver.bulkCreate('doc', [
{ id: 'a', doc_no: 'D-0100' },
{ id: 'b', doc_no: 'D-0100' },
]),
);
const updateManyErr = await refusalOf(() => driver.updateMany('doc', { where: {} }, { doc_no: 'D-0009' }));
const bulkUpdateErr = await refusalOf(() =>
driver.bulkUpdate('doc', [
{ id: '1', data: { doc_no: 'D-0100' } },
{ id: '2', data: { doc_no: 'D-0100' } }, // collides with the row above, not the table
]),
);
const bulkDeleteErr = await refusalOf(() => driver.bulkDelete('doc', ['1', 'ghost']));

expect(createErr.code).toBe('UNIQUE_VIOLATION');
expect(updateManyErr.code).toBe('UNIQUE_VIOLATION');
expect(bulkUpdateErr.code).toBe('UNIQUE_VIOLATION');
expect(bulkDeleteErr).toBeInstanceOf(Error);

// None of the four doors moved the store.
expect(await snapshot(driver, 'doc')).toEqual(before);
});
});

describe('[#13435] non-regression — updateMany and bulkCreate still behave as #13197/#13340 left them', () => {
let driver: InMemoryDriver;

beforeEach(async () => {
driver = new InMemoryDriver();
await driver.syncSchema('doc', DOC_SCHEMA);
await driver.create('doc', { id: '1', doc_no: 'D-0001' });
await driver.create('doc', { id: '2', doc_no: 'D-0002' });
});

it('updateMany still refuses a colliding shared patch and leaves the table untouched', async () => {
const before = await snapshot(driver, 'doc');
const err = await refusalOf(() => driver.updateMany('doc', { where: {} }, { doc_no: 'D-SAME' }));
expect(err.code).toBe('UNIQUE_VIOLATION');
expect(await snapshot(driver, 'doc')).toEqual(before);
});

it('updateMany still applies a clean shared patch to every matched row', async () => {
const count = await driver.updateMany('doc', { where: {} }, { title: 'Bulk' });
expect(count).toBe(2);
expect((await driver.find('doc', { where: { id: '1' } }))[0].title).toBe('Bulk');
expect((await driver.find('doc', { where: { id: '2' } }))[0].title).toBe('Bulk');
});

it('bulkCreate still refuses a self-colliding batch and leaves the table untouched', async () => {
const before = await snapshot(driver, 'doc');
const err = await refusalOf(() =>
driver.bulkCreate('doc', [
{ id: 'a', doc_no: 'D-0100' },
{ id: 'b', doc_no: 'D-0100' },
]),
);
expect(err.code).toBe('UNIQUE_VIOLATION');
expect(await snapshot(driver, 'doc')).toEqual(before);
});

it('bulkCreate still lands a clean batch in full', async () => {
const out = await driver.bulkCreate('doc', [{ id: 'a', doc_no: 'D-0100' }]);
expect(out).toHaveLength(1);
expect(await driver.count('doc')).toBe(3);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' fix(driver-memory): make bulkUpdate and bulkDelete all-or-nothing by zhuangjianguo · Pull Request #13875 · objectstack-ai/objectstack · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .changeset/driver-memory-bulk-update-delete-atomicity.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
---
"@objectstack/driver-memory": patch
---

fix(driver-memory): make `bulkUpdate` and `bulkDelete` all-or-nothing, so a refused row leaves the table exactly as it found it (#13435)

`bulkUpdate` and `bulkDelete` were still `Promise.all(map(...))` over
`update`/`delete`, and both of those write into the table synchronously. So a
mid-batch refusal — a `UNIQUE_VIOLATION`/409 on `bulkUpdate`, a missing-id
throw under `strictMode` on `bulkDelete` — left every row processed *before*
it already mutated, and the caller got a rejection describing a batch that
had partly landed. #13340 fixed the identical shape on `bulkCreate`; this is
the third and fourth batch door.

`bulkUpdate` now builds and checks every pending row's post-image — each id
keeps its own patch, so this is new construction (a projected row set per
pending row, generalized from `updateMany`'s single-shared-patch posture)
rather than a copy of either sibling door — before writing any of them.
`bulkDelete` resolves every id to a table index first, refusing the whole
batch under `strictMode` before touching the table if one is missing, and
only then splices. Both follow `update`/`delete`'s own existing contract for
a missing id (skip under non-strict, refuse under strict) rather than a third
posture. `bulkDelete` still returns `void` — no current caller reads a
per-row outcome, so widening the return type stays out of scope.

All four batch doors of `InMemoryDriver` (`bulkCreate`, `updateMany`,
`bulkUpdate`, `bulkDelete`) now agree that a batch is atomic.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#13435] `bulkUpdate` and `bulkDelete` are ALL-OR-NOTHING: a refused row
* leaves the table exactly as it found it.
*
* ## The defect this pins, and why "it still throws" would not have caught it
*
* Both doors used to be `Promise.all(map(...))` over `update`/`delete`, and
* both of THOSE write into the table synchronously. So when one row of a
* batch was refused — `UNIQUE_VIOLATION`/409 on `bulkUpdate`, a missing-id
* throw under `strictMode` on `bulkDelete` — every row processed BEFORE it
* stayed mutated, and the caller got a rejection describing a batch that had
* partly landed. #13340 measured the identical shape on `bulkCreate`; these
* are the third and fourth batch doors it did not reach.
*
* ⛔ Asserting "the refusal still happens" proves NOTHING here — the refusal
* was already correct (#13197/#13239 pin `assertUnique`, `delete`'s own
* strict-mode throw predates this file). **The discriminating fact is that
* the TABLE DOES NOT MOVE**, so every test below reads the store back after
* the refusal — full rows, not just a count — rather than stopping at the
* envelope.
*
* ## The construction, and why it is NOT `updateMany`'s shape copied over
*
* `updateMany` stamps ONE shared `data` onto every matched row and has no
* per-row pre-image to exclude. `bulkCreate` has no pre-image at all (every
* row is new). `bulkUpdate` is neither: each id in the batch carries its OWN
* patch, so the fix needed new construction — per pending row, its own
* `exceptId` AND a projected row set holding the OTHER rows' post-images
* while dropping their pre-images — not a transcription of either sibling.
*
* ## What this does NOT claim
*
* Atomicity here is the driver refusing before it writes — not a
* transaction, and no rollback: nothing is written until the whole batch has
* been checked (`bulkUpdate`) or resolved to indices (`bulkDelete`).
*/

import { describe, it, expect, beforeEach } from 'vitest';
import { InMemoryDriver } from './memory-driver.js';

interface WireBearingError extends Error {
code?: string;
status?: number;
}

/** Run `fn`, requiring it to reject; hand back the rejection for inspection. */
async function refusalOf(fn: () => Promise<unknown>): Promise<WireBearingError> {
try {
await fn();
} catch (e) {
return e as WireBearingError;
}
throw new Error('expected the driver to refuse this write, but it resolved');
}

const DOC_SCHEMA = {
name: 'doc',
fields: {
id: { type: 'text' },
doc_no: { type: 'text', unique: 'global' },
title: { type: 'text' },
},
} as any;

/** The whole table, sorted by `id` — used to prove BYTE-IDENTITY, not just a count. */
async function snapshot(driver: InMemoryDriver, object: string) {
const rows = await driver.find(object, {});
return rows.slice().sort((a: any, b: any) => String(a.id).localeCompare(String(b.id)));
}

describe('[#13435] bulkUpdate refuses BEFORE writing — no surviving prefix', () => {
let driver: InMemoryDriver;

beforeEach(async () => {
driver = new InMemoryDriver();
await driver.syncSchema('doc', DOC_SCHEMA);
await driver.create('doc', { id: '1', doc_no: 'D-0001', title: 'One' });
await driver.create('doc', { id: '2', doc_no: 'D-0002', title: 'Two' });
await driver.create('doc', { id: '3', doc_no: 'D-0003', title: 'Three' });
});

it('a batch colliding WITHIN itself leaves the table byte-identical', async () => {
const before = await snapshot(driver, 'doc');

const err = await refusalOf(() =>
driver.bulkUpdate('doc', [
{ id: '1', data: { doc_no: 'D-0100' } },
{ id: '2', data: { doc_no: 'D-0100' } }, // collides with the row above, not with the table
]),
);
expect(err.code).toBe('UNIQUE_VIOLATION');
expect(err.status).toBe(409);

// Named explicitly: it is row '1' — accepted BEFORE the refusal under the
// old `Promise.all` shape — whose survival used to be the defect.
const after = await snapshot(driver, 'doc');
expect(after).toEqual(before);
expect((await driver.find('doc', { where: { id: '1' } }))[0].doc_no).toBe('D-0001');
});

it('a batch colliding with a STORED, UNTOUCHED row leaves the table byte-identical', async () => {
const before = await snapshot(driver, 'doc');
// Row '1' would be accepted (no collision on its own) before row '2'
// collides with row '3' — untouched by this batch, so it sits in
// `settled` — under the old `Promise.all` shape row '1' would have
// landed as a survivor before the second `update()` call threw.
const err = await refusalOf(() =>
driver.bulkUpdate('doc', [
{ id: '1', data: { doc_no: 'D-0100' } },
{ id: '2', data: { doc_no: 'D-0003' } }, // row '3's stored value — untouched by this batch
]),
);
expect(err.code).toBe('UNIQUE_VIOLATION');
expect(err.status).toBe(409);

const after = await snapshot(driver, 'doc');
expect(after).toEqual(before);
});

it('the FIRST row colliding refuses the batch too — the check is not order-dependent', async () => {
const before = await snapshot(driver, 'doc');
// Row '1' collides with STORED untouched row '3' on the very first
// iteration; row '2's patch is unrelated and would still land under a
// check that bailed out of the loop but had already pushed nothing.
const err = await refusalOf(() =>
driver.bulkUpdate('doc', [
{ id: '1', data: { doc_no: 'D-0003' } }, // row '3's stored value — collides immediately
{ id: '2', data: { doc_no: 'D-0300' } },
]),
);
expect(err.code).toBe('UNIQUE_VIOLATION');
const after = await snapshot(driver, 'doc');
expect(after).toEqual(before);
// The row AFTER the refusal must not land either — named directly, not
// just inferred from the full-snapshot equality above.
expect((await driver.find('doc', { where: { id: '2' } }))[0].doc_no).toBe('D-0002');
});

it('a clean batch still lands in FULL and returns every updated row', async () => {
// The non-vacuity control: a fix that refused everything would pass every
// assertion above.
const out = await driver.bulkUpdate('doc', [
{ id: '1', data: { doc_no: 'D-0100' } },
{ id: '2', data: { doc_no: 'D-0200' } },
]);
expect(out.map((r: any) => r.doc_no)).toEqual(['D-0100', 'D-0200']);
expect((await driver.find('doc', { where: { id: '1' } }))[0].doc_no).toBe('D-0100');
expect((await driver.find('doc', { where: { id: '2' } }))[0].doc_no).toBe('D-0200');
});

it('a row that keeps its OWN unique value (untouched field) does not collide with itself', async () => {
// exceptId must still exclude a row from its own pre-image when the patch
// does not touch the unique field.
const out = await driver.bulkUpdate('doc', [{ id: '1', data: { title: 'Renamed' } }]);
expect(out[0].doc_no).toBe('D-0001');
expect(out[0].title).toBe('Renamed');
});

it('an empty batch is a no-op that resolves to an empty array', async () => {
const before = await snapshot(driver, 'doc');
expect(await driver.bulkUpdate('doc', [])).toEqual([]);
expect(await snapshot(driver, 'doc')).toEqual(before);
});

describe('non-strict missing id (default `strictMode`)', () => {
it('a missing id is SKIPPED (no placeholder); the rest of the batch still lands', async () => {
// `IDataDriver.bulkUpdate` is declared `Promise<Record<string, unknown>[]>`
// — no `null` member — so a skipped id is OMITTED, not padded, mirroring
// `SqlDriver.bulkUpdate`'s own `if (updated) results.push(updated)`.
const out = await driver.bulkUpdate('doc', [
{ id: 'ghost', data: { doc_no: 'D-9999' } },
{ id: '1', data: { doc_no: 'D-0100' } },
]);
expect(out).toHaveLength(1);
expect(out[0].doc_no).toBe('D-0100');
expect((await driver.find('doc', { where: { id: '1' } }))[0].doc_no).toBe('D-0100');
});
});

describe('strictMode: true', () => {
it('a missing id refuses the WHOLE batch — table byte-identical, valid rows included', async () => {
const strict = new InMemoryDriver({ strictMode: true });
await strict.syncSchema('doc', DOC_SCHEMA);
await strict.create('doc', { id: '1', doc_no: 'D-0001', title: 'One' });
await strict.create('doc', { id: '2', doc_no: 'D-0002', title: 'Two' });
const before = await snapshot(strict, 'doc');

await expect(
strict.bulkUpdate('doc', [
{ id: '1', data: { doc_no: 'D-0100' } }, // would have been valid alone
{ id: 'ghost', data: { doc_no: 'D-9999' } },
]),
).rejects.toThrow();

expect(await snapshot(strict, 'doc')).toEqual(before);
});
});
});

describe('[#13435] bulkDelete refuses BEFORE writing — no surviving prefix', () => {
let driver: InMemoryDriver;

beforeEach(async () => {
driver = new InMemoryDriver({ strictMode: true });
await driver.syncSchema('doc', DOC_SCHEMA);
await driver.create('doc', { id: '1', doc_no: 'D-0001' });
await driver.create('doc', { id: '2', doc_no: 'D-0002' });
await driver.create('doc', { id: '3', doc_no: 'D-0003' });
});

it('strictMode: a missing id refuses the WHOLE batch — table byte-identical, valid ids included', async () => {
const before = await snapshot(driver, 'doc');

await expect(driver.bulkDelete('doc', ['1', 'ghost', '2'])).rejects.toThrow();

// Id '1' would have been removed BEFORE the refusal under the old
// `Promise.all` shape. Named explicitly, not just via count.
const after = await snapshot(driver, 'doc');
expect(after).toEqual(before);
expect(await driver.count('doc')).toBe(3);
});

it('strictMode: a clean batch still removes every named row', async () => {
await driver.bulkDelete('doc', ['1', '3']);
expect(await driver.count('doc')).toBe(1);
expect((await driver.find('doc', { where: {} }))[0].id).toBe('2');
});

describe('non-strict (default `strictMode`)', () => {
it('a missing id is SKIPPED; the rest of the batch still lands', async () => {
const loose = new InMemoryDriver();
await loose.syncSchema('doc', DOC_SCHEMA);
await loose.create('doc', { id: '1', doc_no: 'D-0001' });
await loose.create('doc', { id: '2', doc_no: 'D-0002' });

await loose.bulkDelete('doc', ['1', 'ghost']);
expect(await loose.count('doc')).toBe(1);
expect((await loose.find('doc', { where: {} }))[0].id).toBe('2');
});
});

it('an empty batch is a no-op', async () => {
const before = await snapshot(driver, 'doc');
await driver.bulkDelete('doc', []);
expect(await snapshot(driver, 'doc')).toEqual(before);
});

it('duplicate ids in one batch delete the row once, not twice', async () => {
await driver.bulkDelete('doc', ['1', '1']);
expect(await driver.count('doc')).toBe(2);
});
});

describe('[#13435] all FOUR batch doors of this driver now agree that a batch is atomic', () => {
it('bulkCreate, updateMany, bulkUpdate and bulkDelete all refuse without moving the table', async () => {
const driver = new InMemoryDriver({ strictMode: true });
await driver.syncSchema('doc', DOC_SCHEMA);
await driver.create('doc', { id: '1', doc_no: 'D-0001' });
await driver.create('doc', { id: '2', doc_no: 'D-0002' });
const before = await snapshot(driver, 'doc');

const createErr = await refusalOf(() =>
driver.bulkCreate('doc', [
{ id: 'a', doc_no: 'D-0100' },
{ id: 'b', doc_no: 'D-0100' },
]),
);
const updateManyErr = await refusalOf(() => driver.updateMany('doc', { where: {} }, { doc_no: 'D-0009' }));
const bulkUpdateErr = await refusalOf(() =>
driver.bulkUpdate('doc', [
{ id: '1', data: { doc_no: 'D-0100' } },
{ id: '2', data: { doc_no: 'D-0100' } }, // collides with the row above, not the table
]),
);
const bulkDeleteErr = await refusalOf(() => driver.bulkDelete('doc', ['1', 'ghost']));

expect(createErr.code).toBe('UNIQUE_VIOLATION');
expect(updateManyErr.code).toBe('UNIQUE_VIOLATION');
expect(bulkUpdateErr.code).toBe('UNIQUE_VIOLATION');
expect(bulkDeleteErr).toBeInstanceOf(Error);

// None of the four doors moved the store.
expect(await snapshot(driver, 'doc')).toEqual(before);
});
});

describe('[#13435] non-regression — updateMany and bulkCreate still behave as #13197/#13340 left them', () => {
let driver: InMemoryDriver;

beforeEach(async () => {
driver = new InMemoryDriver();
await driver.syncSchema('doc', DOC_SCHEMA);
await driver.create('doc', { id: '1', doc_no: 'D-0001' });
await driver.create('doc', { id: '2', doc_no: 'D-0002' });
});

it('updateMany still refuses a colliding shared patch and leaves the table untouched', async () => {
const before = await snapshot(driver, 'doc');
const err = await refusalOf(() => driver.updateMany('doc', { where: {} }, { doc_no: 'D-SAME' }));
expect(err.code).toBe('UNIQUE_VIOLATION');
expect(await snapshot(driver, 'doc')).toEqual(before);
});

it('updateMany still applies a clean shared patch to every matched row', async () => {
const count = await driver.updateMany('doc', { where: {} }, { title: 'Bulk' });
expect(count).toBe(2);
expect((await driver.find('doc', { where: { id: '1' } }))[0].title).toBe('Bulk');
expect((await driver.find('doc', { where: { id: '2' } }))[0].title).toBe('Bulk');
});

it('bulkCreate still refuses a self-colliding batch and leaves the table untouched', async () => {
const before = await snapshot(driver, 'doc');
const err = await refusalOf(() =>
driver.bulkCreate('doc', [
{ id: 'a', doc_no: 'D-0100' },
{ id: 'b', doc_no: 'D-0100' },
]),
);
expect(err.code).toBe('UNIQUE_VIOLATION');
expect(await snapshot(driver, 'doc')).toEqual(before);
});

it('bulkCreate still lands a clean batch in full', async () => {
const out = await driver.bulkCreate('doc', [{ id: 'a', doc_no: 'D-0100' }]);
expect(out).toHaveLength(1);
expect(await driver.count('doc')).toBe(3);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(driver-memory): make bulkUpdate and bulkDelete all-or-nothing by zhuangjianguo · Pull Request #13875 · objectstack-ai/objectstack · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .changeset/driver-memory-bulk-update-delete-atomicity.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
---
"@objectstack/driver-memory": patch
---

fix(driver-memory): make `bulkUpdate` and `bulkDelete` all-or-nothing, so a refused row leaves the table exactly as it found it (#13435)

`bulkUpdate` and `bulkDelete` were still `Promise.all(map(...))` over
`update`/`delete`, and both of those write into the table synchronously. So a
mid-batch refusal — a `UNIQUE_VIOLATION`/409 on `bulkUpdate`, a missing-id
throw under `strictMode` on `bulkDelete` — left every row processed *before*
it already mutated, and the caller got a rejection describing a batch that
had partly landed. #13340 fixed the identical shape on `bulkCreate`; this is
the third and fourth batch door.

`bulkUpdate` now builds and checks every pending row's post-image — each id
keeps its own patch, so this is new construction (a projected row set per
pending row, generalized from `updateMany`'s single-shared-patch posture)
rather than a copy of either sibling door — before writing any of them.
`bulkDelete` resolves every id to a table index first, refusing the whole
batch under `strictMode` before touching the table if one is missing, and
only then splices. Both follow `update`/`delete`'s own existing contract for
a missing id (skip under non-strict, refuse under strict) rather than a third
posture. `bulkDelete` still returns `void` — no current caller reads a
per-row outcome, so widening the return type stays out of scope.

All four batch doors of `InMemoryDriver` (`bulkCreate`, `updateMany`,
`bulkUpdate`, `bulkDelete`) now agree that a batch is atomic.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#13435] `bulkUpdate` and `bulkDelete` are ALL-OR-NOTHING: a refused row
* leaves the table exactly as it found it.
*
* ## The defect this pins, and why "it still throws" would not have caught it
*
* Both doors used to be `Promise.all(map(...))` over `update`/`delete`, and
* both of THOSE write into the table synchronously. So when one row of a
* batch was refused — `UNIQUE_VIOLATION`/409 on `bulkUpdate`, a missing-id
* throw under `strictMode` on `bulkDelete` — every row processed BEFORE it
* stayed mutated, and the caller got a rejection describing a batch that had
* partly landed. #13340 measured the identical shape on `bulkCreate`; these
* are the third and fourth batch doors it did not reach.
*
* ⛔ Asserting "the refusal still happens" proves NOTHING here — the refusal
* was already correct (#13197/#13239 pin `assertUnique`, `delete`'s own
* strict-mode throw predates this file). **The discriminating fact is that
* the TABLE DOES NOT MOVE**, so every test below reads the store back after
* the refusal — full rows, not just a count — rather than stopping at the
* envelope.
*
* ## The construction, and why it is NOT `updateMany`'s shape copied over
*
* `updateMany` stamps ONE shared `data` onto every matched row and has no
* per-row pre-image to exclude. `bulkCreate` has no pre-image at all (every
* row is new). `bulkUpdate` is neither: each id in the batch carries its OWN
* patch, so the fix needed new construction — per pending row, its own
* `exceptId` AND a projected row set holding the OTHER rows' post-images
* while dropping their pre-images — not a transcription of either sibling.
*
* ## What this does NOT claim
*
* Atomicity here is the driver refusing before it writes — not a
* transaction, and no rollback: nothing is written until the whole batch has
* been checked (`bulkUpdate`) or resolved to indices (`bulkDelete`).
*/

import { describe, it, expect, beforeEach } from 'vitest';
import { InMemoryDriver } from './memory-driver.js';

interface WireBearingError extends Error {
code?: string;
status?: number;
}

/** Run `fn`, requiring it to reject; hand back the rejection for inspection. */
async function refusalOf(fn: () => Promise<unknown>): Promise<WireBearingError> {
try {
await fn();
} catch (e) {
return e as WireBearingError;
}
throw new Error('expected the driver to refuse this write, but it resolved');
}

const DOC_SCHEMA = {
name: 'doc',
fields: {
id: { type: 'text' },
doc_no: { type: 'text', unique: 'global' },
title: { type: 'text' },
},
} as any;

/** The whole table, sorted by `id` — used to prove BYTE-IDENTITY, not just a count. */
async function snapshot(driver: InMemoryDriver, object: string) {
const rows = await driver.find(object, {});
return rows.slice().sort((a: any, b: any) => String(a.id).localeCompare(String(b.id)));
}

describe('[#13435] bulkUpdate refuses BEFORE writing — no surviving prefix', () => {
let driver: InMemoryDriver;

beforeEach(async () => {
driver = new InMemoryDriver();
await driver.syncSchema('doc', DOC_SCHEMA);
await driver.create('doc', { id: '1', doc_no: 'D-0001', title: 'One' });
await driver.create('doc', { id: '2', doc_no: 'D-0002', title: 'Two' });
await driver.create('doc', { id: '3', doc_no: 'D-0003', title: 'Three' });
});

it('a batch colliding WITHIN itself leaves the table byte-identical', async () => {
const before = await snapshot(driver, 'doc');

const err = await refusalOf(() =>
driver.bulkUpdate('doc', [
{ id: '1', data: { doc_no: 'D-0100' } },
{ id: '2', data: { doc_no: 'D-0100' } }, // collides with the row above, not with the table
]),
);
expect(err.code).toBe('UNIQUE_VIOLATION');
expect(err.status).toBe(409);

// Named explicitly: it is row '1' — accepted BEFORE the refusal under the
// old `Promise.all` shape — whose survival used to be the defect.
const after = await snapshot(driver, 'doc');
expect(after).toEqual(before);
expect((await driver.find('doc', { where: { id: '1' } }))[0].doc_no).toBe('D-0001');
});

it('a batch colliding with a STORED, UNTOUCHED row leaves the table byte-identical', async () => {
const before = await snapshot(driver, 'doc');
// Row '1' would be accepted (no collision on its own) before row '2'
// collides with row '3' — untouched by this batch, so it sits in
// `settled` — under the old `Promise.all` shape row '1' would have
// landed as a survivor before the second `update()` call threw.
const err = await refusalOf(() =>
driver.bulkUpdate('doc', [
{ id: '1', data: { doc_no: 'D-0100' } },
{ id: '2', data: { doc_no: 'D-0003' } }, // row '3's stored value — untouched by this batch
]),
);
expect(err.code).toBe('UNIQUE_VIOLATION');
expect(err.status).toBe(409);

const after = await snapshot(driver, 'doc');
expect(after).toEqual(before);
});

it('the FIRST row colliding refuses the batch too — the check is not order-dependent', async () => {
const before = await snapshot(driver, 'doc');
// Row '1' collides with STORED untouched row '3' on the very first
// iteration; row '2's patch is unrelated and would still land under a
// check that bailed out of the loop but had already pushed nothing.
const err = await refusalOf(() =>
driver.bulkUpdate('doc', [
{ id: '1', data: { doc_no: 'D-0003' } }, // row '3's stored value — collides immediately
{ id: '2', data: { doc_no: 'D-0300' } },
]),
);
expect(err.code).toBe('UNIQUE_VIOLATION');
const after = await snapshot(driver, 'doc');
expect(after).toEqual(before);
// The row AFTER the refusal must not land either — named directly, not
// just inferred from the full-snapshot equality above.
expect((await driver.find('doc', { where: { id: '2' } }))[0].doc_no).toBe('D-0002');
});

it('a clean batch still lands in FULL and returns every updated row', async () => {
// The non-vacuity control: a fix that refused everything would pass every
// assertion above.
const out = await driver.bulkUpdate('doc', [
{ id: '1', data: { doc_no: 'D-0100' } },
{ id: '2', data: { doc_no: 'D-0200' } },
]);
expect(out.map((r: any) => r.doc_no)).toEqual(['D-0100', 'D-0200']);
expect((await driver.find('doc', { where: { id: '1' } }))[0].doc_no).toBe('D-0100');
expect((await driver.find('doc', { where: { id: '2' } }))[0].doc_no).toBe('D-0200');
});

it('a row that keeps its OWN unique value (untouched field) does not collide with itself', async () => {
// exceptId must still exclude a row from its own pre-image when the patch
// does not touch the unique field.
const out = await driver.bulkUpdate('doc', [{ id: '1', data: { title: 'Renamed' } }]);
expect(out[0].doc_no).toBe('D-0001');
expect(out[0].title).toBe('Renamed');
});

it('an empty batch is a no-op that resolves to an empty array', async () => {
const before = await snapshot(driver, 'doc');
expect(await driver.bulkUpdate('doc', [])).toEqual([]);
expect(await snapshot(driver, 'doc')).toEqual(before);
});

describe('non-strict missing id (default `strictMode`)', () => {
it('a missing id is SKIPPED (no placeholder); the rest of the batch still lands', async () => {
// `IDataDriver.bulkUpdate` is declared `Promise<Record<string, unknown>[]>`
// — no `null` member — so a skipped id is OMITTED, not padded, mirroring
// `SqlDriver.bulkUpdate`'s own `if (updated) results.push(updated)`.
const out = await driver.bulkUpdate('doc', [
{ id: 'ghost', data: { doc_no: 'D-9999' } },
{ id: '1', data: { doc_no: 'D-0100' } },
]);
expect(out).toHaveLength(1);
expect(out[0].doc_no).toBe('D-0100');
expect((await driver.find('doc', { where: { id: '1' } }))[0].doc_no).toBe('D-0100');
});
});

describe('strictMode: true', () => {
it('a missing id refuses the WHOLE batch — table byte-identical, valid rows included', async () => {
const strict = new InMemoryDriver({ strictMode: true });
await strict.syncSchema('doc', DOC_SCHEMA);
await strict.create('doc', { id: '1', doc_no: 'D-0001', title: 'One' });
await strict.create('doc', { id: '2', doc_no: 'D-0002', title: 'Two' });
const before = await snapshot(strict, 'doc');

await expect(
strict.bulkUpdate('doc', [
{ id: '1', data: { doc_no: 'D-0100' } }, // would have been valid alone
{ id: 'ghost', data: { doc_no: 'D-9999' } },
]),
).rejects.toThrow();

expect(await snapshot(strict, 'doc')).toEqual(before);
});
});
});

describe('[#13435] bulkDelete refuses BEFORE writing — no surviving prefix', () => {
let driver: InMemoryDriver;

beforeEach(async () => {
driver = new InMemoryDriver({ strictMode: true });
await driver.syncSchema('doc', DOC_SCHEMA);
await driver.create('doc', { id: '1', doc_no: 'D-0001' });
await driver.create('doc', { id: '2', doc_no: 'D-0002' });
await driver.create('doc', { id: '3', doc_no: 'D-0003' });
});

it('strictMode: a missing id refuses the WHOLE batch — table byte-identical, valid ids included', async () => {
const before = await snapshot(driver, 'doc');

await expect(driver.bulkDelete('doc', ['1', 'ghost', '2'])).rejects.toThrow();

// Id '1' would have been removed BEFORE the refusal under the old
// `Promise.all` shape. Named explicitly, not just via count.
const after = await snapshot(driver, 'doc');
expect(after).toEqual(before);
expect(await driver.count('doc')).toBe(3);
});

it('strictMode: a clean batch still removes every named row', async () => {
await driver.bulkDelete('doc', ['1', '3']);
expect(await driver.count('doc')).toBe(1);
expect((await driver.find('doc', { where: {} }))[0].id).toBe('2');
});

describe('non-strict (default `strictMode`)', () => {
it('a missing id is SKIPPED; the rest of the batch still lands', async () => {
const loose = new InMemoryDriver();
await loose.syncSchema('doc', DOC_SCHEMA);
await loose.create('doc', { id: '1', doc_no: 'D-0001' });
await loose.create('doc', { id: '2', doc_no: 'D-0002' });

await loose.bulkDelete('doc', ['1', 'ghost']);
expect(await loose.count('doc')).toBe(1);
expect((await loose.find('doc', { where: {} }))[0].id).toBe('2');
});
});

it('an empty batch is a no-op', async () => {
const before = await snapshot(driver, 'doc');
await driver.bulkDelete('doc', []);
expect(await snapshot(driver, 'doc')).toEqual(before);
});

it('duplicate ids in one batch delete the row once, not twice', async () => {
await driver.bulkDelete('doc', ['1', '1']);
expect(await driver.count('doc')).toBe(2);
});
});

describe('[#13435] all FOUR batch doors of this driver now agree that a batch is atomic', () => {
it('bulkCreate, updateMany, bulkUpdate and bulkDelete all refuse without moving the table', async () => {
const driver = new InMemoryDriver({ strictMode: true });
await driver.syncSchema('doc', DOC_SCHEMA);
await driver.create('doc', { id: '1', doc_no: 'D-0001' });
await driver.create('doc', { id: '2', doc_no: 'D-0002' });
const before = await snapshot(driver, 'doc');

const createErr = await refusalOf(() =>
driver.bulkCreate('doc', [
{ id: 'a', doc_no: 'D-0100' },
{ id: 'b', doc_no: 'D-0100' },
]),
);
const updateManyErr = await refusalOf(() => driver.updateMany('doc', { where: {} }, { doc_no: 'D-0009' }));
const bulkUpdateErr = await refusalOf(() =>
driver.bulkUpdate('doc', [
{ id: '1', data: { doc_no: 'D-0100' } },
{ id: '2', data: { doc_no: 'D-0100' } }, // collides with the row above, not the table
]),
);
const bulkDeleteErr = await refusalOf(() => driver.bulkDelete('doc', ['1', 'ghost']));

expect(createErr.code).toBe('UNIQUE_VIOLATION');
expect(updateManyErr.code).toBe('UNIQUE_VIOLATION');
expect(bulkUpdateErr.code).toBe('UNIQUE_VIOLATION');
expect(bulkDeleteErr).toBeInstanceOf(Error);

// None of the four doors moved the store.
expect(await snapshot(driver, 'doc')).toEqual(before);
});
});

describe('[#13435] non-regression — updateMany and bulkCreate still behave as #13197/#13340 left them', () => {
let driver: InMemoryDriver;

beforeEach(async () => {
driver = new InMemoryDriver();
await driver.syncSchema('doc', DOC_SCHEMA);
await driver.create('doc', { id: '1', doc_no: 'D-0001' });
await driver.create('doc', { id: '2', doc_no: 'D-0002' });
});

it('updateMany still refuses a colliding shared patch and leaves the table untouched', async () => {
const before = await snapshot(driver, 'doc');
const err = await refusalOf(() => driver.updateMany('doc', { where: {} }, { doc_no: 'D-SAME' }));
expect(err.code).toBe('UNIQUE_VIOLATION');
expect(await snapshot(driver, 'doc')).toEqual(before);
});

it('updateMany still applies a clean shared patch to every matched row', async () => {
const count = await driver.updateMany('doc', { where: {} }, { title: 'Bulk' });
expect(count).toBe(2);
expect((await driver.find('doc', { where: { id: '1' } }))[0].title).toBe('Bulk');
expect((await driver.find('doc', { where: { id: '2' } }))[0].title).toBe('Bulk');
});

it('bulkCreate still refuses a self-colliding batch and leaves the table untouched', async () => {
const before = await snapshot(driver, 'doc');
const err = await refusalOf(() =>
driver.bulkCreate('doc', [
{ id: 'a', doc_no: 'D-0100' },
{ id: 'b', doc_no: 'D-0100' },
]),
);
expect(err.code).toBe('UNIQUE_VIOLATION');
expect(await snapshot(driver, 'doc')).toEqual(before);
});

it('bulkCreate still lands a clean batch in full', async () => {
const out = await driver.bulkCreate('doc', [{ id: 'a', doc_no: 'D-0100' }]);
expect(out).toHaveLength(1);
expect(await driver.count('doc')).toBe(3);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(driver-memory): make bulkUpdate and bulkDelete all-or-nothing by zhuangjianguo · Pull Request #13875 · objectstack-ai/objectstack · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .changeset/driver-memory-bulk-update-delete-atomicity.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
---
"@objectstack/driver-memory": patch
---

fix(driver-memory): make `bulkUpdate` and `bulkDelete` all-or-nothing, so a refused row leaves the table exactly as it found it (#13435)

`bulkUpdate` and `bulkDelete` were still `Promise.all(map(...))` over
`update`/`delete`, and both of those write into the table synchronously. So a
mid-batch refusal — a `UNIQUE_VIOLATION`/409 on `bulkUpdate`, a missing-id
throw under `strictMode` on `bulkDelete` — left every row processed *before*
it already mutated, and the caller got a rejection describing a batch that
had partly landed. #13340 fixed the identical shape on `bulkCreate`; this is
the third and fourth batch door.

`bulkUpdate` now builds and checks every pending row's post-image — each id
keeps its own patch, so this is new construction (a projected row set per
pending row, generalized from `updateMany`'s single-shared-patch posture)
rather than a copy of either sibling door — before writing any of them.
`bulkDelete` resolves every id to a table index first, refusing the whole
batch under `strictMode` before touching the table if one is missing, and
only then splices. Both follow `update`/`delete`'s own existing contract for
a missing id (skip under non-strict, refuse under strict) rather than a third
posture. `bulkDelete` still returns `void` — no current caller reads a
per-row outcome, so widening the return type stays out of scope.

All four batch doors of `InMemoryDriver` (`bulkCreate`, `updateMany`,
`bulkUpdate`, `bulkDelete`) now agree that a batch is atomic.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#13435] `bulkUpdate` and `bulkDelete` are ALL-OR-NOTHING: a refused row
* leaves the table exactly as it found it.
*
* ## The defect this pins, and why "it still throws" would not have caught it
*
* Both doors used to be `Promise.all(map(...))` over `update`/`delete`, and
* both of THOSE write into the table synchronously. So when one row of a
* batch was refused — `UNIQUE_VIOLATION`/409 on `bulkUpdate`, a missing-id
* throw under `strictMode` on `bulkDelete` — every row processed BEFORE it
* stayed mutated, and the caller got a rejection describing a batch that had
* partly landed. #13340 measured the identical shape on `bulkCreate`; these
* are the third and fourth batch doors it did not reach.
*
* ⛔ Asserting "the refusal still happens" proves NOTHING here — the refusal
* was already correct (#13197/#13239 pin `assertUnique`, `delete`'s own
* strict-mode throw predates this file). **The discriminating fact is that
* the TABLE DOES NOT MOVE**, so every test below reads the store back after
* the refusal — full rows, not just a count — rather than stopping at the
* envelope.
*
* ## The construction, and why it is NOT `updateMany`'s shape copied over
*
* `updateMany` stamps ONE shared `data` onto every matched row and has no
* per-row pre-image to exclude. `bulkCreate` has no pre-image at all (every
* row is new). `bulkUpdate` is neither: each id in the batch carries its OWN
* patch, so the fix needed new construction — per pending row, its own
* `exceptId` AND a projected row set holding the OTHER rows' post-images
* while dropping their pre-images — not a transcription of either sibling.
*
* ## What this does NOT claim
*
* Atomicity here is the driver refusing before it writes — not a
* transaction, and no rollback: nothing is written until the whole batch has
* been checked (`bulkUpdate`) or resolved to indices (`bulkDelete`).
*/

import { describe, it, expect, beforeEach } from 'vitest';
import { InMemoryDriver } from './memory-driver.js';

interface WireBearingError extends Error {
code?: string;
status?: number;
}

/** Run `fn`, requiring it to reject; hand back the rejection for inspection. */
async function refusalOf(fn: () => Promise<unknown>): Promise<WireBearingError> {
try {
await fn();
} catch (e) {
return e as WireBearingError;
}
throw new Error('expected the driver to refuse this write, but it resolved');
}

const DOC_SCHEMA = {
name: 'doc',
fields: {
id: { type: 'text' },
doc_no: { type: 'text', unique: 'global' },
title: { type: 'text' },
},
} as any;

/** The whole table, sorted by `id` — used to prove BYTE-IDENTITY, not just a count. */
async function snapshot(driver: InMemoryDriver, object: string) {
const rows = await driver.find(object, {});
return rows.slice().sort((a: any, b: any) => String(a.id).localeCompare(String(b.id)));
}

describe('[#13435] bulkUpdate refuses BEFORE writing — no surviving prefix', () => {
let driver: InMemoryDriver;

beforeEach(async () => {
driver = new InMemoryDriver();
await driver.syncSchema('doc', DOC_SCHEMA);
await driver.create('doc', { id: '1', doc_no: 'D-0001', title: 'One' });
await driver.create('doc', { id: '2', doc_no: 'D-0002', title: 'Two' });
await driver.create('doc', { id: '3', doc_no: 'D-0003', title: 'Three' });
});

it('a batch colliding WITHIN itself leaves the table byte-identical', async () => {
const before = await snapshot(driver, 'doc');

const err = await refusalOf(() =>
driver.bulkUpdate('doc', [
{ id: '1', data: { doc_no: 'D-0100' } },
{ id: '2', data: { doc_no: 'D-0100' } }, // collides with the row above, not with the table
]),
);
expect(err.code).toBe('UNIQUE_VIOLATION');
expect(err.status).toBe(409);

// Named explicitly: it is row '1' — accepted BEFORE the refusal under the
// old `Promise.all` shape — whose survival used to be the defect.
const after = await snapshot(driver, 'doc');
expect(after).toEqual(before);
expect((await driver.find('doc', { where: { id: '1' } }))[0].doc_no).toBe('D-0001');
});

it('a batch colliding with a STORED, UNTOUCHED row leaves the table byte-identical', async () => {
const before = await snapshot(driver, 'doc');
// Row '1' would be accepted (no collision on its own) before row '2'
// collides with row '3' — untouched by this batch, so it sits in
// `settled` — under the old `Promise.all` shape row '1' would have
// landed as a survivor before the second `update()` call threw.
const err = await refusalOf(() =>
driver.bulkUpdate('doc', [
{ id: '1', data: { doc_no: 'D-0100' } },
{ id: '2', data: { doc_no: 'D-0003' } }, // row '3's stored value — untouched by this batch
]),
);
expect(err.code).toBe('UNIQUE_VIOLATION');
expect(err.status).toBe(409);

const after = await snapshot(driver, 'doc');
expect(after).toEqual(before);
});

it('the FIRST row colliding refuses the batch too — the check is not order-dependent', async () => {
const before = await snapshot(driver, 'doc');
// Row '1' collides with STORED untouched row '3' on the very first
// iteration; row '2's patch is unrelated and would still land under a
// check that bailed out of the loop but had already pushed nothing.
const err = await refusalOf(() =>
driver.bulkUpdate('doc', [
{ id: '1', data: { doc_no: 'D-0003' } }, // row '3's stored value — collides immediately
{ id: '2', data: { doc_no: 'D-0300' } },
]),
);
expect(err.code).toBe('UNIQUE_VIOLATION');
const after = await snapshot(driver, 'doc');
expect(after).toEqual(before);
// The row AFTER the refusal must not land either — named directly, not
// just inferred from the full-snapshot equality above.
expect((await driver.find('doc', { where: { id: '2' } }))[0].doc_no).toBe('D-0002');
});

it('a clean batch still lands in FULL and returns every updated row', async () => {
// The non-vacuity control: a fix that refused everything would pass every
// assertion above.
const out = await driver.bulkUpdate('doc', [
{ id: '1', data: { doc_no: 'D-0100' } },
{ id: '2', data: { doc_no: 'D-0200' } },
]);
expect(out.map((r: any) => r.doc_no)).toEqual(['D-0100', 'D-0200']);
expect((await driver.find('doc', { where: { id: '1' } }))[0].doc_no).toBe('D-0100');
expect((await driver.find('doc', { where: { id: '2' } }))[0].doc_no).toBe('D-0200');
});

it('a row that keeps its OWN unique value (untouched field) does not collide with itself', async () => {
// exceptId must still exclude a row from its own pre-image when the patch
// does not touch the unique field.
const out = await driver.bulkUpdate('doc', [{ id: '1', data: { title: 'Renamed' } }]);
expect(out[0].doc_no).toBe('D-0001');
expect(out[0].title).toBe('Renamed');
});

it('an empty batch is a no-op that resolves to an empty array', async () => {
const before = await snapshot(driver, 'doc');
expect(await driver.bulkUpdate('doc', [])).toEqual([]);
expect(await snapshot(driver, 'doc')).toEqual(before);
});

describe('non-strict missing id (default `strictMode`)', () => {
it('a missing id is SKIPPED (no placeholder); the rest of the batch still lands', async () => {
// `IDataDriver.bulkUpdate` is declared `Promise<Record<string, unknown>[]>`
// — no `null` member — so a skipped id is OMITTED, not padded, mirroring
// `SqlDriver.bulkUpdate`'s own `if (updated) results.push(updated)`.
const out = await driver.bulkUpdate('doc', [
{ id: 'ghost', data: { doc_no: 'D-9999' } },
{ id: '1', data: { doc_no: 'D-0100' } },
]);
expect(out).toHaveLength(1);
expect(out[0].doc_no).toBe('D-0100');
expect((await driver.find('doc', { where: { id: '1' } }))[0].doc_no).toBe('D-0100');
});
});

describe('strictMode: true', () => {
it('a missing id refuses the WHOLE batch — table byte-identical, valid rows included', async () => {
const strict = new InMemoryDriver({ strictMode: true });
await strict.syncSchema('doc', DOC_SCHEMA);
await strict.create('doc', { id: '1', doc_no: 'D-0001', title: 'One' });
await strict.create('doc', { id: '2', doc_no: 'D-0002', title: 'Two' });
const before = await snapshot(strict, 'doc');

await expect(
strict.bulkUpdate('doc', [
{ id: '1', data: { doc_no: 'D-0100' } }, // would have been valid alone
{ id: 'ghost', data: { doc_no: 'D-9999' } },
]),
).rejects.toThrow();

expect(await snapshot(strict, 'doc')).toEqual(before);
});
});
});

describe('[#13435] bulkDelete refuses BEFORE writing — no surviving prefix', () => {
let driver: InMemoryDriver;

beforeEach(async () => {
driver = new InMemoryDriver({ strictMode: true });
await driver.syncSchema('doc', DOC_SCHEMA);
await driver.create('doc', { id: '1', doc_no: 'D-0001' });
await driver.create('doc', { id: '2', doc_no: 'D-0002' });
await driver.create('doc', { id: '3', doc_no: 'D-0003' });
});

it('strictMode: a missing id refuses the WHOLE batch — table byte-identical, valid ids included', async () => {
const before = await snapshot(driver, 'doc');

await expect(driver.bulkDelete('doc', ['1', 'ghost', '2'])).rejects.toThrow();

// Id '1' would have been removed BEFORE the refusal under the old
// `Promise.all` shape. Named explicitly, not just via count.
const after = await snapshot(driver, 'doc');
expect(after).toEqual(before);
expect(await driver.count('doc')).toBe(3);
});

it('strictMode: a clean batch still removes every named row', async () => {
await driver.bulkDelete('doc', ['1', '3']);
expect(await driver.count('doc')).toBe(1);
expect((await driver.find('doc', { where: {} }))[0].id).toBe('2');
});

describe('non-strict (default `strictMode`)', () => {
it('a missing id is SKIPPED; the rest of the batch still lands', async () => {
const loose = new InMemoryDriver();
await loose.syncSchema('doc', DOC_SCHEMA);
await loose.create('doc', { id: '1', doc_no: 'D-0001' });
await loose.create('doc', { id: '2', doc_no: 'D-0002' });

await loose.bulkDelete('doc', ['1', 'ghost']);
expect(await loose.count('doc')).toBe(1);
expect((await loose.find('doc', { where: {} }))[0].id).toBe('2');
});
});

it('an empty batch is a no-op', async () => {
const before = await snapshot(driver, 'doc');
await driver.bulkDelete('doc', []);
expect(await snapshot(driver, 'doc')).toEqual(before);
});

it('duplicate ids in one batch delete the row once, not twice', async () => {
await driver.bulkDelete('doc', ['1', '1']);
expect(await driver.count('doc')).toBe(2);
});
});

describe('[#13435] all FOUR batch doors of this driver now agree that a batch is atomic', () => {
it('bulkCreate, updateMany, bulkUpdate and bulkDelete all refuse without moving the table', async () => {
const driver = new InMemoryDriver({ strictMode: true });
await driver.syncSchema('doc', DOC_SCHEMA);
await driver.create('doc', { id: '1', doc_no: 'D-0001' });
await driver.create('doc', { id: '2', doc_no: 'D-0002' });
const before = await snapshot(driver, 'doc');

const createErr = await refusalOf(() =>
driver.bulkCreate('doc', [
{ id: 'a', doc_no: 'D-0100' },
{ id: 'b', doc_no: 'D-0100' },
]),
);
const updateManyErr = await refusalOf(() => driver.updateMany('doc', { where: {} }, { doc_no: 'D-0009' }));
const bulkUpdateErr = await refusalOf(() =>
driver.bulkUpdate('doc', [
{ id: '1', data: { doc_no: 'D-0100' } },
{ id: '2', data: { doc_no: 'D-0100' } }, // collides with the row above, not the table
]),
);
const bulkDeleteErr = await refusalOf(() => driver.bulkDelete('doc', ['1', 'ghost']));

expect(createErr.code).toBe('UNIQUE_VIOLATION');
expect(updateManyErr.code).toBe('UNIQUE_VIOLATION');
expect(bulkUpdateErr.code).toBe('UNIQUE_VIOLATION');
expect(bulkDeleteErr).toBeInstanceOf(Error);

// None of the four doors moved the store.
expect(await snapshot(driver, 'doc')).toEqual(before);
});
});

describe('[#13435] non-regression — updateMany and bulkCreate still behave as #13197/#13340 left them', () => {
let driver: InMemoryDriver;

beforeEach(async () => {
driver = new InMemoryDriver();
await driver.syncSchema('doc', DOC_SCHEMA);
await driver.create('doc', { id: '1', doc_no: 'D-0001' });
await driver.create('doc', { id: '2', doc_no: 'D-0002' });
});

it('updateMany still refuses a colliding shared patch and leaves the table untouched', async () => {
const before = await snapshot(driver, 'doc');
const err = await refusalOf(() => driver.updateMany('doc', { where: {} }, { doc_no: 'D-SAME' }));
expect(err.code).toBe('UNIQUE_VIOLATION');
expect(await snapshot(driver, 'doc')).toEqual(before);
});

it('updateMany still applies a clean shared patch to every matched row', async () => {
const count = await driver.updateMany('doc', { where: {} }, { title: 'Bulk' });
expect(count).toBe(2);
expect((await driver.find('doc', { where: { id: '1' } }))[0].title).toBe('Bulk');
expect((await driver.find('doc', { where: { id: '2' } }))[0].title).toBe('Bulk');
});

it('bulkCreate still refuses a self-colliding batch and leaves the table untouched', async () => {
const before = await snapshot(driver, 'doc');
const err = await refusalOf(() =>
driver.bulkCreate('doc', [
{ id: 'a', doc_no: 'D-0100' },
{ id: 'b', doc_no: 'D-0100' },
]),
);
expect(err.code).toBe('UNIQUE_VIOLATION');
expect(await snapshot(driver, 'doc')).toEqual(before);
});

it('bulkCreate still lands a clean batch in full', async () => {
const out = await driver.bulkCreate('doc', [{ id: 'a', doc_no: 'D-0100' }]);
expect(out).toHaveLength(1);
expect(await driver.count('doc')).toBe(3);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); fix(driver-memory): make bulkUpdate and bulkDelete all-or-nothing by zhuangjianguo · Pull Request #13875 · objectstack-ai/objectstack · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .changeset/driver-memory-bulk-update-delete-atomicity.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
---
"@objectstack/driver-memory": patch
---

fix(driver-memory): make `bulkUpdate` and `bulkDelete` all-or-nothing, so a refused row leaves the table exactly as it found it (#13435)

`bulkUpdate` and `bulkDelete` were still `Promise.all(map(...))` over
`update`/`delete`, and both of those write into the table synchronously. So a
mid-batch refusal — a `UNIQUE_VIOLATION`/409 on `bulkUpdate`, a missing-id
throw under `strictMode` on `bulkDelete` — left every row processed *before*
it already mutated, and the caller got a rejection describing a batch that
had partly landed. #13340 fixed the identical shape on `bulkCreate`; this is
the third and fourth batch door.

`bulkUpdate` now builds and checks every pending row's post-image — each id
keeps its own patch, so this is new construction (a projected row set per
pending row, generalized from `updateMany`'s single-shared-patch posture)
rather than a copy of either sibling door — before writing any of them.
`bulkDelete` resolves every id to a table index first, refusing the whole
batch under `strictMode` before touching the table if one is missing, and
only then splices. Both follow `update`/`delete`'s own existing contract for
a missing id (skip under non-strict, refuse under strict) rather than a third
posture. `bulkDelete` still returns `void` — no current caller reads a
per-row outcome, so widening the return type stays out of scope.

All four batch doors of `InMemoryDriver` (`bulkCreate`, `updateMany`,
`bulkUpdate`, `bulkDelete`) now agree that a batch is atomic.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#13435] `bulkUpdate` and `bulkDelete` are ALL-OR-NOTHING: a refused row
* leaves the table exactly as it found it.
*
* ## The defect this pins, and why "it still throws" would not have caught it
*
* Both doors used to be `Promise.all(map(...))` over `update`/`delete`, and
* both of THOSE write into the table synchronously. So when one row of a
* batch was refused — `UNIQUE_VIOLATION`/409 on `bulkUpdate`, a missing-id
* throw under `strictMode` on `bulkDelete` — every row processed BEFORE it
* stayed mutated, and the caller got a rejection describing a batch that had
* partly landed. #13340 measured the identical shape on `bulkCreate`; these
* are the third and fourth batch doors it did not reach.
*
* ⛔ Asserting "the refusal still happens" proves NOTHING here — the refusal
* was already correct (#13197/#13239 pin `assertUnique`, `delete`'s own
* strict-mode throw predates this file). **The discriminating fact is that
* the TABLE DOES NOT MOVE**, so every test below reads the store back after
* the refusal — full rows, not just a count — rather than stopping at the
* envelope.
*
* ## The construction, and why it is NOT `updateMany`'s shape copied over
*
* `updateMany` stamps ONE shared `data` onto every matched row and has no
* per-row pre-image to exclude. `bulkCreate` has no pre-image at all (every
* row is new). `bulkUpdate` is neither: each id in the batch carries its OWN
* patch, so the fix needed new construction — per pending row, its own
* `exceptId` AND a projected row set holding the OTHER rows' post-images
* while dropping their pre-images — not a transcription of either sibling.
*
* ## What this does NOT claim
*
* Atomicity here is the driver refusing before it writes — not a
* transaction, and no rollback: nothing is written until the whole batch has
* been checked (`bulkUpdate`) or resolved to indices (`bulkDelete`).
*/

import { describe, it, expect, beforeEach } from 'vitest';
import { InMemoryDriver } from './memory-driver.js';

interface WireBearingError extends Error {
code?: string;
status?: number;
}

/** Run `fn`, requiring it to reject; hand back the rejection for inspection. */
async function refusalOf(fn: () => Promise<unknown>): Promise<WireBearingError> {
try {
await fn();
} catch (e) {
return e as WireBearingError;
}
throw new Error('expected the driver to refuse this write, but it resolved');
}

const DOC_SCHEMA = {
name: 'doc',
fields: {
id: { type: 'text' },
doc_no: { type: 'text', unique: 'global' },
title: { type: 'text' },
},
} as any;

/** The whole table, sorted by `id` — used to prove BYTE-IDENTITY, not just a count. */
async function snapshot(driver: InMemoryDriver, object: string) {
const rows = await driver.find(object, {});
return rows.slice().sort((a: any, b: any) => String(a.id).localeCompare(String(b.id)));
}

describe('[#13435] bulkUpdate refuses BEFORE writing — no surviving prefix', () => {
let driver: InMemoryDriver;

beforeEach(async () => {
driver = new InMemoryDriver();
await driver.syncSchema('doc', DOC_SCHEMA);
await driver.create('doc', { id: '1', doc_no: 'D-0001', title: 'One' });
await driver.create('doc', { id: '2', doc_no: 'D-0002', title: 'Two' });
await driver.create('doc', { id: '3', doc_no: 'D-0003', title: 'Three' });
});

it('a batch colliding WITHIN itself leaves the table byte-identical', async () => {
const before = await snapshot(driver, 'doc');

const err = await refusalOf(() =>
driver.bulkUpdate('doc', [
{ id: '1', data: { doc_no: 'D-0100' } },
{ id: '2', data: { doc_no: 'D-0100' } }, // collides with the row above, not with the table
]),
);
expect(err.code).toBe('UNIQUE_VIOLATION');
expect(err.status).toBe(409);

// Named explicitly: it is row '1' — accepted BEFORE the refusal under the
// old `Promise.all` shape — whose survival used to be the defect.
const after = await snapshot(driver, 'doc');
expect(after).toEqual(before);
expect((await driver.find('doc', { where: { id: '1' } }))[0].doc_no).toBe('D-0001');
});

it('a batch colliding with a STORED, UNTOUCHED row leaves the table byte-identical', async () => {
const before = await snapshot(driver, 'doc');
// Row '1' would be accepted (no collision on its own) before row '2'
// collides with row '3' — untouched by this batch, so it sits in
// `settled` — under the old `Promise.all` shape row '1' would have
// landed as a survivor before the second `update()` call threw.
const err = await refusalOf(() =>
driver.bulkUpdate('doc', [
{ id: '1', data: { doc_no: 'D-0100' } },
{ id: '2', data: { doc_no: 'D-0003' } }, // row '3's stored value — untouched by this batch
]),
);
expect(err.code).toBe('UNIQUE_VIOLATION');
expect(err.status).toBe(409);

const after = await snapshot(driver, 'doc');
expect(after).toEqual(before);
});

it('the FIRST row colliding refuses the batch too — the check is not order-dependent', async () => {
const before = await snapshot(driver, 'doc');
// Row '1' collides with STORED untouched row '3' on the very first
// iteration; row '2's patch is unrelated and would still land under a
// check that bailed out of the loop but had already pushed nothing.
const err = await refusalOf(() =>
driver.bulkUpdate('doc', [
{ id: '1', data: { doc_no: 'D-0003' } }, // row '3's stored value — collides immediately
{ id: '2', data: { doc_no: 'D-0300' } },
]),
);
expect(err.code).toBe('UNIQUE_VIOLATION');
const after = await snapshot(driver, 'doc');
expect(after).toEqual(before);
// The row AFTER the refusal must not land either — named directly, not
// just inferred from the full-snapshot equality above.
expect((await driver.find('doc', { where: { id: '2' } }))[0].doc_no).toBe('D-0002');
});

it('a clean batch still lands in FULL and returns every updated row', async () => {
// The non-vacuity control: a fix that refused everything would pass every
// assertion above.
const out = await driver.bulkUpdate('doc', [
{ id: '1', data: { doc_no: 'D-0100' } },
{ id: '2', data: { doc_no: 'D-0200' } },
]);
expect(out.map((r: any) => r.doc_no)).toEqual(['D-0100', 'D-0200']);
expect((await driver.find('doc', { where: { id: '1' } }))[0].doc_no).toBe('D-0100');
expect((await driver.find('doc', { where: { id: '2' } }))[0].doc_no).toBe('D-0200');
});

it('a row that keeps its OWN unique value (untouched field) does not collide with itself', async () => {
// exceptId must still exclude a row from its own pre-image when the patch
// does not touch the unique field.
const out = await driver.bulkUpdate('doc', [{ id: '1', data: { title: 'Renamed' } }]);
expect(out[0].doc_no).toBe('D-0001');
expect(out[0].title).toBe('Renamed');
});

it('an empty batch is a no-op that resolves to an empty array', async () => {
const before = await snapshot(driver, 'doc');
expect(await driver.bulkUpdate('doc', [])).toEqual([]);
expect(await snapshot(driver, 'doc')).toEqual(before);
});

describe('non-strict missing id (default `strictMode`)', () => {
it('a missing id is SKIPPED (no placeholder); the rest of the batch still lands', async () => {
// `IDataDriver.bulkUpdate` is declared `Promise<Record<string, unknown>[]>`
// — no `null` member — so a skipped id is OMITTED, not padded, mirroring
// `SqlDriver.bulkUpdate`'s own `if (updated) results.push(updated)`.
const out = await driver.bulkUpdate('doc', [
{ id: 'ghost', data: { doc_no: 'D-9999' } },
{ id: '1', data: { doc_no: 'D-0100' } },
]);
expect(out).toHaveLength(1);
expect(out[0].doc_no).toBe('D-0100');
expect((await driver.find('doc', { where: { id: '1' } }))[0].doc_no).toBe('D-0100');
});
});

describe('strictMode: true', () => {
it('a missing id refuses the WHOLE batch — table byte-identical, valid rows included', async () => {
const strict = new InMemoryDriver({ strictMode: true });
await strict.syncSchema('doc', DOC_SCHEMA);
await strict.create('doc', { id: '1', doc_no: 'D-0001', title: 'One' });
await strict.create('doc', { id: '2', doc_no: 'D-0002', title: 'Two' });
const before = await snapshot(strict, 'doc');

await expect(
strict.bulkUpdate('doc', [
{ id: '1', data: { doc_no: 'D-0100' } }, // would have been valid alone
{ id: 'ghost', data: { doc_no: 'D-9999' } },
]),
).rejects.toThrow();

expect(await snapshot(strict, 'doc')).toEqual(before);
});
});
});

describe('[#13435] bulkDelete refuses BEFORE writing — no surviving prefix', () => {
let driver: InMemoryDriver;

beforeEach(async () => {
driver = new InMemoryDriver({ strictMode: true });
await driver.syncSchema('doc', DOC_SCHEMA);
await driver.create('doc', { id: '1', doc_no: 'D-0001' });
await driver.create('doc', { id: '2', doc_no: 'D-0002' });
await driver.create('doc', { id: '3', doc_no: 'D-0003' });
});

it('strictMode: a missing id refuses the WHOLE batch — table byte-identical, valid ids included', async () => {
const before = await snapshot(driver, 'doc');

await expect(driver.bulkDelete('doc', ['1', 'ghost', '2'])).rejects.toThrow();

// Id '1' would have been removed BEFORE the refusal under the old
// `Promise.all` shape. Named explicitly, not just via count.
const after = await snapshot(driver, 'doc');
expect(after).toEqual(before);
expect(await driver.count('doc')).toBe(3);
});

it('strictMode: a clean batch still removes every named row', async () => {
await driver.bulkDelete('doc', ['1', '3']);
expect(await driver.count('doc')).toBe(1);
expect((await driver.find('doc', { where: {} }))[0].id).toBe('2');
});

describe('non-strict (default `strictMode`)', () => {
it('a missing id is SKIPPED; the rest of the batch still lands', async () => {
const loose = new InMemoryDriver();
await loose.syncSchema('doc', DOC_SCHEMA);
await loose.create('doc', { id: '1', doc_no: 'D-0001' });
await loose.create('doc', { id: '2', doc_no: 'D-0002' });

await loose.bulkDelete('doc', ['1', 'ghost']);
expect(await loose.count('doc')).toBe(1);
expect((await loose.find('doc', { where: {} }))[0].id).toBe('2');
});
});

it('an empty batch is a no-op', async () => {
const before = await snapshot(driver, 'doc');
await driver.bulkDelete('doc', []);
expect(await snapshot(driver, 'doc')).toEqual(before);
});

it('duplicate ids in one batch delete the row once, not twice', async () => {
await driver.bulkDelete('doc', ['1', '1']);
expect(await driver.count('doc')).toBe(2);
});
});

describe('[#13435] all FOUR batch doors of this driver now agree that a batch is atomic', () => {
it('bulkCreate, updateMany, bulkUpdate and bulkDelete all refuse without moving the table', async () => {
const driver = new InMemoryDriver({ strictMode: true });
await driver.syncSchema('doc', DOC_SCHEMA);
await driver.create('doc', { id: '1', doc_no: 'D-0001' });
await driver.create('doc', { id: '2', doc_no: 'D-0002' });
const before = await snapshot(driver, 'doc');

const createErr = await refusalOf(() =>
driver.bulkCreate('doc', [
{ id: 'a', doc_no: 'D-0100' },
{ id: 'b', doc_no: 'D-0100' },
]),
);
const updateManyErr = await refusalOf(() => driver.updateMany('doc', { where: {} }, { doc_no: 'D-0009' }));
const bulkUpdateErr = await refusalOf(() =>
driver.bulkUpdate('doc', [
{ id: '1', data: { doc_no: 'D-0100' } },
{ id: '2', data: { doc_no: 'D-0100' } }, // collides with the row above, not the table
]),
);
const bulkDeleteErr = await refusalOf(() => driver.bulkDelete('doc', ['1', 'ghost']));

expect(createErr.code).toBe('UNIQUE_VIOLATION');
expect(updateManyErr.code).toBe('UNIQUE_VIOLATION');
expect(bulkUpdateErr.code).toBe('UNIQUE_VIOLATION');
expect(bulkDeleteErr).toBeInstanceOf(Error);

// None of the four doors moved the store.
expect(await snapshot(driver, 'doc')).toEqual(before);
});
});

describe('[#13435] non-regression — updateMany and bulkCreate still behave as #13197/#13340 left them', () => {
let driver: InMemoryDriver;

beforeEach(async () => {
driver = new InMemoryDriver();
await driver.syncSchema('doc', DOC_SCHEMA);
await driver.create('doc', { id: '1', doc_no: 'D-0001' });
await driver.create('doc', { id: '2', doc_no: 'D-0002' });
});

it('updateMany still refuses a colliding shared patch and leaves the table untouched', async () => {
const before = await snapshot(driver, 'doc');
const err = await refusalOf(() => driver.updateMany('doc', { where: {} }, { doc_no: 'D-SAME' }));
expect(err.code).toBe('UNIQUE_VIOLATION');
expect(await snapshot(driver, 'doc')).toEqual(before);
});

it('updateMany still applies a clean shared patch to every matched row', async () => {
const count = await driver.updateMany('doc', { where: {} }, { title: 'Bulk' });
expect(count).toBe(2);
expect((await driver.find('doc', { where: { id: '1' } }))[0].title).toBe('Bulk');
expect((await driver.find('doc', { where: { id: '2' } }))[0].title).toBe('Bulk');
});

it('bulkCreate still refuses a self-colliding batch and leaves the table untouched', async () => {
const before = await snapshot(driver, 'doc');
const err = await refusalOf(() =>
driver.bulkCreate('doc', [
{ id: 'a', doc_no: 'D-0100' },
{ id: 'b', doc_no: 'D-0100' },
]),
);
expect(err.code).toBe('UNIQUE_VIOLATION');
expect(await snapshot(driver, 'doc')).toEqual(before);
});

it('bulkCreate still lands a clean batch in full', async () => {
const out = await driver.bulkCreate('doc', [{ id: 'a', doc_no: 'D-0100' }]);
expect(out).toHaveLength(1);
expect(await driver.count('doc')).toBe(3);
});
});
Loading
Loading