Skip to content

Commit cfbd650

Browse files
pubkeyclaude
andauthored
Fix schema migration dropping _attachments across chained strategies (#8399)
When a migration strategy returned a new object without forwarding the `_attachments` field, subsequent strategies received the document with `_attachments` as `undefined`. This violated the public `WithAttachments<DocData>` contract and prevented strategies from reading or mutating attachment metadata as described in the docs example. `migrateDocumentData()` now tracks the last-known `_attachments` value across strategy invocations and re-attaches it before the next strategy runs, while still honoring explicit rewrites (e.g. `_attachments = {}`). https://claude.ai/code/session_01UiLzK67sLGyehJis5YfBYb Co-authored-by: Claude <noreply@anthropic.com>
1 parent 929bb42 commit cfbd650

3 files changed

Lines changed: 146 additions & 15 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- FIX schema migration not forwarding `_attachments` across chained migration strategies, so later strategies received the document with `_attachments` as `undefined` when an earlier strategy returned a new object without forwarding them, breaking the `WithAttachments<DocData>` contract and preventing strategies from reading or mutating attachment metadata as described in the docs

src/plugins/migration-schema/migration-helpers.ts

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,16 @@ export function migrateDocumentData(
7777

7878
let nextVersion = docSchemaVersion + 1;
7979

80+
/**
81+
* Track attachments across chained strategies so that each strategy
82+
* receives the document matching the public `WithAttachments<DocData>`
83+
* contract, even when an earlier strategy returned a fresh object that
84+
* did not forward `_attachments`. A strategy that explicitly rewrites
85+
* `_attachments` still wins because its value is carried forward as
86+
* the new baseline.
87+
*/
88+
let currentAttachments = attachmentsBefore;
89+
8090
// run the document through migrationStrategies
8191
let currentPromise = Promise.resolve(mutateableDocData);
8292
while (nextVersion <= collection.schema.version) {
@@ -85,7 +95,16 @@ export function migrateDocumentData(
8595
collection,
8696
version,
8797
docOrNull
88-
));
98+
)).then(docOrNull => {
99+
if (docOrNull !== null) {
100+
if (typeof docOrNull._attachments === 'undefined') {
101+
docOrNull._attachments = currentAttachments;
102+
} else {
103+
currentAttachments = docOrNull._attachments;
104+
}
105+
}
106+
return docOrNull;
107+
});
89108
nextVersion++;
90109
}
91110

@@ -98,20 +117,6 @@ export function migrateDocumentData(
98117
doc._meta = meta;
99118
}
100119
doc._deleted = deleted;
101-
/**
102-
* Restore attachments in case a migration strategy returned a fresh
103-
* object that did not forward the `_attachments` field. Strategies
104-
* receive the document with its attachments already attached, but
105-
* only internal fields (`_meta`, `_deleted`) were restored here.
106-
* Without re-attaching, attachments would be silently dropped when
107-
* a strategy returns a new object, which is a valid public API use.
108-
* Strategies that intentionally clear attachments (e.g. by setting
109-
* `_attachments = {}` on the old doc) still win because the
110-
* returned object already carries the cleared value.
111-
*/
112-
if (typeof doc._attachments === 'undefined') {
113-
doc._attachments = attachmentsBefore;
114-
}
115120
return doc;
116121
});
117122
}

