rxdb
Version:
A local-first realtime NoSQL Database for JavaScript applications - https://rxdb.info/
554 lines (491 loc) • 16.8 kB
text/typescript
import { newRxError, newRxFetchError } from '../../rx-error.ts';
import { ensureNotFalsy } from '../utils/index.ts';
import type {
GoogleDriveOptionsWithDefaults,
DriveFileMetadata
} from './google-drive-types.ts';
import { DriveStructure } from './init.ts';
export const DRIVE_API_VERSION = 'v3';
export const DRIVE_MAX_PAGE_SIZE = 1000;
export const DRIVE_MAX_BULK_SIZE = DRIVE_MAX_PAGE_SIZE / 4;
export const FOLDER_MIME_TYPE = 'application/vnd.google-apps.folder';
/**
* The id of the top level parent for the configured space.
* For 'appDataFolder' this is the literal 'appDataFolder' which Google Drive
* accepts as a parent id and in queries. For 'drive' it is the regular 'root'.
*/
export function driveRootParentId(
googleDriveOptions: GoogleDriveOptionsWithDefaults
): string {
return googleDriveOptions.space === 'appDataFolder' ? 'appDataFolder' : 'root';
}
/**
* files.list only returns appDataFolder contents when spaces=appDataFolder
* is set, even when querying by an explicit parent id.
*/
export function applyDriveSpace(
googleDriveOptions: GoogleDriveOptionsWithDefaults,
params: URLSearchParams
): void {
if (googleDriveOptions.space === 'appDataFolder') {
params.set('spaces', 'appDataFolder');
}
}
/**
* Same as applyDriveSpace() but for URLs that are built via string concatenation.
* Returns an empty string for the default 'drive' space.
*/
export function driveSpaceQuerySuffix(
googleDriveOptions: GoogleDriveOptionsWithDefaults
): string {
return googleDriveOptions.space === 'appDataFolder' ? '&spaces=appDataFolder' : '';
}
export async function createFolder(
googleDriveOptions: GoogleDriveOptionsWithDefaults,
parentId: string = 'root',
folderName: string
): Promise<string> {
const url = googleDriveOptions.apiEndpoint + '/drive/v3/files?fields=id,name,mimeType,trashed';
const body = {
name: folderName,
mimeType: FOLDER_MIME_TYPE,
parents: [parentId]
};
const response = await fetch(url, {
method: 'POST',
headers: {
Authorization: 'Bearer ' + googleDriveOptions.authToken,
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
});
if (!response.ok) {
const errorText = await response.text();
if (response.status == 409) {
// someone else created the same folder, return that one instead.
const found = await findFolder(googleDriveOptions, parentId, folderName);
return ensureNotFalsy(found);
}
throw await newRxFetchError(response, {
folderName,
parentId
});
}
await response.json();
/**
* To make the function idempotent, we do not use the id from the creation-response.
* Instead after creating the folder, we search for it again so that in case
* some other instance created the same folder, we use the oldest one always.
*/
const foundFolder = await findFolder(
googleDriveOptions,
parentId,
folderName
);
return ensureNotFalsy(foundFolder);
}
export async function findFolder(
googleDriveOptions: GoogleDriveOptionsWithDefaults,
parentId: string = 'root',
folderName: string
): Promise<string | undefined> {
const query = "name = '" + folderName + "' and '" + parentId + "' in parents and trashed = false and mimeType = '" + FOLDER_MIME_TYPE + "'";
/**
* We sort by createdTime ASC
* so in case the same folder was created multiple times, we always pick the same
* one which is the oldest one.
*/
const searchUrl = googleDriveOptions.apiEndpoint + '/drive/v3/files?fields=files(id,mimeType)&orderBy=createdTime asc&q=' + encodeURIComponent(query) + driveSpaceQuerySuffix(googleDriveOptions);
const searchResponse = await fetch(searchUrl, {
method: 'GET',
headers: {
Authorization: 'Bearer ' + googleDriveOptions.authToken
}
});
const searchData = await searchResponse.json();
if (searchData.files && searchData.files.length > 0) {
const file = searchData.files[0];
if (file.mimeType !== FOLDER_MIME_TYPE) {
throw newRxError('GDR3', {
folderName,
args: {
file,
FOLDER_MIME_TYPE
}
});
}
return file.id;
} else {
return undefined;
}
}
export async function ensureFolderExists(
googleDriveOptions: GoogleDriveOptionsWithDefaults,
folderPath: string
): Promise<string> {
const parts = folderPath.split('/').filter(p => p.length > 0);
let parentId = driveRootParentId(googleDriveOptions);
for (const part of parts) {
const newParentId = await findFolder(googleDriveOptions, parentId, part);
if (newParentId) {
parentId = newParentId
} else {
parentId = await createFolder(googleDriveOptions, parentId, part);
}
}
return parentId;
}
export async function createEmptyFile(
googleDriveOptions: GoogleDriveOptionsWithDefaults,
parentId: string,
fileName: string
) {
const url = googleDriveOptions.apiEndpoint + '/drive/v3/files?fields=id';
const body = {
name: fileName,
parents: [parentId],
mimeType: 'application/json'
};
const response = await fetch(url, {
method: 'POST',
headers: {
Authorization: 'Bearer ' + googleDriveOptions.authToken,
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
});
/**
* Do not throw on duplicates,
* if the file is there already, find its id
* and return that one.
*/
if (!response.ok && response.status !== 409) {
throw await newRxFetchError(response, {
folderName: fileName
});
}
/**
* For idempotent runs, fetch the file again
* after creating it.
*/
const query = [
`name = '${fileName}'`,
`'${parentId}' in parents`,
`trashed = false`,
].join(' and ');
const url2 =
googleDriveOptions.apiEndpoint + '/drive/v3/files' +
'?fields=files(id)' +
'&orderBy=createdTime asc' +
'&q=' + encodeURIComponent(query) +
driveSpaceQuerySuffix(googleDriveOptions);
const res = await fetch(url2, {
headers: {
Authorization: 'Bearer ' + googleDriveOptions.authToken,
},
});
const data = await res.json();
const fileId = ensureNotFalsy(data.files[0]?.id);
/**
* Fetch the file metadata via v2 to get the etag (from the ETag response header)
* and the file size atomically in a single request.
* This avoids a race condition where the etag could change between
* the file listing and a separate etag fetch.
*/
const meta = await getFileMetadataV2(googleDriveOptions, fileId);
return {
status: response.status,
etag: meta.etag,
createdTime: meta.createdTime,
fileId,
size: meta.size
};
}
/**
* Fetches file metadata from the Google Drive v2 API.
* Returns the etag (from the ETag response header), size, and createdTime
* in a single atomic request.
*
* Note: the v2 API uses "createdDate" and "fileSize" as field names;
* this function maps them to "createdTime" and "size" for consistency
* with the rest of the codebase.
*/
async function getFileMetadataV2(
googleDriveOptions: GoogleDriveOptionsWithDefaults,
fileId: string
): Promise<{ etag: string; size: number; createdTime: string }> {
const url =
googleDriveOptions.apiEndpoint +
`/drive/v2/files/${encodeURIComponent(fileId)}`;
const res = await fetch(url, {
headers: {
Authorization: `Bearer ${googleDriveOptions.authToken}`,
},
});
if (!res.ok) {
throw await newRxFetchError(res, { args: { fileId } });
}
const meta = await res.json();
return {
etag: ensureNotFalsy(res.headers.get('ETag'), 'ETag missing'),
// v2 uses "fileSize" and "createdDate" instead of the v3 "size" and "createdTime"
size: parseInt(meta.fileSize ?? '0', 10),
createdTime: ensureNotFalsy(meta.createdDate)
};
}
export async function getFileEtag(
googleDriveOptions: GoogleDriveOptionsWithDefaults,
fileId: string
): Promise<string> {
const meta = await getFileMetadataV2(googleDriveOptions, fileId);
return meta.etag;
}
export async function fillFileIfEtagMatches<T = any>(
googleDriveOptions: GoogleDriveOptionsWithDefaults,
fileId: string,
etag: string,
jsonContent?: any
): Promise<{
status: number;
etag: string;
content: T | undefined;
serverTime: number;
}> {
const url =
`${googleDriveOptions.apiEndpoint}` +
`/upload/drive/v2/files/${encodeURIComponent(fileId)}` +
`?uploadType=media`;
const writeContent = typeof jsonContent !== 'undefined' ? JSON.stringify(jsonContent) : '';
const res = await fetch(url, {
method: "PUT",
headers: {
Authorization: `Bearer ${googleDriveOptions.authToken}`,
"Content-Type": "application/json; charset=utf-8",
"If-Match": etag,
},
body: writeContent,
});
if (res.status !== 412 && res.status !== 200) {
throw await newRxFetchError(res);
}
return readJsonFileContent<T>(
googleDriveOptions,
fileId
).then(r => {
return {
content: r.content,
etag: r.etag,
status: res.status,
serverTime: r.serverTime
};
});
}
export async function deleteIfEtagMatches(
googleDriveOptions: GoogleDriveOptionsWithDefaults,
fileId: string,
etag: string
): Promise<void> {
const url =
`${googleDriveOptions.apiEndpoint}` +
`/drive/v2/files/${encodeURIComponent(fileId)}`;
const res = await fetch(url, {
method: "DELETE",
headers: {
Authorization: `Bearer ${googleDriveOptions.authToken}`,
"If-Match": etag,
},
});
if (!res.ok) {
throw await newRxFetchError(res, {
args: {
etag,
fileId
}
});
}
if (res.ok) {
// Drive v2 returns 204 No Content on successful delete
return;
}
}
export async function deleteFile(
googleDriveOptions: GoogleDriveOptionsWithDefaults,
fileId: string
): Promise<void> {
const url =
`${googleDriveOptions.apiEndpoint}` +
`/drive/v2/files/${encodeURIComponent(fileId)}`;
const res = await fetch(url, {
method: "DELETE",
headers: {
Authorization: `Bearer ${googleDriveOptions.authToken}`,
},
});
if (!res.ok) {
throw await newRxFetchError(res, {
args: {
fileId
}
});
}
if (res.ok) {
// Drive v2 returns 204 No Content on successful delete
return;
}
}
export async function readJsonFileContent<T>(
googleDriveOptions: GoogleDriveOptionsWithDefaults,
fileId: string
): Promise<{
etag: string;
content: T | undefined;
serverTime: number;
}> {
const url =
`${googleDriveOptions.apiEndpoint}` +
`/drive/v2/files/${encodeURIComponent(fileId)}?alt=media`;
const res = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${googleDriveOptions.authToken}`,
Accept: "application/json",
},
});
if (!res.ok) {
throw await newRxFetchError(res, {
args: {
fileId
}
});
}
const dateHeader = res.headers.get('date');
const unixMs = Date.parse(ensureNotFalsy(dateHeader));
const contentType = res.headers.get("content-type") || "";
if (!contentType.includes("application/json")) {
const err = new Error("NOT_A_JSON_FILE but " + contentType);
(err as any).code = "NOT_A_JSON_FILE";
(err as any).contentType = contentType;
throw err;
}
const contentText = await res.text();
const content = contentText.length > 0 ? JSON.parse(contentText) : undefined;
const etag = ensureNotFalsy(res.headers.get('etag'));
return {
etag,
content: content as T,
serverTime: unixMs
};
}
export async function readFolder(
googleDriveOptions: GoogleDriveOptionsWithDefaults,
folderPath: string
): Promise<DriveFileMetadata[]> {
let parentId = driveRootParentId(googleDriveOptions);
const parts = folderPath.split('/').filter(p => p.length > 0);
// Resolve folder path
for (const part of parts) {
const query = "name = '" + part + "' and '" + parentId + "' in parents and trashed = false and mimeType = '" + FOLDER_MIME_TYPE + "'";
const searchUrl = googleDriveOptions.apiEndpoint + '/drive/v3/files?fields=files(id)&q=' + encodeURIComponent(query) + driveSpaceQuerySuffix(googleDriveOptions);
const searchResponse = await fetch(searchUrl, {
method: 'GET',
headers: {
Authorization: 'Bearer ' + googleDriveOptions.authToken
}
});
const searchData = await searchResponse.json();
if (searchData.files && searchData.files.length > 0) {
parentId = searchData.files[0].id;
} else {
throw newRxError('SNH', { folderPath });
}
}
// List children
const query = "'" + parentId + "' in parents and trashed = false";
const listUrl = googleDriveOptions.apiEndpoint + '/drive/v3/files?fields=files(id,name,mimeType,trashed,parents)&q=' + encodeURIComponent(query) + driveSpaceQuerySuffix(googleDriveOptions);
const listResponse = await fetch(listUrl, {
method: 'GET',
headers: {
Authorization: 'Bearer ' + googleDriveOptions.authToken
}
});
if (!listResponse.ok) {
throw await newRxFetchError(listResponse, {
folderName: folderPath
});
}
const listData = await listResponse.json();
return listData.files || [];
}
export async function insertMultipartFile<T>(
googleDriveOptions: GoogleDriveOptionsWithDefaults,
folderId: string,
filename: string,
jsonData: T
) {
const content = JSON.stringify(jsonData);
const metadata = {
name: filename,
mimeType: 'application/json',
parents: [folderId],
};
const postData = createMultipartBody(
metadata,
content
);
const res = await fetch(googleDriveOptions.apiEndpoint + "/upload/drive/v3/files?uploadType=multipart", {
method: 'POST',
headers: {
Authorization: `Bearer ${googleDriveOptions.authToken}`,
'Content-Type': 'multipart/related; boundary="' + postData.boundary + '"'
},
body: postData.body
});
if (!res.ok) {
throw await newRxFetchError(res);
}
}
export function createMultipartBody(
metadata: Record<string, unknown>,
content: string
) {
const multipartBoundary = '-------1337-use-RxDB-7355608-' + Math.random().toString(16).slice(2);
const delimiter = '\r\n--' + multipartBoundary + '\r\n';
const closeDelim = '\r\n--' + multipartBoundary + '--';
const body = delimiter +
'Content-Type: application/json\r\n\r\n' +
JSON.stringify(metadata) +
delimiter +
'Content-Type: application/json\r\n\r\n' +
content +
closeDelim;
return { body, boundary: multipartBoundary };
};
export async function listFilesInFolder(
googleDriveOptions: GoogleDriveOptionsWithDefaults,
folderId: string
): Promise<DriveFileMetadata[]> {
const q = `'${folderId}' in parents and trashed = false`;
const params = new URLSearchParams({
q,
pageSize: "1000", // max allowed
fields: "files(id,name,mimeType,parents,modifiedTime,size)",
supportsAllDrives: "true",
includeItemsFromAllDrives: "true",
});
applyDriveSpace(googleDriveOptions, params);
const url =
googleDriveOptions.apiEndpoint +
"/drive/v3/files?" +
params.toString();
const res = await fetch(url, {
headers: {
Authorization: `Bearer ${googleDriveOptions.authToken}`,
},
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`files.list failed: ${res.status} ${text}`);
}
const data = await res.json();
return data.files ?? [];
}