Skip to content

Commit 94fddf9

Browse files
pubkeyclaude
andauthored
Add attachment replication support to Microsoft OneDrive plugin (#8592)
* feat(replication-microsoft-onedrive): implement attachment replication with base64 encoding Mirrors the attachment replication support that was added to the google-drive plugin: attachment binary data is serialised as base64 inside the document JSON file stored on OneDrive while `_attachments` remains as clean stubs. Enabled automatically when the collection schema declares `attachments: {}` and can be disabled via `attachments: false`. * refactor: share attachment serialization helpers between google-drive and onedrive plugins Move serializeDocAttachments, deserializeDocAttachments and the inline-stripForComparison logic (now stripAllAttachmentDataForComparison) from each plugin's document-handling.ts / upstream.ts into src/replication-protocol/helper.ts so both replication backends share a single implementation. The plugins re-export the same names so the public API is unchanged. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent be3943c commit 94fddf9

9 files changed

Lines changed: 226 additions & 87 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
- ADD `replication-microsoft-onedrive` plugin now supports attachment replication. Attachment binary data is stored as base64 in a separate `_attachments_data` field of the document JSON file on OneDrive, keeping `_attachments` as clean stubs. Attachment replication is enabled automatically when the collection schema has `attachments: {}` defined and can be disabled by passing `attachments: false` to `replicateMicrosoftOneDrive()`.
2+
- REFACTOR move the attachment serialisation helpers (`serializeDocAttachments`, `deserializeDocAttachments`, `stripAllAttachmentDataForComparison`) to `src/replication-protocol/helper.ts` so they can be shared between the google-drive and microsoft-onedrive replication plugins.

src/plugins/replication-google-drive/document-handling.ts

Lines changed: 5 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
import { newRxError, newRxFetchError } from '../../rx-error.ts';
2-
import { stripAttachmentsDataFromDocument } from '../../rx-storage-helper.ts';
32
import { ById } from '../../types/util';
43
import { ensureNotFalsy } from '../utils/index.ts';
5-
import { blobToBase64String, createBlobFromBase64 } from '../utils/index.ts';
64
import { applyDriveSpace, insertMultipartFile } from './google-drive-helper.ts';
75
import type {
86
DriveFileListResponse,
97
GoogleDriveOptionsWithDefaults
108
} from './google-drive-types.ts';
119
import { DriveStructure } from './init.ts';
1210

11+
export {
12+
serializeDocAttachments,
13+
deserializeDocAttachments
14+
} from '../../replication-protocol/helper.ts';
15+
1316
const MAX_DRIVE_PAGE_SIZE = 1000;
1417

1518

@@ -217,69 +220,3 @@ export async function fetchDocumentContents<DocType>(
217220
return { byId, ordered };
218221
}
219222

220-
/**
221-
* Serialises attachment data in a document clone so it can safely be stored
222-
* as JSON in Google Drive.
223-
*
224-
* - When `serializeData` is true: Blob values are extracted and stored as
225-
* base64 strings in the top-level `_attachments_data` field, while
226-
* `_attachments` is stripped to clean stubs via `stripAttachmentsDataFromDocument`.
227-
* - When `serializeData` is false: `_attachments` is set to `{}` so that
228-
* attachment stubs (without binary data) are never stored in Drive. This
229-
* prevents the downstream replication protocol from trying to write attachment
230-
* data it does not have when a peer pulls the document.
231-
*
232-
* The function returns a NEW document object; the original is not mutated.
233-
*/
234-
export async function serializeDocAttachments<T>(doc: T, serializeData: boolean): Promise<T> {
235-
const d = doc as any;
236-
if (!d?._attachments) {
237-
return doc;
238-
}
239-
240-
if (!serializeData) {
241-
return { ...d, _attachments: {} } as any;
242-
}
243-
244-
const attachmentData: Record<string, string> = {};
245-
await Promise.all(
246-
Object.entries(d._attachments as Record<string, any>).map(async ([id, att]) => {
247-
if (att.data instanceof Blob) {
248-
attachmentData[id] = await blobToBase64String(att.data);
249-
}
250-
})
251-
);
252-
253-
// Strip binary data from _attachments, leaving clean stubs {digest, length, type}.
254-
const stripped = stripAttachmentsDataFromDocument(d) as any;
255-
256-
if (Object.keys(attachmentData).length > 0) {
257-
return { ...stripped, _attachments_data: attachmentData } as any;
258-
}
259-
return stripped as any;
260-
}
261-
262-
/**
263-
* Converts attachment data stored in `_attachments_data` back to Blobs in the
264-
* document, so that the downstream replication protocol can write them to the
265-
* fork storage instance correctly.
266-
*
267-
* Mutates the document in place and removes the `_attachments_data` field.
268-
*/
269-
export async function deserializeDocAttachments(doc: any): Promise<void> {
270-
const attachmentData: Record<string, string> | undefined = doc._attachments_data;
271-
if (!attachmentData || !doc._attachments) {
272-
return;
273-
}
274-
await Promise.all(
275-
Object.entries(attachmentData).map(async ([id, base64]) => {
276-
if (doc._attachments[id]) {
277-
doc._attachments[id] = {
278-
...doc._attachments[id],
279-
data: await createBlobFromBase64(base64, doc._attachments[id].type)
280-
};
281-
}
282-
})
283-
);
284-
delete doc._attachments_data;
285-
}

src/plugins/replication-google-drive/upstream.ts

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { RxReplicationWriteToMasterRow, WithDeletedAndAttachments } from '../../index.ts';
22
import { newRxError, newRxFetchError } from '../../rx-error.ts';
3-
import { stripAttachmentsDataFromDocument } from '../../rx-storage-helper.ts';
3+
import { stripAllAttachmentDataForComparison } from '../../replication-protocol/helper.ts';
44
import { deepEqual, ensureNotFalsy } from '../utils/index.ts';
55
import { fetchDocumentContents, getDocumentFiles, insertDocumentFiles, updateDocumentFiles } from './document-handling.ts';
66
import { DRIVE_MAX_BULK_SIZE, fillFileIfEtagMatches, getFileEtag } from './google-drive-helper.ts';
@@ -55,20 +55,10 @@ export async function fetchConflicts<RxDocType>(
5555
fileContent = contentsByFileId.byId[fileId];
5656
}
5757
if (row.assumedMasterState) {
58-
/**
59-
* Strip binary/serialised attachment data from both sides before
60-
* comparing. `assumedMasterState` comes from the replication protocol
61-
* which already stores clean stubs (no `data`). `fileContent` comes
62-
* from Google Drive and stores base64 data in the separate
63-
* `_attachments_data` field — strip that too so the structural
64-
* metadata (digest / length / type) is all that is compared.
65-
*/
66-
const stripForComparison = (d: any) => {
67-
const s = stripAttachmentsDataFromDocument(d) as any;
68-
delete s._attachments_data;
69-
return s;
70-
};
71-
if (!deepEqual(stripForComparison(row.assumedMasterState), stripForComparison(fileContent))) {
58+
if (!deepEqual(
59+
stripAllAttachmentDataForComparison(row.assumedMasterState),
60+
stripAllAttachmentDataForComparison(fileContent)
61+
)) {
7262
conflicts.push(ensureNotFalsy(fileContent));
7363
} else {
7464
nonConflicts.push(row);

src/plugins/replication-microsoft-onedrive/document-handling.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ import type {
99
import { DriveStructure } from './init.ts';
1010
import { getDriveBaseUrl } from './microsoft-onedrive-helper.ts';
1111

12+
export {
13+
serializeDocAttachments,
14+
deserializeDocAttachments
15+
} from '../../replication-protocol/helper.ts';
16+
1217
const MAX_DRIVE_PAGE_SIZE = 999;
1318

1419
export async function getDocumentFiles(
@@ -214,3 +219,4 @@ export async function fetchDocumentContents<DocType>(
214219
await Promise.all(Array.from({ length: concurrency }, () => worker()));
215220
return { byId, ordered };
216221
}
222+

src/plugins/replication-microsoft-onedrive/index.ts

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ import {
3030
ensureProcessNextTickIsSet
3131
} from '../replication-webrtc/connection-handler-simple-peer.ts';
3232
import { SignalingOptions, SignalingState } from './signaling.ts';
33+
import {
34+
deserializeDocAttachments,
35+
serializeDocAttachments
36+
} from './document-handling.ts';
3337

3438
export * from './microsoft-onedrive-types.ts';
3539
export * from './microsoft-onedrive-helper.ts';
@@ -105,6 +109,14 @@ export async function replicateMicrosoftOneDrive<RxDocType>(
105109
);
106110
const driveStructure = await initDriveStructure(oneDriveState);
107111

112+
/**
113+
* When true, attachment binary data is serialised as base64 inside the
114+
* document JSON file stored on OneDrive. Defaults to true only when
115+
* the collection schema has `attachments: {}` defined. Can be disabled
116+
* explicitly by passing `attachments: false` in the options.
117+
*/
118+
const replicateAttachments = options.attachments !== false && !!collection.schema.jsonSchema.attachments;
119+
108120

109121
let replicationState: RxOneDriveReplicationState<RxDocType>;
110122

@@ -131,6 +143,17 @@ export async function replicateMicrosoftOneDrive<RxDocType>(
131143
lastPulledCheckpoint,
132144
batchSize
133145
);
146+
/**
147+
* Convert base64 attachment data that was stored in the
148+
* OneDrive JSON file back to Blobs so that the
149+
* downstream replication protocol can write them to the
150+
* fork storage instance correctly.
151+
*/
152+
if (replicateAttachments) {
153+
await Promise.all(
154+
changes.documents.map(doc => deserializeDocAttachments(doc as any))
155+
);
156+
}
134157
return changes as any;
135158
}
136159
);
@@ -148,6 +171,27 @@ export async function replicateMicrosoftOneDrive<RxDocType>(
148171
async handler(
149172
rows: RxReplicationWriteToMasterRow<RxDocType>[]
150173
) {
174+
/**
175+
* Convert Blob attachment data to base64 strings before the
176+
* rows are written to the WAL file or stored as document JSON
177+
* on OneDrive. We create new row objects so the originals
178+
* (which the replication protocol still uses for meta updates)
179+
* are not mutated.
180+
* When attachments are disabled we still run the conversion so
181+
* that Blob values are stripped rather than serialised as `{}`
182+
* by JSON.stringify.
183+
*/
184+
const rowsForDrive: RxReplicationWriteToMasterRow<RxDocType>[] =
185+
await Promise.all(
186+
rows.map(async row => {
187+
const newState = await serializeDocAttachments(
188+
row.newDocumentState,
189+
replicateAttachments
190+
);
191+
return { ...row, newDocumentState: newState };
192+
})
193+
);
194+
151195
return runInTransaction(
152196
oneDriveState,
153197
driveStructure,
@@ -157,8 +201,19 @@ export async function replicateMicrosoftOneDrive<RxDocType>(
157201
oneDriveState,
158202
driveStructure,
159203
options.collection.schema.primaryPath as any,
160-
rows
204+
rowsForDrive
161205
);
206+
/**
207+
* When attachment replication is enabled, deserialise the
208+
* base64 attachment data that OneDrive stored in the
209+
* `_attachments_data` field back into Blobs on each conflict
210+
* document before returning them to the replication protocol.
211+
*/
212+
if (replicateAttachments && conflicts.length > 0) {
213+
await Promise.all(
214+
conflicts.map(c => deserializeDocAttachments(c as any))
215+
);
216+
}
162217
return conflicts;
163218
},
164219
() => replicationState.notifyPeers().catch(() => { })

src/plugins/replication-microsoft-onedrive/microsoft-onedrive-types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,14 @@ export type SyncOptionsOneDrive<RxDocType> = Omit<
6666
signalingOptions?: SignalingOptions;
6767
pull?: OneDriveSyncPullOptions<RxDocType>;
6868
push?: OneDriveSyncPushOptions<RxDocType>;
69+
/**
70+
* Set to false to disable attachment replication.
71+
* When enabled (default), attachment binary data is stored as base64
72+
* directly inside the document JSON file on OneDrive.
73+
* Attachment replication only has an effect when the collection schema
74+
* has `attachments: {}` defined.
75+
*/
76+
attachments?: false;
6977
};
7078

7179
export type OneDriveCheckpointType = {

src/plugins/replication-microsoft-onedrive/upstream.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { RxReplicationWriteToMasterRow, WithDeletedAndAttachments } from '../../index.ts';
22
import { newRxError, newRxFetchError } from '../../rx-error.ts';
3+
import { stripAllAttachmentDataForComparison } from '../../replication-protocol/helper.ts';
34
import { deepEqual, ensureNotFalsy } from '../utils/index.ts';
45
import { fetchDocumentContents, getDocumentFiles, insertDocumentFiles, updateDocumentFiles } from './document-handling.ts';
56
import { fillFileIfEtagMatches, getDriveBaseUrl } from './microsoft-onedrive-helper.ts';
@@ -57,7 +58,10 @@ export async function fetchConflicts<RxDocType>(
5758
fileContent = contentsByFileId.byId[fileId];
5859
}
5960
if (row.assumedMasterState) {
60-
if (!deepEqual(row.assumedMasterState, fileContent)) {
61+
if (!deepEqual(
62+
stripAllAttachmentDataForComparison(row.assumedMasterState),
63+
stripAllAttachmentDataForComparison(fileContent)
64+
)) {
6165
conflicts.push(ensureNotFalsy(fileContent));
6266
} else {
6367
nonConflicts.push(row);

src/replication-protocol/helper.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ import type {
88
WithDeletedAndAttachments
99
} from '../types/index.d.ts';
1010
import {
11+
blobToBase64String,
1112
clone,
13+
createBlobFromBase64,
1214
createRevision,
1315
flatClone,
1416
getDefaultRevision,
@@ -83,6 +85,86 @@ export function stripAttachmentsDataFromMetaWriteRows<RxDocType>(
8385
});
8486
}
8587

88+
/**
89+
* Serialises attachment data in a document clone so it can safely be stored
90+
* as JSON in a remote storage that does not natively support attachments
91+
* (e.g. Google Drive, OneDrive).
92+
*
93+
* - When `serializeData` is true: Blob values are extracted and stored as
94+
* base64 strings in the top-level `_attachments_data` field, while
95+
* `_attachments` is stripped to clean stubs via `stripAttachmentsDataFromDocument`.
96+
* - When `serializeData` is false: `_attachments` is set to `{}` so that
97+
* attachment stubs (without binary data) are never persisted. This
98+
* prevents the downstream replication protocol from trying to write
99+
* attachment data it does not have when a peer pulls the document.
100+
*
101+
* The function returns a NEW document object; the original is not mutated.
102+
*/
103+
export async function serializeDocAttachments<T>(doc: T, serializeData: boolean): Promise<T> {
104+
const d = doc as any;
105+
if (!d?._attachments) {
106+
return doc;
107+
}
108+
109+
if (!serializeData) {
110+
return { ...d, _attachments: {} } as any;
111+
}
112+
113+
const attachmentData: Record<string, string> = {};
114+
await Promise.all(
115+
Object.entries(d._attachments as Record<string, any>).map(async ([id, att]) => {
116+
if (att.data instanceof Blob) {
117+
attachmentData[id] = await blobToBase64String(att.data);
118+
}
119+
})
120+
);
121+
122+
const stripped = stripAttachmentsDataFromDocument(d) as any;
123+
124+
if (Object.keys(attachmentData).length > 0) {
125+
return { ...stripped, _attachments_data: attachmentData } as any;
126+
}
127+
return stripped as any;
128+
}
129+
130+
/**
131+
* Strips both `_attachments` data and the serialised `_attachments_data` field
132+
* from a document, leaving only the structural attachment metadata
133+
* (digest / length / type). Used to compare two document states without
134+
* being affected by attachment binary data that lives in different shapes on
135+
* each side (Blobs in memory vs base64 in the serialised JSON file).
136+
*/
137+
export function stripAllAttachmentDataForComparison<T>(doc: T): T {
138+
const stripped = stripAttachmentsDataFromDocument(doc as any) as any;
139+
delete stripped._attachments_data;
140+
return stripped;
141+
}
142+
143+
/**
144+
* Converts attachment data stored in `_attachments_data` back to Blobs in the
145+
* document, so that the downstream replication protocol can write them to the
146+
* fork storage instance correctly.
147+
*
148+
* Mutates the document in place and removes the `_attachments_data` field.
149+
*/
150+
export async function deserializeDocAttachments(doc: any): Promise<void> {
151+
const attachmentData: Record<string, string> | undefined = doc._attachments_data;
152+
if (!attachmentData || !doc._attachments) {
153+
return;
154+
}
155+
await Promise.all(
156+
Object.entries(attachmentData).map(async ([id, base64]) => {
157+
if (doc._attachments[id]) {
158+
doc._attachments[id] = {
159+
...doc._attachments[id],
160+
data: await createBlobFromBase64(base64, doc._attachments[id].type)
161+
};
162+
}
163+
})
164+
);
165+
delete doc._attachments_data;
166+
}
167+
86168
export function getUnderlyingPersistentStorage<RxDocType>(
87169
instance: RxStorageInstance<RxDocType, any, any, any>
88170
): RxStorageInstance<RxDocType, any, any, any> {

0 commit comments

Comments
 (0)