rxdb
Version:
A local-first realtime NoSQL Database for JavaScript applications - https://rxdb.info/
283 lines (254 loc) • 8.22 kB
text/typescript
import { RxReplicationWriteToMasterRow, WithDeletedAndAttachments } from '../../index.ts';
import { newRxError, newRxFetchError } from '../../rx-error.ts';
import { deepEqual, ensureNotFalsy } from '../utils/index.ts';
import { fetchDocumentContents, getDocumentFiles, insertDocumentFiles, updateDocumentFiles } from './document-handling.ts';
import { DRIVE_MAX_BULK_SIZE, fillFileIfEtagMatches } from './google-drive-helper.ts';
import type {
DriveFileMetadata,
GoogleDriveOptionsWithDefaults
} from './google-drive-types';
import { DriveStructure } from './init.ts';
import { commitTransaction, startTransaction } from './transaction.ts';
export const WAL_FILE_NAME = 'rxdb-wal.json';
export async function fetchConflicts<RxDocType>(
googleDriveOptions: GoogleDriveOptionsWithDefaults,
init: DriveStructure,
primaryPath: keyof WithDeletedAndAttachments<RxDocType>,
writeRows: RxReplicationWriteToMasterRow<RxDocType>[]
) {
if (writeRows.length > DRIVE_MAX_BULK_SIZE) {
throw newRxError('GDR18', {
args: {
DRIVE_MAX_BULK_SIZE
}
});
}
const ids = writeRows.map(row => (row.newDocumentState as any)[primaryPath]);
const filesMeta = await getDocumentFiles(
googleDriveOptions,
init,
ids as string[]
);
const fileIdByDocId = new Map<string, string>();
const fileIds: string[] = filesMeta.files.map((f) => {
const fileId = ensureNotFalsy(f.id);
const docId = f.name.split('.')[0];
fileIdByDocId.set(docId, fileId);
return fileId;
});
const contentsByFileId = await fetchDocumentContents<WithDeletedAndAttachments<RxDocType>>(
googleDriveOptions,
fileIds
);
const conflicts: WithDeletedAndAttachments<RxDocType>[] = [];
const nonConflicts: RxReplicationWriteToMasterRow<RxDocType>[] = [];
writeRows.forEach(row => {
const docId = (row.newDocumentState as any)[primaryPath];
let fileContent: undefined | WithDeletedAndAttachments<RxDocType>;
const fileId = fileIdByDocId.get(docId);
if (fileId) {
fileContent = contentsByFileId.byId[fileId];
}
if (row.assumedMasterState) {
if (!deepEqual(row.assumedMasterState, fileContent)) {
conflicts.push(ensureNotFalsy(fileContent));
} else {
nonConflicts.push(row);
}
} else if (fileContent) {
conflicts.push(fileContent);
} else {
nonConflicts.push(row);
}
});
if ((nonConflicts.length + conflicts.length) !== writeRows.length) {
throw newRxError('SNH', {
pushRows: writeRows,
args: {
nonConflicts,
conflicts,
contentsByFileId: contentsByFileId.byId
}
});
}
return {
conflicts,
nonConflicts
};
}
export async function writeToWal<RxDocType>(
googleDriveOptions: GoogleDriveOptionsWithDefaults,
init: DriveStructure,
writeRows?: RxReplicationWriteToMasterRow<RxDocType>[]
) {
const walFileId = init.walFile.fileId;
const metaUrl =
googleDriveOptions.apiEndpoint +
`/drive/v2/files/${encodeURIComponent(walFileId)}?` +
new URLSearchParams({
fields: "id,fileSize,mimeType,title,etag",
supportsAllDrives: "true"
}).toString();
const metaRes = await fetch(metaUrl, {
method: "GET",
headers: {
Authorization: `Bearer ${googleDriveOptions.authToken}`,
},
});
if (!metaRes.ok) {
throw await newRxFetchError(metaRes);
}
const meta: DriveFileMetadata = await metaRes.json();
const sizeStr = meta.fileSize ?? "0";
const sizeNum = Number(sizeStr);
if (writeRows && (!meta.fileSize || sizeNum > 0)) {
throw newRxError("GDR19", {
args: {
sizeNum,
walFileId,
size: meta.size,
meta,
writeRows: writeRows?.length
}
});
}
const etag = ensureNotFalsy(metaRes.headers.get("etag"), 'etag missing');
const writeResult = await fillFileIfEtagMatches(
googleDriveOptions,
walFileId,
etag,
writeRows
);
if (writeResult.status !== 200) {
throw newRxError("GDR19", {
args: {
walFileId,
meta,
writeRows: writeRows?.length
}
});
}
}
export async function readWalContent<RxDocType>(
googleDriveOptions: GoogleDriveOptionsWithDefaults,
init: DriveStructure,
): Promise<{
etag: string;
rows: RxReplicationWriteToMasterRow<RxDocType>[] | undefined;
}> {
const walFileId = init.walFile.fileId;
const contentUrl =
googleDriveOptions.apiEndpoint +
`/drive/v2/files/${encodeURIComponent(walFileId)}?alt=media`;
const res = await fetch(contentUrl, {
method: "GET",
headers: {
Authorization: `Bearer ${googleDriveOptions.authToken}`,
},
});
if (!res.ok) {
throw await newRxFetchError(res);
}
const etag = ensureNotFalsy(
res.headers.get("etag"),
"etag missing on WAL read"
);
const text = await res.text();
// If empty or whitespace → no WAL entries
if (!text || !text.trim()) {
return {
etag,
rows: undefined
};
}
return {
etag,
rows: JSON.parse(text) as RxReplicationWriteToMasterRow<RxDocType>[]
};
}
/**
* Here we read the WAL file content
* and sort the content into the actual
* document files.
* Notice that when the JavaScript process
* exists at any point here, we need to have
* a recoverable state on the next run. So this
* must be idempotent.
*/
export async function processWalFile<RxDocType>(
googleDriveOptions: GoogleDriveOptionsWithDefaults,
init: DriveStructure,
primaryPath: keyof RxDocType
) {
const content = await readWalContent<RxDocType>(
googleDriveOptions,
init
);
if (!content.rows) {
return;
}
const docIds = content.rows.map(row => row.newDocumentState[primaryPath]);
const docFiles = await getDocumentFiles(
googleDriveOptions,
init,
docIds as string[]
);
const fileMetaByDocId: Record<string, { fileId: string; etag: string }> = {};
docFiles.files.forEach(file => {
const docId = file.name.split('.')[0] as any;
fileMetaByDocId[docId] = {
fileId: file.id,
etag: ensureNotFalsy(file.etag),
};
});
const toInsert: WithDeletedAndAttachments<RxDocType>[] = [];
const toUpdate: WithDeletedAndAttachments<RxDocType>[] = [];
content.rows.filter(row => {
const docId = row.newDocumentState[primaryPath];
const fileExists = fileMetaByDocId[docId as any];
if (!fileExists) {
toInsert.push(row.newDocumentState);
} else {
toUpdate.push(row.newDocumentState);
}
});
await Promise.all([
insertDocumentFiles(
googleDriveOptions,
init,
primaryPath,
toInsert
),
updateDocumentFiles(
googleDriveOptions,
primaryPath,
toUpdate,
fileMetaByDocId,
)
]);
// overwrite wal with emptyness
await writeToWal(
googleDriveOptions,
init,
undefined
);
}
export async function handleUpstreamBatch<RxDocType>(
googleDriveOptions: GoogleDriveOptionsWithDefaults,
init: DriveStructure,
primaryPath: keyof WithDeletedAndAttachments<RxDocType>,
writeRows: RxReplicationWriteToMasterRow<RxDocType>[]
): Promise<WithDeletedAndAttachments<RxDocType>[]> {
const conflictResult = await fetchConflicts(
googleDriveOptions,
init,
primaryPath,
writeRows
);
await writeToWal(
googleDriveOptions,
init,
conflictResult.nonConflicts
);
return conflictResult.conflicts;
}