test/unit/migration-schema.test.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2155,6 +2155,131 @@ describe('migration-schema.test.ts', function () {
21552155
const fetchedData = await attachment.getStringData();
21562156
assert.strictEqual(fetchedData, attachmentData);
21572157

2158+
await db2.close();
2159+
});
2160+
/**
2161+
* Regression test for multi-step migrations where an intermediate
2162+
* migration strategy returns a new object without forwarding
2163+
* `_attachments`. Subsequent strategies still receive the document
2164+
* typed as `WithAttachments<DocData>`, so `_attachments` must stay
2165+
* visible across chained strategies. Without forwarding
2166+
* `_attachments` between steps, later strategies see it as
2167+
* `undefined` and can no longer read or mutate attachment metadata,
2168+
* even though the public-API docs explicitly describe mutating
2169+
* `oldDoc._attachments` inside a strategy.
2170+
*/
2171+
it('should preserve _attachments across chained migration strategies', async () => {
2172+
if (!config.storage.hasAttachments) {
2173+
return;
2174+
}
2175+
if (isDeno && config.storage.name === 'dexie') {
2176+
return;
2177+
}
2178+
2179+
const dbName = randomToken(10);
2180+
const schema0 = {
2181+
version: 0,
2182+
primaryKey: 'id',
2183+
type: 'object' as const,
2184+
properties: {
2185+
id: {
2186+
type: 'string' as const,
2187+
maxLength: 100
2188+
},
2189+
name: {
2190+
type: 'string' as const
2191+
}
2192+
},
2193+
required: ['id', 'name'] as const,
2194+
attachments: {}
2195+
};
2196+
const schema2 = {
2197+
version: 2,
2198+
primaryKey: 'id',
2199+
type: 'object' as const,
2200+
properties: {
2201+
id: {
2202+
type: 'string' as const,
2203+
maxLength: 100
2204+
},
2205+
name: {
2206+
type: 'string' as const
2207+
},
2208+
hadAttachmentInStepTwo: {
2209+
type: 'boolean' as const
2210+
}
2211+
},
2212+
required: ['id', 'name'] as const,
2213+
attachments: {}
2214+
};
2215+
2216+
// create v0 database and insert a doc with an attachment
2217+
const db = await createRxDatabase({
2218+
name: dbName,
2219+
storage: config.storage.getStorage(),
2220+
});
2221+
const cols = await db.addCollections({
2222+
heroes: { schema: schema0 }
2223+
});
2224+
const doc = await cols.heroes.insert({ id: 'alice', name: 'Alice' });
2225+
const attachmentData = AsyncTestUtil.randomString(20);
2226+
await doc.putAttachment({
2227+
id: 'note.txt',
2228+
data: createBlob(attachmentData, 'text/plain'),
2229+
type: 'text/plain'
2230+
});
2231+
await db.close();
2232+
2233+
// reopen with v2 schema using two strategies.
2234+
// Strategy 1 returns a NEW object without forwarding
2235+
// `_attachments` (a valid public-API usage: the
2236+
// MigrationStrategy type is
2237+
// `(doc, collection) => WithAttachments<DocData> | null`
2238+
// and `_attachments` is optional on that type).
2239+
// Strategy 2 must still receive `_attachments` to comply
2240+
// with the same contract and the docs that show
2241+
// mutating `oldDoc._attachments` inside a strategy.
2242+
const db2 = await createRxDatabase({
2243+
name: dbName,
2244+
storage: config.storage.getStorage(),
2245+
});
2246+
const cols2 = await db2.addCollections({
2247+
heroes: {
2248+
schema: schema2,
2249+
migrationStrategies: {
2250+
1: (oldDoc: any) => {
2251+
return {
2252+
id: oldDoc.id,
2253+
name: oldDoc.name
2254+
};
2255+
},
2256+
2: (oldDoc: any) => {
2257+
const hadAttachment = !!oldDoc._attachments
2258+
&& Object.keys(oldDoc._attachments).length > 0;
2259+
return {
2260+
id: oldDoc.id,
2261+
name: oldDoc.name,
2262+
hadAttachmentInStepTwo: hadAttachment
2263+
};
2264+
}
2265+
}
2266+
}
2267+
});
2268+
2269+
const migratedDoc = await cols2.heroes.findOne('alice').exec(true);
2270+
assert.strictEqual(
2271+
(migratedDoc as any).hadAttachmentInStepTwo,
2272+
true,
2273+
'strategy 2 must see _attachments even when strategy 1 returned a new object'
2274+
);
2275+
2276+
const attachment = migratedDoc.getAttachment('note.txt');
2277+
assert.ok(attachment, 'attachment must still be present after migration');
2278+
assert.strictEqual(attachment.type, 'text/plain');
2279+
assert.strictEqual(attachment.length, attachmentData.length);
2280+
const fetchedData = await attachment.getStringData();
2281+
assert.strictEqual(fetchedData, attachmentData);
2282+
21582283
await db2.close();
21592284
});
21602285
});

0 commit comments

Comments
 (0)