From d314cd9c6b62d45f712d9c62ad202a23cd1b6368 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 5 May 2026 12:20:20 +0000 Subject: [PATCH 1/6] prevent writes to RxCollection while a schema migration is running Outside writes that race with a running migration could conflict with the migration replication, which surfaced as confusing RC_PUSH errors. Block inserts/upserts/removes and RxDocument modifications via a new ensureRxCollectionIsNotMigrating guard (COL25) while migrationInProgress is true on the collection. --- docs-src/docs/migration-schema.md | 10 ++ src/plugins/attachments/index.ts | 3 + src/plugins/dev-mode/error-messages.ts | 6 ++ .../migration-schema/rx-migration-state.ts | 20 +++- src/rx-collection-helper.ts | 20 ++++ src/rx-collection.ts | 16 ++- src/rx-document.ts | 3 + test/unit/migration-schema.test.ts | 100 ++++++++++++++++++ 8 files changed, 174 insertions(+), 4 deletions(-) diff --git a/docs-src/docs/migration-schema.md b/docs-src/docs/migration-schema.md index 8248c1a37b0..66cb7737cb7 100644 --- a/docs-src/docs/migration-schema.md +++ b/docs-src/docs/migration-schema.md @@ -107,6 +107,16 @@ myDatabase.addCollections({ By default, the migration automatically happens when the collection is created. Calling `RxDatabase.addCollections()` returns only when the migration has finished. If you have lots of data or the migrationStrategies take a long time, it might be better to start the migration 'by hand' and show the migration-state to the user as a loading-bar. +:::warning No writes during a running migration +While a schema migration is running on a collection, writes to that collection are not allowed. +This includes `insert()`, `bulkInsert()`, `upsert()`, `bulkUpsert()`, `incrementalUpsert()`, `bulkRemove()`, +RxDocument operations like `patch()`, `modify()`, `incrementalPatch()`, `incrementalModify()`, `remove()`, +and attachment writes via `putAttachment()`/`putAttachments()`/`RxAttachment.remove()`. +Calls to these methods will throw a `COL25` error until the migration finishes. +Wait for `collection.migratePromise()` to resolve (or observe `collection.getMigrationState().$` until status is `DONE`) +before performing writes. +::: + ```javascript const messageCol = await myDatabase.addCollections({ messages: { diff --git a/src/plugins/attachments/index.ts b/src/plugins/attachments/index.ts index 003be6bf47e..786352b870c 100644 --- a/src/plugins/attachments/index.ts +++ b/src/plugins/attachments/index.ts @@ -26,6 +26,7 @@ import { assignMethodsToAttachment, ensureSchemaSupportsAttachments } from './attachments-utils.ts'; +import { ensureRxCollectionIsNotMigrating } from '../../rx-collection-helper.ts'; @@ -56,6 +57,7 @@ export class RxAttachment { } remove(): Promise { + ensureRxCollectionIsNotMigrating(this.doc.collection); return this.doc.collection.incrementalWriteQueue.addWrite( this.doc._data, docWriteData => { @@ -113,6 +115,7 @@ async function _putAttachmentsImpl( attachments: RxAttachmentCreator[] ): Promise { ensureSchemaSupportsAttachments(doc); + ensureRxCollectionIsNotMigrating(doc.collection); if (attachments.length === 0) { return []; diff --git a/src/plugins/dev-mode/error-messages.ts b/src/plugins/dev-mode/error-messages.ts index 90ce850f5de..e4a94c8a862 100644 --- a/src/plugins/dev-mode/error-messages.ts +++ b/src/plugins/dev-mode/error-messages.ts @@ -461,6 +461,12 @@ export const ERROR_MESSAGES = { fix: 'Reduce the number of open collections or upgrade to premium.', docs: 'https://rxdb.info/premium.html?console=errors&code=COL23' }, + COL25: { + message: 'Cannot write to RxCollection while a schema migration is running. Wait for the migration to finish before writing.', + cause: 'You tried to insert/upsert/remove or modify a document while the collection is in the middle of a schema migration.', + fix: 'Await the migration (e.g. via collection.migratePromise() or by observing collection.getMigrationState().$) before writing.', + docs: 'https://rxdb.info/migration-schema.html?console=errors&code=COL25' + }, COL24: { message: 'inline _attachments must be an array of { id, type, data } objects; the map format is reserved for internal use only', cause: 'An object was passed as _attachments that is neither an array of attachment creators nor a fully-normalized internal map.', diff --git a/src/plugins/migration-schema/rx-migration-state.ts b/src/plugins/migration-schema/rx-migration-state.ts index 6b346f5c8ba..de3a69adef5 100644 --- a/src/plugins/migration-schema/rx-migration-state.ts +++ b/src/plugins/migration-schema/rx-migration-state.ts @@ -134,13 +134,24 @@ export class RxMigrationState { * is run on a different browser tab. */ async startMigration(batchSize: number = MIGRATION_DEFAULT_BATCH_SIZE): Promise { + if (this.started) { + throw newRxError('DM1'); + } + /** + * Block outside writes to the collection while the migration is running. + * The migration replication fills the new storage and concurrent writes + * could conflict with that process. + * We set the flag synchronously (before the `mustMigrate` await) so that + * any code calling `migratePromise()` and then immediately performing a + * write will reliably observe the block. + * If no migration is actually needed, the flag is cleared again below. + */ + this.collection.migrationInProgress = true; const must = await this.mustMigrate; if (!must) { + this.collection.migrationInProgress = false; return; } - if (this.started) { - throw newRxError('DM1'); - } this.started = true; @@ -228,6 +239,7 @@ export class RxMigrationState { ); } catch (err) { await oldStorageInstance.close(); + this.collection.migrationInProgress = false; await this.updateStatus(s => { s.status = 'ERROR'; s.error = errorToPlainJson(err as Error); @@ -276,6 +288,7 @@ export class RxMigrationState { } } + this.collection.migrationInProgress = false; await this.updateStatus(s => { s.status = 'DONE'; return s; @@ -503,6 +516,7 @@ export class RxMigrationState { */ public async cancel() { this.canceled = true; + this.collection.migrationInProgress = false; await Promise.all( Array.from(this.replicationStates.values()) .map(state => cancelRxStorageReplication(state)) diff --git a/src/rx-collection-helper.ts b/src/rx-collection-helper.ts index 15234c6c2bb..9b212aaf43d 100644 --- a/src/rx-collection-helper.ts +++ b/src/rx-collection-helper.ts @@ -266,3 +266,23 @@ export function ensureRxCollectionIsNotClosed( ); } } + +/** + * Throws if a schema migration is currently running on the collection. + * Writes are not allowed during a migration because the new collection + * is being filled by the migration replication and outside writes + * could conflict with that process. + */ +export function ensureRxCollectionIsNotMigrating( + collection: RxCollection | RxCollectionBase +) { + if (collection.migrationInProgress) { + throw newRxError( + 'COL25', + { + collection: collection.name, + version: collection.schema.version + } + ); + } +} diff --git a/src/rx-collection.ts b/src/rx-collection.ts index 0fff34388b1..6e83022f68b 100644 --- a/src/rx-collection.ts +++ b/src/rx-collection.ts @@ -21,7 +21,8 @@ import { normalizeInlineAttachments, createRxCollectionStorageInstance, removeCollectionStorages, - ensureRxCollectionIsNotClosed + ensureRxCollectionIsNotClosed, + ensureRxCollectionIsNotMigrating } from './rx-collection-helper.ts'; import { createRxQuery, @@ -229,6 +230,13 @@ export class RxCollectionBase< public onClose: (() => MaybePromise)[] = []; public closed = false; + /** + * Set to true while a schema migration is running for this collection. + * Writes are blocked while this is true to ensure the migration + * replication can fill the new storage without external interference. + */ + public migrationInProgress = false; + public onRemove: (() => MaybePromise)[] = []; public async prepare(): Promise { @@ -379,6 +387,7 @@ export class RxCollectionBase< json: RxDocumentType | RxDocument ): Promise> { ensureRxCollectionIsNotClosed(this); + ensureRxCollectionIsNotMigrating(this); const writeResult = await this.bulkInsert([json as any]); const isError = writeResult.error[0]; @@ -411,6 +420,7 @@ export class RxCollectionBase< error: RxStorageWriteError[]; }> { ensureRxCollectionIsNotClosed(this); + ensureRxCollectionIsNotMigrating(this); /** * Optimization shortcut, * do nothing when called with an empty array @@ -560,6 +570,7 @@ export class RxCollectionBase< error: RxStorageWriteError[]; }> { ensureRxCollectionIsNotClosed(this); + ensureRxCollectionIsNotMigrating(this); const primaryPath = this.schema.primaryPath; /** * Optimization shortcut, @@ -649,6 +660,7 @@ export class RxCollectionBase< error: RxStorageWriteError[]; }> { ensureRxCollectionIsNotClosed(this); + ensureRxCollectionIsNotMigrating(this); const insertData: RxDocumentType[] = []; const useJsonByDocId: Map = new Map(); @@ -739,6 +751,7 @@ export class RxCollectionBase< */ async upsert(json: Partial, options?: UpsertOptions): Promise> { ensureRxCollectionIsNotClosed(this); + ensureRxCollectionIsNotMigrating(this); const bulkResult = await this.bulkUpsert([json], options); throwIfIsStorageWriteError( this.asRxCollection, @@ -754,6 +767,7 @@ export class RxCollectionBase< */ incrementalUpsert(json: Partial, options?: UpsertOptions): Promise> { ensureRxCollectionIsNotClosed(this); + ensureRxCollectionIsNotMigrating(this); const useJson = fillObjectDataBeforeInsert(this.schema, json); const primary: string = useJson[this.schema.primaryPath] as any; if (!primary) { diff --git a/src/rx-document.ts b/src/rx-document.ts index b92a6c3f5f5..f260c018469 100644 --- a/src/rx-document.ts +++ b/src/rx-document.ts @@ -42,6 +42,7 @@ import { overwritable } from './overwritable.ts'; import { getSchemaByObjectPath } from './rx-schema-helper.ts'; import { getWrittenDocumentsFromBulkWriteResponse, throwIfIsStorageWriteError } from './rx-storage-helper.ts'; import { modifierFromPublicToInternal } from './incremental-write.ts'; +import { ensureRxCollectionIsNotMigrating } from './rx-collection-helper.ts'; export const basePrototype = { get primaryPath() { @@ -325,6 +326,7 @@ export const basePrototype = { // used by some plugins that wrap the method _context?: string ): Promise { + ensureRxCollectionIsNotMigrating(this.collection); return this.collection.incrementalWriteQueue.addWrite( this._data, modifierFromPublicToInternal(mutationFunction) @@ -371,6 +373,7 @@ export const basePrototype = { newData: RxDocumentWriteData, oldData: RxDocumentData ): Promise> { + ensureRxCollectionIsNotMigrating(this.collection); newData = flatClone(newData); // deleted documents cannot be changed diff --git a/test/unit/migration-schema.test.ts b/test/unit/migration-schema.test.ts index d9025a0319e..740db4b4a24 100644 --- a/test/unit/migration-schema.test.ts +++ b/test/unit/migration-schema.test.ts @@ -418,6 +418,106 @@ describe('migration-schema.test.ts', function () { }); }); }); + describe('writes during migration', () => { + it('should block writes while a migration is running and allow them again after it finishes', async () => { + const col = await humansCollection.createMigrationCollection( + isFastMode() ? 3 : 10, + { + 3: async (doc: any) => { + await promiseWait(20); + doc.age = parseInt(doc.age, 10); + return doc; + } + } + ); + + const migrationDone = col.migratePromise(1); + // wait until the migration flag is actually set + await waitUntil(() => (col as any).migrationInProgress === true); + + await assertThrows( + () => col.insert(schemaObjects.simpleHumanAge() as any), + 'RxError', + 'COL25' + ); + await assertThrows( + () => col.bulkInsert([schemaObjects.simpleHumanAge() as any]), + 'RxError', + 'COL25' + ); + await assertThrows( + () => col.upsert(schemaObjects.simpleHumanAge() as any), + 'RxError', + 'COL25' + ); + await assertThrows( + () => col.bulkUpsert([schemaObjects.simpleHumanAge() as any]), + 'RxError', + 'COL25' + ); + await assertThrows( + () => col.incrementalUpsert(schemaObjects.simpleHumanAge() as any), + 'RxError', + 'COL25' + ); + await assertThrows( + () => col.bulkRemove(['nonexistent']), + 'RxError', + 'COL25' + ); + + // reads must still work + await col.find().exec(); + + await migrationDone; + assert.strictEqual((col as any).migrationInProgress, false); + + // now writes work again + await col.insert(schemaObjects.simpleHumanAge() as any); + + await col.database.close(); + }); + + // Previously a bulkInsert called directly after migratePromise + // could race with the migration replication and surface as a + // confusing RC_PUSH error. Now the write must fail fast with COL25. + it('should block bulkInsert that races with a starting migration (no waitUntil)', async () => { + const col = await humansCollection.createMigrationCollection( + isFastMode() ? 3 : 10, + { + 3: async (doc: any) => { + await promiseWait(20); + doc.age = parseInt(doc.age, 10); + return doc; + } + } + ); + + const migrationDone = col.migratePromise(1); + + await assertThrows( + () => col.bulkInsert([schemaObjects.simpleHumanAge() as any]), + 'RxError', + 'COL25' + ); + + await migrationDone; + await col.database.close(); + }); + + it('should reset migrationInProgress on migration error', async () => { + const col = await humansCollection.createMigrationCollection(3, { + 3: () => { + throw new Error('migration-failed-on-purpose'); + } + }); + let failed = false; + await col.migratePromise().catch(() => failed = true); + assert.ok(failed); + assert.strictEqual((col as any).migrationInProgress, false); + await col.database.close(); + }); + }); }); describeParallel('integration into collection', () => { describe('run', () => { From 240d58f4e05e42aa8b8ca84e57edfcca1a0a5560 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 5 May 2026 13:20:50 +0000 Subject: [PATCH 2/6] docs: simplify migration write-block warning --- docs-src/docs/migration-schema.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/docs-src/docs/migration-schema.md b/docs-src/docs/migration-schema.md index 66cb7737cb7..df2c3a19184 100644 --- a/docs-src/docs/migration-schema.md +++ b/docs-src/docs/migration-schema.md @@ -109,10 +109,7 @@ If you have lots of data or the migrationStrategies take a long time, it might b :::warning No writes during a running migration While a schema migration is running on a collection, writes to that collection are not allowed. -This includes `insert()`, `bulkInsert()`, `upsert()`, `bulkUpsert()`, `incrementalUpsert()`, `bulkRemove()`, -RxDocument operations like `patch()`, `modify()`, `incrementalPatch()`, `incrementalModify()`, `remove()`, -and attachment writes via `putAttachment()`/`putAttachments()`/`RxAttachment.remove()`. -Calls to these methods will throw a `COL25` error until the migration finishes. +Calls that would write will throw a `COL25` error until the migration finishes. Wait for `collection.migratePromise()` to resolve (or observe `collection.getMigrationState().$` until status is `DONE`) before performing writes. ::: From 4bb9696b5a7f8033cae2141853383e4ec7a32ae1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 5 May 2026 13:27:26 +0000 Subject: [PATCH 3/6] consolidate write checks into isWriteAllowed and block writes when migration is needed - Replace ensureRxCollectionIsNotClosed + ensureRxCollectionIsNotMigrating pairs at write call sites with a single isWriteAllowed helper that asserts both conditions. - Set migrationInProgress eagerly in the migration plugin's createRxCollection hook for schemas with version > 0, so writes are refused with COL25 even before startMigration() has been called. Cleared once migrationNeeded() resolves false or once the migration finishes/errors/cancels. --- src/plugins/attachments/index.ts | 6 +++--- src/plugins/migration-schema/index.ts | 25 ++++++++++++++++++++++++ src/rx-collection-helper.ts | 19 +++++++++++++----- src/rx-collection.ts | 20 +++++++------------ src/rx-document.ts | 6 +++--- test/unit/migration-schema.test.ts | 28 +++++++++++++++++++++++++++ 6 files changed, 80 insertions(+), 24 deletions(-) diff --git a/src/plugins/attachments/index.ts b/src/plugins/attachments/index.ts index 786352b870c..9c1a5e2dd49 100644 --- a/src/plugins/attachments/index.ts +++ b/src/plugins/attachments/index.ts @@ -26,7 +26,7 @@ import { assignMethodsToAttachment, ensureSchemaSupportsAttachments } from './attachments-utils.ts'; -import { ensureRxCollectionIsNotMigrating } from '../../rx-collection-helper.ts'; +import { isWriteAllowed } from '../../rx-collection-helper.ts'; @@ -57,7 +57,7 @@ export class RxAttachment { } remove(): Promise { - ensureRxCollectionIsNotMigrating(this.doc.collection); + isWriteAllowed(this.doc.collection); return this.doc.collection.incrementalWriteQueue.addWrite( this.doc._data, docWriteData => { @@ -115,7 +115,7 @@ async function _putAttachmentsImpl( attachments: RxAttachmentCreator[] ): Promise { ensureSchemaSupportsAttachments(doc); - ensureRxCollectionIsNotMigrating(doc.collection); + isWriteAllowed(doc.collection); if (attachments.length === 0) { return []; diff --git a/src/plugins/migration-schema/index.ts b/src/plugins/migration-schema/index.ts index 51fec716740..369e8e58284 100644 --- a/src/plugins/migration-schema/index.ts +++ b/src/plugins/migration-schema/index.ts @@ -36,6 +36,31 @@ export const RxDBMigrationPlugin: RxPlugin = { hooks: { preCloseRxDatabase: { after: onDatabaseClose + }, + /** + * Block writes to the new collection while a migration is pending. + * For schemas with version > 0 we optimistically set the flag, then + * release it once we know no migration is actually needed. If a + * migration is needed, the flag stays set until startMigration() + * runs to completion (or fails/cancels), which clears it. + */ + createRxCollection: { + after: (i: any) => { + const collection: RxCollection = i.collection; + if (collection.schema.version === 0) { + return; + } + collection.migrationInProgress = true; + collection.migrationNeeded() + .then((needed: boolean) => { + if (!needed) { + collection.migrationInProgress = false; + } + }) + .catch(() => { + collection.migrationInProgress = false; + }); + } } }, prototypes: { diff --git a/src/rx-collection-helper.ts b/src/rx-collection-helper.ts index 9b212aaf43d..273d140989d 100644 --- a/src/rx-collection-helper.ts +++ b/src/rx-collection-helper.ts @@ -268,14 +268,23 @@ export function ensureRxCollectionIsNotClosed( } /** - * Throws if a schema migration is currently running on the collection. - * Writes are not allowed during a migration because the new collection - * is being filled by the migration replication and outside writes - * could conflict with that process. + * Asserts that a write to the given collection is currently allowed. + * Throws if the collection is closed or if a schema migration is + * pending or running, in which cases external writes would either + * fail or conflict with the migration replication. */ -export function ensureRxCollectionIsNotMigrating( +export function isWriteAllowed( collection: RxCollection | RxCollectionBase ) { + if (collection.closed) { + throw newRxError( + 'COL21', + { + collection: collection.name, + version: collection.schema.version + } + ); + } if (collection.migrationInProgress) { throw newRxError( 'COL25', diff --git a/src/rx-collection.ts b/src/rx-collection.ts index 6e83022f68b..d8fb5bf90dd 100644 --- a/src/rx-collection.ts +++ b/src/rx-collection.ts @@ -22,7 +22,7 @@ import { createRxCollectionStorageInstance, removeCollectionStorages, ensureRxCollectionIsNotClosed, - ensureRxCollectionIsNotMigrating + isWriteAllowed } from './rx-collection-helper.ts'; import { createRxQuery, @@ -386,8 +386,7 @@ export class RxCollectionBase< async insert( json: RxDocumentType | RxDocument ): Promise> { - ensureRxCollectionIsNotClosed(this); - ensureRxCollectionIsNotMigrating(this); + isWriteAllowed(this); const writeResult = await this.bulkInsert([json as any]); const isError = writeResult.error[0]; @@ -419,8 +418,7 @@ export class RxCollectionBase< success: RxDocument[]; error: RxStorageWriteError[]; }> { - ensureRxCollectionIsNotClosed(this); - ensureRxCollectionIsNotMigrating(this); + isWriteAllowed(this); /** * Optimization shortcut, * do nothing when called with an empty array @@ -569,8 +567,7 @@ export class RxCollectionBase< success: RxDocument[]; error: RxStorageWriteError[]; }> { - ensureRxCollectionIsNotClosed(this); - ensureRxCollectionIsNotMigrating(this); + isWriteAllowed(this); const primaryPath = this.schema.primaryPath; /** * Optimization shortcut, @@ -659,8 +656,7 @@ export class RxCollectionBase< success: RxDocument[]; error: RxStorageWriteError[]; }> { - ensureRxCollectionIsNotClosed(this); - ensureRxCollectionIsNotMigrating(this); + isWriteAllowed(this); const insertData: RxDocumentType[] = []; const useJsonByDocId: Map = new Map(); @@ -750,8 +746,7 @@ export class RxCollectionBase< * same as insert but overwrites existing document with same primary */ async upsert(json: Partial, options?: UpsertOptions): Promise> { - ensureRxCollectionIsNotClosed(this); - ensureRxCollectionIsNotMigrating(this); + isWriteAllowed(this); const bulkResult = await this.bulkUpsert([json], options); throwIfIsStorageWriteError( this.asRxCollection, @@ -766,8 +761,7 @@ export class RxCollectionBase< * upserts to a RxDocument, uses incrementalModify if document already exists */ incrementalUpsert(json: Partial, options?: UpsertOptions): Promise> { - ensureRxCollectionIsNotClosed(this); - ensureRxCollectionIsNotMigrating(this); + isWriteAllowed(this); const useJson = fillObjectDataBeforeInsert(this.schema, json); const primary: string = useJson[this.schema.primaryPath] as any; if (!primary) { diff --git a/src/rx-document.ts b/src/rx-document.ts index f260c018469..70c3e27f24e 100644 --- a/src/rx-document.ts +++ b/src/rx-document.ts @@ -42,7 +42,7 @@ import { overwritable } from './overwritable.ts'; import { getSchemaByObjectPath } from './rx-schema-helper.ts'; import { getWrittenDocumentsFromBulkWriteResponse, throwIfIsStorageWriteError } from './rx-storage-helper.ts'; import { modifierFromPublicToInternal } from './incremental-write.ts'; -import { ensureRxCollectionIsNotMigrating } from './rx-collection-helper.ts'; +import { isWriteAllowed } from './rx-collection-helper.ts'; export const basePrototype = { get primaryPath() { @@ -326,7 +326,7 @@ export const basePrototype = { // used by some plugins that wrap the method _context?: string ): Promise { - ensureRxCollectionIsNotMigrating(this.collection); + isWriteAllowed(this.collection); return this.collection.incrementalWriteQueue.addWrite( this._data, modifierFromPublicToInternal(mutationFunction) @@ -373,7 +373,7 @@ export const basePrototype = { newData: RxDocumentWriteData, oldData: RxDocumentData ): Promise> { - ensureRxCollectionIsNotMigrating(this.collection); + isWriteAllowed(this.collection); newData = flatClone(newData); // deleted documents cannot be changed diff --git a/test/unit/migration-schema.test.ts b/test/unit/migration-schema.test.ts index 740db4b4a24..5c920e32d68 100644 --- a/test/unit/migration-schema.test.ts +++ b/test/unit/migration-schema.test.ts @@ -505,6 +505,34 @@ describe('migration-schema.test.ts', function () { await col.database.close(); }); + it('should block writes when a migration is needed but not yet started', async () => { + // collection is created with autoMigrate=false and old data is present, + // so the migration is required but startMigration() has not been called. + const col = await humansCollection.createMigrationCollection( + isFastMode() ? 3 : 5, + { + 3: (doc: any) => { + doc.age = parseInt(doc.age, 10); + return doc; + } + } + ); + + await assertThrows( + () => col.insert(schemaObjects.simpleHumanAge() as any), + 'RxError', + 'COL25' + ); + + await col.migratePromise(); + assert.strictEqual((col as any).migrationInProgress, false); + + // writes are allowed after the migration completes + await col.insert(schemaObjects.simpleHumanAge() as any); + + await col.database.close(); + }); + it('should reset migrationInProgress on migration error', async () => { const col = await humansCollection.createMigrationCollection(3, { 3: () => { From a38630e88af052a50bb3a17aa516a3cdcfe53e34 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 5 May 2026 13:43:58 +0000 Subject: [PATCH 4/6] changelog: writes blocked during pending/running schema migration --- orga/changelog/prevent-writes-during-migration.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 orga/changelog/prevent-writes-during-migration.md diff --git a/orga/changelog/prevent-writes-during-migration.md b/orga/changelog/prevent-writes-during-migration.md new file mode 100644 index 00000000000..d02f632312f --- /dev/null +++ b/orga/changelog/prevent-writes-during-migration.md @@ -0,0 +1 @@ +- FIX writes to an `RxCollection` racing with a running schema migration could surface as confusing `RC_PUSH` errors. Writes (`insert`, `bulkInsert`, `upsert`, `bulkUpsert`, `incrementalUpsert`, `bulkRemove`, `RxDocument` modifications, attachment writes) are now blocked with a clear `COL25` error while a migration is pending or running. The new `isWriteAllowed()` helper consolidates the "closed" and "migrating" checks at every write call site, and the migration plugin now sets `migrationInProgress` eagerly on collection creation so writes are also refused before `startMigration()`/`migratePromise()` is called. From afa29106a0e6bb9a04beafc0a4d649ac254e7f4e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 5 May 2026 13:46:23 +0000 Subject: [PATCH 5/6] changelog: shorten migration write-block entry --- orga/changelog/prevent-writes-during-migration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/orga/changelog/prevent-writes-during-migration.md b/orga/changelog/prevent-writes-during-migration.md index d02f632312f..3bca9d6504c 100644 --- a/orga/changelog/prevent-writes-during-migration.md +++ b/orga/changelog/prevent-writes-during-migration.md @@ -1 +1 @@ -- FIX writes to an `RxCollection` racing with a running schema migration could surface as confusing `RC_PUSH` errors. Writes (`insert`, `bulkInsert`, `upsert`, `bulkUpsert`, `incrementalUpsert`, `bulkRemove`, `RxDocument` modifications, attachment writes) are now blocked with a clear `COL25` error while a migration is pending or running. The new `isWriteAllowed()` helper consolidates the "closed" and "migrating" checks at every write call site, and the migration plugin now sets `migrationInProgress` eagerly on collection creation so writes are also refused before `startMigration()`/`migratePromise()` is called. +- FIX writes to an `RxCollection` while a schema migration is pending or running now fail fast with a clear `COL25` error instead of racing the migration and surfacing as `RC_PUSH`. From 6a18c352615cacd2c93820c41e8ffec29284bdc3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 08:03:04 +0000 Subject: [PATCH 6/6] fix: use simpleHumanV3Data() for post-migration inserts in tests Agent-Logs-Url: https://github.com/pubkey/rxdb/sessions/3d26456a-4820-4b8b-8207-d4cbc89c651e Co-authored-by: pubkey <8926560+pubkey@users.noreply.github.com> --- test/unit/migration-schema.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/unit/migration-schema.test.ts b/test/unit/migration-schema.test.ts index 5c920e32d68..49482325d60 100644 --- a/test/unit/migration-schema.test.ts +++ b/test/unit/migration-schema.test.ts @@ -472,8 +472,8 @@ describe('migration-schema.test.ts', function () { await migrationDone; assert.strictEqual((col as any).migrationInProgress, false); - // now writes work again - await col.insert(schemaObjects.simpleHumanAge() as any); + // now writes work again (use V3 data since migration converted age to number) + await col.insert(schemaObjects.simpleHumanV3Data()); await col.database.close(); }); @@ -527,8 +527,8 @@ describe('migration-schema.test.ts', function () { await col.migratePromise(); assert.strictEqual((col as any).migrationInProgress, false); - // writes are allowed after the migration completes - await col.insert(schemaObjects.simpleHumanAge() as any); + // writes are allowed after the migration completes (use V3 data since age is now a number) + await col.insert(schemaObjects.simpleHumanV3Data()); await col.database.close(); });