@open-webapp/drive-sync 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/SPEC.md +9 -4
- package/dist/connection.d.ts +24 -0
- package/dist/connection.js +31 -0
- package/dist/errors.d.ts +28 -0
- package/dist/errors.js +31 -0
- package/dist/files.d.ts +24 -1
- package/dist/files.js +170 -12
- package/dist/index.d.ts +20 -2
- package/dist/index.js +39 -1
- package/dist/picker.d.ts +95 -0
- package/dist/picker.js +140 -0
- package/dist/storage.d.ts +15 -1
- package/dist/storage.js +30 -0
- package/dist/testing/driveFake.d.ts +10 -0
- package/dist/testing/driveFake.js +12 -1
- package/dist/testing/gisFake.d.ts +6 -0
- package/dist/testing/gisFake.js +13 -0
- package/dist/testing/index.d.ts +2 -0
- package/dist/testing/index.js +1 -0
- package/dist/testing/pickerFake.d.ts +46 -0
- package/dist/testing/pickerFake.js +189 -0
- package/dist/token.js +11 -0
- package/dist/types.d.ts +42 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -24,6 +24,7 @@ await drive.reconcile(knownProjectIds)
|
|
|
24
24
|
|
|
25
25
|
const p = drive.project(projectId)
|
|
26
26
|
await p.connect()
|
|
27
|
+
const picked = await p.pickFile({ apiKey: PICKER_API_KEY, appId: GCP_PROJECT_NUMBER })
|
|
27
28
|
const folderId = await p.ensureFolderPath()
|
|
28
29
|
await p.files.write({ folderId, name: 'data.json', content: '{}', mimeType: 'application/json' })
|
|
29
30
|
```
|
package/SPEC.md
CHANGED
|
@@ -31,7 +31,9 @@ const p = drive.project(projectId);
|
|
|
31
31
|
await p.connect(); // interactive; prompt:'consent'
|
|
32
32
|
const conn = await p.getConnection(); // { email, needsReauth, expiresAt } | null
|
|
33
33
|
|
|
34
|
+
const picked = await p.pickFile({ apiKey: PICKER_API_KEY, appId: GCP_PROJECT_NUMBER }); // file-selection via Google Picker
|
|
34
35
|
const folderId = await p.ensureFolderPath();
|
|
36
|
+
const token = await p.getAccessToken(); // raw token for advanced/custom Picker wiring; pickFile() is the preferred path for most uses
|
|
35
37
|
const files = await p.files.list({ folderId });
|
|
36
38
|
const text = await p.files.read(fileId); // string | Blob | null (null on 404)
|
|
37
39
|
const ref = await p.files.write({ folderId, name: 'x.json', content, mimeType: 'application/json' });
|
|
@@ -44,9 +46,11 @@ dispose();
|
|
|
44
46
|
|
|
45
47
|
`createDriveSync()` itself attaches no listeners and makes no network calls. Every Drive-op call site accepts an optional `{ interactive?: boolean }` (default `false`) and resolves its own token internally — no caller ever threads a token or a `projectId` string into an HTTP call by hand.
|
|
46
48
|
|
|
47
|
-
Files implementing the surface: `index.ts` (factory + `ProjectHandle`/`FilesHandle`/`PermissionsHandle`), `connection.ts` (`connect`/`getConnection`/`disconnect`/`refreshSilently`), `files.ts`, `permissions.ts`, `reconcile.ts`, `refresh.ts` (`activate`/warm-up), `errors.ts` (typed error classes), `types.ts` (`DriveSyncOptions`, `Connection`, `StoredToken`, `FileRef`, `DrivePermission`, `CallOptions`).
|
|
49
|
+
Files implementing the surface: `index.ts` (factory + `ProjectHandle`/`FilesHandle`/`PermissionsHandle`), `connection.ts` (`connect`/`getConnection`/`disconnect`/`refreshSilently`/`getAccessToken`), `files.ts`, `permissions.ts`, `reconcile.ts`, `refresh.ts` (`activate`/warm-up), `picker.ts` (Google Picker integration), `errors.ts` (typed error classes), `types.ts` (`DriveSyncOptions`, `Connection`, `StoredToken`, `FileRef`, `DrivePermission`, `CallOptions`).
|
|
48
50
|
|
|
49
|
-
|
|
51
|
+
`getAccessToken()` is the one deliberate exception to `Connection` never exposing secret material (types.ts): it exists solely so an app can feed the token to Google Picker (`setOAuthToken()`), which runs outside this library's control and has no other way to read it. Reuses a cached token while it has more than 5 minutes left; otherwise acquires one (interactive by default, since callers use this to drive a UI the user is actively interacting with).
|
|
52
|
+
|
|
53
|
+
## 2. The 35 resolved design decisions
|
|
50
54
|
|
|
51
55
|
**Bugs fixed (both source apps carried these):**
|
|
52
56
|
|
|
@@ -84,10 +88,11 @@ Files implementing the surface: `index.ts` (factory + `ProjectHandle`/`FilesHand
|
|
|
84
88
|
28. **No `folderId`/`fileId` persistence** — `ensureFolderPath()` and `write()` both return ids to the caller; nothing in `storage.ts`'s schema has a field for either.
|
|
85
89
|
29. **One IndexedDB DB per project** — `storage.ts`'s `dbName(appId, projectId)` → `owa-drive-{appId}-{projectId}`, opened at version `1` with a single object store named `auth`.
|
|
86
90
|
30. **`conn`/`token` split** — `storage.ts` stores a durable `ConnRecord` under key `'conn'` and an ephemeral `StoredToken` under key `'token'` in the same `auth` store; `clearToken` deletes only the `'token'` key.
|
|
87
|
-
31. **Cross-tab BroadcastChannel —
|
|
91
|
+
31. **Cross-tab BroadcastChannel — fully wired.** `broadcast.ts` implements `createBroadcast(appId)` with `postLogout`, `postToken`, and `onMessage`, channel-named `owa-drive-{appId}`, feature-detected to a no-op where `BroadcastChannel` is absent. `connection.ts`'s `disconnect()` calls `postLogout`; `token.ts`'s `acquireToken` calls `postToken` after every successful acquisition (interactive `connect()`, silent `refreshSilently()`, and the plain warm-up fallback alike — one choke point right after the token lands in IndexedDB). `index.ts`'s `activate()` subscribes via `onMessage`: a `logout` message evicts this tab's cached IDB handle for that project (`evictDbHandle`) so a subsequent read sees the other tab's cleared storage; a `token` message calls `token.ts`'s `notifyExternalTokenRefresh(projectId)`, which lets this tab's next non-interactive `acquireToken` for that project skip its own GIS round-trip and re-read the fresh token from shared storage instead. Both directions are only live between `activate()` and its disposer — a project handle used without ever calling `.activate()` still reads/writes the same IndexedDB, just without the cross-tab shortcut.
|
|
88
92
|
32. **`reconcile`/`dropProject`** — `reconcile.ts`: `reconcile(appId, knownProjectIds)` enumerates via `indexedDB.databases()` and deletes any `owa-drive-{appId}-*` DB not in the known set; `dropProject(appId, projectId)` deletes one DB eagerly and evicts its cached handle.
|
|
89
93
|
33. **No timer; warm-up on `visibilitychange`/`pageshow`** — `refresh.ts`'s `activate()` attaches both listeners (only when called; none at import time), gated on `document.visibilityState === 'visible'` / `event.persisted && !document.hidden`; `warmUpIfNeeded` only fires if a connection exists **and** the token is missing or within a 5-minute buffer (`REFRESH_BUFFER_MS`) of expiry. `index.ts`'s top-level `activate()` also layers a `trackedProjectIds` Set so one global listener pair drives warm-ups for every project ever passed to `.project(id)`.
|
|
90
94
|
34. **`interactive` option, default `false`** — every `BaseCallOptions`-shaped call in `files.ts`/`permissions.ts`/`http.ts` defaults `interactive` to falsy; a non-interactive call with no usable token throws `NeedsReauthError` rather than silently prompting.
|
|
95
|
+
35. **Google Picker integration** — `picker.ts`'s `pickFile` accepts an `apiKey` and an `appId` (the OAuth client's Cloud project number) per-call (not stored in `DriveSyncOptions`); `appId` is mandatory because drive-sync holds only a `drive.file`-scoped token and Picker rejects a scoped session it cannot attribute to an app — omitting it makes Picker drop the OAuth token, show its own sign-in prompt, and fail with "The API developer key is invalid"; `index.ts` and `connection.ts` resolve the token, but `picker.ts` only ever sees a plain string token to avoid secret exposure. Script loading is cached at module level to avoid repeated GIS-loader calls. On user cancel, `PickerCancelledError` is thrown; on success, `FileRef` is returned. Drive scope prerequisites and token refresh are handled transparently (`picker.ts` takes the token and makes the Picker call; no token-boundary complexity leaks to callers).
|
|
91
96
|
|
|
92
97
|
## 3. Storage layout
|
|
93
98
|
|
|
@@ -146,5 +151,5 @@ Wrong-account detection therefore covers exactly two silent paths — the 401-re
|
|
|
146
151
|
- **`reconcile()` degrades to a no-op** where `indexedDB.databases()` is unsupported (Firefox, older Safari at time of writing). On those browsers, orphaned per-project auth databases from deleted projects are never automatically reclaimed unless the app calls `dropProject(id)` eagerly when it deletes the project — `reconcile()` is a safety net, not the primary cleanup mechanism.
|
|
147
152
|
- **`files.ts`'s `read()` returns `null` on 404**, and that single value conflates two different situations: a genuinely wrong/nonexistent `fileId`, and a file that exists but that the currently-authenticated account cannot see (e.g. connected as the wrong Google account). The library cannot distinguish these — Drive itself returns an identical 404 for both — so callers that want to give an honest error message need to account for both cases themselves.
|
|
148
153
|
- **Wrong-account detection is not universal.** As detailed in §4, it is implemented once, inside `connection.ts`'s `refreshSilently`, and is only reached via two call sites: the 401-triggered silent refresh in `http.ts`, and the proactive warm-up in `refresh.ts` (when a `fetchEmail` resolver is supplied — `index.ts` always supplies one). It is **not** checked on the interactive `connect()` path, and the `refresh.ts` fallback branch that calls `acquireToken` directly (used only when no `fetchEmail` is configured) bypasses it entirely. A non-401 Drive call that succeeds against a token silently swapped to the wrong account (rather than expiring first) would not be caught until some later 401 or explicit `getConnection()`/email check.
|
|
149
|
-
- **Cross-tab
|
|
154
|
+
- **Cross-tab token sharing is best-effort, not a guarantee.** `notifyExternalTokenRefresh` (§4, decision #31) only ever skips ONE subsequent GIS round-trip per `token` broadcast received — a one-shot flag, not a durable "this project is externally fresh" cache. If two tabs both attempt a refresh in the same narrow window, both can still end up making their own GIS calls.
|
|
150
155
|
- **`ensureFolderPath()`'s root-level lookup has no anchor.** Because the library only holds the `drive.file` scope, the first path segment is searched for by name/mimeType with no `in parents` constraint (every subsequent level is unambiguous, anchored to the previous level's id). Two folders with the same name at the top level anywhere the app can see are indistinguishable to this lookup; the first match wins.
|
package/dist/connection.d.ts
CHANGED
|
@@ -63,6 +63,30 @@ export interface GetConnectionOptions {
|
|
|
63
63
|
* granted scopes on the stored connection are missing any required scope.
|
|
64
64
|
*/
|
|
65
65
|
export declare function getConnection(opts: GetConnectionOptions): Promise<Connection | null>;
|
|
66
|
+
export interface GetAccessTokenOptions {
|
|
67
|
+
appId: string;
|
|
68
|
+
projectId: string;
|
|
69
|
+
clientId: string;
|
|
70
|
+
scopes: string[];
|
|
71
|
+
/** Whether an interactive (popup) auth flow may be triggered if no usable cached token exists. */
|
|
72
|
+
interactive: boolean;
|
|
73
|
+
logger?: Logger;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Returns a raw OAuth access token for callers that must hand it directly to
|
|
77
|
+
* a Google-hosted widget this library does not control (namely Google
|
|
78
|
+
* Picker, which requires `setOAuthToken()`). This is a deliberate, narrow
|
|
79
|
+
* exception to Connection's "no secret material" contract documented in
|
|
80
|
+
* types.ts: Picker runs in Google's own popup/iframe and has no way to read
|
|
81
|
+
* a token this library keeps private, so the token must leave the library
|
|
82
|
+
* for that one integration to work at all. Callers should request this only
|
|
83
|
+
* to feed it straight to Picker, not to make their own Drive API calls
|
|
84
|
+
* (use `files`/`permissions` for that).
|
|
85
|
+
*
|
|
86
|
+
* Reuses a still-valid cached token as-is; otherwise acquires a fresh one
|
|
87
|
+
* via the normal token flow (interactive per `opts.interactive`).
|
|
88
|
+
*/
|
|
89
|
+
export declare function getAccessToken(opts: GetAccessTokenOptions): Promise<string>;
|
|
66
90
|
export interface DisconnectOptions {
|
|
67
91
|
appId: string;
|
|
68
92
|
projectId: string;
|
package/dist/connection.js
CHANGED
|
@@ -2,6 +2,8 @@ import { getConn, setConn, clearConn, getToken, clearToken } from './storage.js'
|
|
|
2
2
|
import { createBroadcast } from './broadcast.js';
|
|
3
3
|
import { acquireToken } from './token.js';
|
|
4
4
|
import { WrongAccountError } from './errors.js';
|
|
5
|
+
/** Mirrors refresh.ts's own buffer: a cached token this close to expiry is treated as unusable. */
|
|
6
|
+
const TOKEN_REUSE_BUFFER_MS = 5 * 60 * 1000;
|
|
5
7
|
/**
|
|
6
8
|
* Interactive connection flow: acquires a token with prompt: 'consent',
|
|
7
9
|
* resolves the account email, and persists the durable Connection record.
|
|
@@ -74,6 +76,35 @@ export async function getConnection(opts) {
|
|
|
74
76
|
expiresAt: token?.expiresAt ?? null,
|
|
75
77
|
};
|
|
76
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* Returns a raw OAuth access token for callers that must hand it directly to
|
|
81
|
+
* a Google-hosted widget this library does not control (namely Google
|
|
82
|
+
* Picker, which requires `setOAuthToken()`). This is a deliberate, narrow
|
|
83
|
+
* exception to Connection's "no secret material" contract documented in
|
|
84
|
+
* types.ts: Picker runs in Google's own popup/iframe and has no way to read
|
|
85
|
+
* a token this library keeps private, so the token must leave the library
|
|
86
|
+
* for that one integration to work at all. Callers should request this only
|
|
87
|
+
* to feed it straight to Picker, not to make their own Drive API calls
|
|
88
|
+
* (use `files`/`permissions` for that).
|
|
89
|
+
*
|
|
90
|
+
* Reuses a still-valid cached token as-is; otherwise acquires a fresh one
|
|
91
|
+
* via the normal token flow (interactive per `opts.interactive`).
|
|
92
|
+
*/
|
|
93
|
+
export async function getAccessToken(opts) {
|
|
94
|
+
const cached = await getToken(opts.appId, opts.projectId);
|
|
95
|
+
if (cached && cached.expiresAt > Date.now() + TOKEN_REUSE_BUFFER_MS) {
|
|
96
|
+
return cached.accessToken;
|
|
97
|
+
}
|
|
98
|
+
const token = await acquireToken({
|
|
99
|
+
appId: opts.appId,
|
|
100
|
+
projectId: opts.projectId,
|
|
101
|
+
clientId: opts.clientId,
|
|
102
|
+
scopes: opts.scopes,
|
|
103
|
+
interactive: opts.interactive,
|
|
104
|
+
logger: opts.logger,
|
|
105
|
+
});
|
|
106
|
+
return token.accessToken;
|
|
107
|
+
}
|
|
77
108
|
/**
|
|
78
109
|
* Disconnects a project: revokes the cached token (if any and if a
|
|
79
110
|
* revokeFn was supplied), then unconditionally clears both the durable
|
package/dist/errors.d.ts
CHANGED
|
@@ -58,7 +58,35 @@ export declare class RateLimitedError extends DriveSyncError {
|
|
|
58
58
|
export declare class TransientError extends DriveSyncError {
|
|
59
59
|
constructor(message?: string, opts?: DriveSyncErrorOptions);
|
|
60
60
|
}
|
|
61
|
+
export interface RemoteChangedErrorOptions extends DriveSyncErrorOptions {
|
|
62
|
+
fileId: string;
|
|
63
|
+
/** Version this client last restored, or null if it has never restored this file. */
|
|
64
|
+
baseVersion: string | null;
|
|
65
|
+
/** Version currently on Drive. */
|
|
66
|
+
remoteVersion: string;
|
|
67
|
+
reason: 'remote-changed' | 'never-restored';
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Thrown when a write would clobber remote content this client has not seen.
|
|
71
|
+
*
|
|
72
|
+
* There is deliberately no `force` option: the only way past this error is to
|
|
73
|
+
* restore the remote file with `files.read()`, which records the remote
|
|
74
|
+
* version as the new baseline. From there the caller may either merge the
|
|
75
|
+
* remote content into their own and write the merged result, or discard the
|
|
76
|
+
* remote content and write their local version — but both paths require
|
|
77
|
+
* having pulled the changed file first.
|
|
78
|
+
*/
|
|
79
|
+
export declare class RemoteChangedError extends DriveSyncError {
|
|
80
|
+
fileId: string;
|
|
81
|
+
baseVersion: string | null;
|
|
82
|
+
remoteVersion: string;
|
|
83
|
+
constructor(opts: RemoteChangedErrorOptions);
|
|
84
|
+
}
|
|
61
85
|
/** Thrown when the Google Identity Services script never loaded within the timeout. */
|
|
62
86
|
export declare class GisLoadError extends Error {
|
|
63
87
|
constructor(message?: string);
|
|
64
88
|
}
|
|
89
|
+
/** Thrown when the user cancels the Google Picker dialog. */
|
|
90
|
+
export declare class PickerCancelledError extends DriveSyncError {
|
|
91
|
+
constructor(message?: string, opts?: DriveSyncErrorOptions);
|
|
92
|
+
}
|
package/dist/errors.js
CHANGED
|
@@ -75,6 +75,30 @@ export class TransientError extends DriveSyncError {
|
|
|
75
75
|
this.name = 'TransientError';
|
|
76
76
|
}
|
|
77
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* Thrown when a write would clobber remote content this client has not seen.
|
|
80
|
+
*
|
|
81
|
+
* There is deliberately no `force` option: the only way past this error is to
|
|
82
|
+
* restore the remote file with `files.read()`, which records the remote
|
|
83
|
+
* version as the new baseline. From there the caller may either merge the
|
|
84
|
+
* remote content into their own and write the merged result, or discard the
|
|
85
|
+
* remote content and write their local version — but both paths require
|
|
86
|
+
* having pulled the changed file first.
|
|
87
|
+
*/
|
|
88
|
+
export class RemoteChangedError extends DriveSyncError {
|
|
89
|
+
fileId;
|
|
90
|
+
baseVersion;
|
|
91
|
+
remoteVersion;
|
|
92
|
+
constructor(opts) {
|
|
93
|
+
super(opts.reason === 'never-restored'
|
|
94
|
+
? `File ${opts.fileId} has never been restored by this client; read() it before writing`
|
|
95
|
+
: `File ${opts.fileId} changed on Drive since it was last restored`, { status: 409, ...opts });
|
|
96
|
+
this.name = 'RemoteChangedError';
|
|
97
|
+
this.fileId = opts.fileId;
|
|
98
|
+
this.baseVersion = opts.baseVersion;
|
|
99
|
+
this.remoteVersion = opts.remoteVersion;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
78
102
|
/** Thrown when the Google Identity Services script never loaded within the timeout. */
|
|
79
103
|
export class GisLoadError extends Error {
|
|
80
104
|
constructor(message = 'Google Identity Services failed to load in time') {
|
|
@@ -82,3 +106,10 @@ export class GisLoadError extends Error {
|
|
|
82
106
|
this.name = 'GisLoadError';
|
|
83
107
|
}
|
|
84
108
|
}
|
|
109
|
+
/** Thrown when the user cancels the Google Picker dialog. */
|
|
110
|
+
export class PickerCancelledError extends DriveSyncError {
|
|
111
|
+
constructor(message = 'User cancelled the picker', opts) {
|
|
112
|
+
super(message, opts);
|
|
113
|
+
this.name = 'PickerCancelledError';
|
|
114
|
+
}
|
|
115
|
+
}
|
package/dist/files.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Logger } from './logger.js';
|
|
2
|
-
import type { FileRef } from './types.js';
|
|
2
|
+
import type { FileRef, FileState } from './types.js';
|
|
3
3
|
/** Scopes this library always requests/requires. */
|
|
4
4
|
export declare const REQUIRED_SCOPES: string[];
|
|
5
5
|
interface BaseCallOptions {
|
|
@@ -39,8 +39,31 @@ export interface WriteOptions extends BaseCallOptions {
|
|
|
39
39
|
* so file content that happens to contain a literal boundary-looking string
|
|
40
40
|
* can never corrupt the request (a known bug in a hand-rolled-boundary
|
|
41
41
|
* implementation this replaces).
|
|
42
|
+
*
|
|
43
|
+
* When no fileId is given but a name is, an existing non-trashed file with
|
|
44
|
+
* that name (within `folderId`, when given) is looked up first and updated
|
|
45
|
+
* in place. Without this, a caller that syncs by name — having no id to
|
|
46
|
+
* hand back, e.g. on a fresh page load — would mint a brand-new Drive file
|
|
47
|
+
* on every single sync instead of updating the file already in use.
|
|
48
|
+
*
|
|
49
|
+
* Every update path is guarded: if the file changed on Drive since this
|
|
50
|
+
* client last restored it (or was never restored here at all), the write is
|
|
51
|
+
* refused with a RemoteChangedError instead of clobbering someone else's
|
|
52
|
+
* edit. See that error's docs for the two ways forward.
|
|
42
53
|
*/
|
|
43
54
|
export declare function write(opts: WriteOptions): Promise<FileRef>;
|
|
55
|
+
export interface StatusOptions extends BaseCallOptions {
|
|
56
|
+
fileId: string;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Cheap metadata-only drift probe — the primitive an app polls to notify the
|
|
60
|
+
* user that the file in use has moved on. Downloads no content, so it is safe
|
|
61
|
+
* to call on a timer or on window focus.
|
|
62
|
+
*
|
|
63
|
+
* `exists: false` means the file is gone (or invisible to this account).
|
|
64
|
+
* `changedSinceRestore: true` means a write would currently be refused.
|
|
65
|
+
*/
|
|
66
|
+
export declare function status(opts: StatusOptions): Promise<FileState>;
|
|
44
67
|
export interface ListOptions extends BaseCallOptions {
|
|
45
68
|
folderId?: string;
|
|
46
69
|
mimeType?: string;
|
package/dist/files.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { driveFetch } from './http.js';
|
|
2
2
|
import { escapeQ } from './query.js';
|
|
3
|
-
import { NotFoundError } from './errors.js';
|
|
3
|
+
import { NotFoundError, RemoteChangedError } from './errors.js';
|
|
4
|
+
import { getFileState, setFileState, clearFileState } from './storage.js';
|
|
4
5
|
const DRIVE_BASE = 'https://www.googleapis.com/drive/v3';
|
|
5
6
|
const UPLOAD_BASE = 'https://www.googleapis.com/upload/drive/v3';
|
|
6
7
|
const FOLDER_MIME_TYPE = 'application/vnd.google-apps.folder';
|
|
@@ -9,6 +10,44 @@ export const REQUIRED_SCOPES = [
|
|
|
9
10
|
'https://www.googleapis.com/auth/drive.file',
|
|
10
11
|
'https://www.googleapis.com/auth/userinfo.email',
|
|
11
12
|
];
|
|
13
|
+
/**
|
|
14
|
+
* Fetches just the fields needed for conflict detection. Returns null on a
|
|
15
|
+
* 404, matching read()'s treatment of a missing/invisible file.
|
|
16
|
+
*/
|
|
17
|
+
async function fetchRemoteVersion(opts) {
|
|
18
|
+
try {
|
|
19
|
+
const res = await driveFetch({
|
|
20
|
+
appId: opts.appId,
|
|
21
|
+
projectId: opts.projectId,
|
|
22
|
+
clientId: opts.clientId,
|
|
23
|
+
url: `${DRIVE_BASE}/files/${encodeURIComponent(opts.fileId)}?fields=${encodeURIComponent('id,name,version,modifiedTime')}`,
|
|
24
|
+
method: 'GET',
|
|
25
|
+
interactive: opts.interactive,
|
|
26
|
+
requiredScopes: REQUIRED_SCOPES,
|
|
27
|
+
logger: opts.logger,
|
|
28
|
+
fetchEmail: opts.fetchEmail,
|
|
29
|
+
});
|
|
30
|
+
const json = (await res.json());
|
|
31
|
+
if (json.version === undefined)
|
|
32
|
+
return null;
|
|
33
|
+
return { version: String(json.version), name: json.name, modifiedTime: json.modifiedTime };
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
if (err instanceof NotFoundError)
|
|
37
|
+
return null;
|
|
38
|
+
throw err;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** Records `version` as this client's baseline for `fileId`. */
|
|
42
|
+
async function recordBaseline(opts, fileId, version) {
|
|
43
|
+
if (version === undefined)
|
|
44
|
+
return;
|
|
45
|
+
await setFileState(opts.appId, opts.projectId, {
|
|
46
|
+
fileId,
|
|
47
|
+
version: String(version),
|
|
48
|
+
syncedAt: Date.now(),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
12
51
|
/**
|
|
13
52
|
* Fetches a file's content. Returns `null` on a 404 rather than throwing,
|
|
14
53
|
* since Drive 404s both for a genuinely wrong id and for a file the
|
|
@@ -18,6 +57,13 @@ export const REQUIRED_SCOPES = [
|
|
|
18
57
|
*/
|
|
19
58
|
export async function read(opts) {
|
|
20
59
|
try {
|
|
60
|
+
// Sampled BEFORE the content download, not after: if someone writes to
|
|
61
|
+
// the file mid-read, the baseline recorded below is then older than
|
|
62
|
+
// what is on Drive, so the next write is refused and the caller
|
|
63
|
+
// restores again. Sampling afterwards would instead mark an edit this
|
|
64
|
+
// caller never received as "seen", which is exactly the clobber this
|
|
65
|
+
// tracking exists to prevent.
|
|
66
|
+
const meta = await fetchRemoteVersion(opts);
|
|
21
67
|
const res = await driveFetch({
|
|
22
68
|
appId: opts.appId,
|
|
23
69
|
projectId: opts.projectId,
|
|
@@ -33,7 +79,13 @@ export async function read(opts) {
|
|
|
33
79
|
const isTextual = contentType === '' ||
|
|
34
80
|
contentType.startsWith('text/') ||
|
|
35
81
|
contentType.includes('application/json');
|
|
36
|
-
|
|
82
|
+
const content = isTextual ? await res.text() : await res.blob();
|
|
83
|
+
// Restoring the file makes the version just sampled this client's new
|
|
84
|
+
// baseline: the caller has now seen whatever anyone else wrote, so a
|
|
85
|
+
// subsequent write is no longer a blind clobber. This is the ONLY way a
|
|
86
|
+
// RemoteChangedError is cleared, which is why it runs on every read.
|
|
87
|
+
await recordBaseline(opts, opts.fileId, meta?.version);
|
|
88
|
+
return content;
|
|
37
89
|
}
|
|
38
90
|
catch (err) {
|
|
39
91
|
if (err instanceof NotFoundError) {
|
|
@@ -54,10 +106,64 @@ export async function remove(opts) {
|
|
|
54
106
|
logger: opts.logger,
|
|
55
107
|
fetchEmail: opts.fetchEmail,
|
|
56
108
|
});
|
|
109
|
+
// The baseline describes a file that no longer exists; leaving it behind
|
|
110
|
+
// would make a later file reusing this id look spuriously in sync.
|
|
111
|
+
await clearFileState(opts.appId, opts.projectId, opts.fileId);
|
|
57
112
|
}
|
|
58
113
|
function toBlob(content, mimeType) {
|
|
59
114
|
return content instanceof Blob ? content : new Blob([content], { type: mimeType });
|
|
60
115
|
}
|
|
116
|
+
/**
|
|
117
|
+
* Refuses the write unless this client's baseline matches what is on Drive
|
|
118
|
+
* right now. `knownRemoteVersion` lets the name-resolution path reuse the
|
|
119
|
+
* version it already got from list() instead of paying a second round trip.
|
|
120
|
+
*/
|
|
121
|
+
async function assertNotStale(opts, fileId, knownRemoteVersion) {
|
|
122
|
+
const remoteVersion = knownRemoteVersion ?? (await fetchRemoteVersion({ ...opts, fileId }))?.version;
|
|
123
|
+
// No version reported (a fake/older backend that does not expose `version`,
|
|
124
|
+
// or a file that has just vanished) — nothing to compare against, so fall
|
|
125
|
+
// through rather than blocking the caller on a check we cannot perform.
|
|
126
|
+
if (remoteVersion === undefined)
|
|
127
|
+
return;
|
|
128
|
+
const baseline = await getFileState(opts.appId, opts.projectId, fileId);
|
|
129
|
+
if (!baseline) {
|
|
130
|
+
throw new RemoteChangedError({
|
|
131
|
+
fileId,
|
|
132
|
+
baseVersion: null,
|
|
133
|
+
remoteVersion,
|
|
134
|
+
reason: 'never-restored',
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
if (baseline.version !== remoteVersion) {
|
|
138
|
+
throw new RemoteChangedError({
|
|
139
|
+
fileId,
|
|
140
|
+
baseVersion: baseline.version,
|
|
141
|
+
remoteVersion,
|
|
142
|
+
reason: 'remote-changed',
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
async function updateContent(opts, fileId, knownRemoteVersion) {
|
|
147
|
+
await assertNotStale(opts, fileId, knownRemoteVersion);
|
|
148
|
+
const res = await driveFetch({
|
|
149
|
+
appId: opts.appId,
|
|
150
|
+
projectId: opts.projectId,
|
|
151
|
+
clientId: opts.clientId,
|
|
152
|
+
url: `${UPLOAD_BASE}/files/${encodeURIComponent(fileId)}?uploadType=media&fields=${encodeURIComponent('id,name,version')}`,
|
|
153
|
+
method: 'PATCH',
|
|
154
|
+
headers: { 'Content-Type': opts.mimeType },
|
|
155
|
+
body: opts.content,
|
|
156
|
+
interactive: opts.interactive,
|
|
157
|
+
requiredScopes: REQUIRED_SCOPES,
|
|
158
|
+
logger: opts.logger,
|
|
159
|
+
fetchEmail: opts.fetchEmail,
|
|
160
|
+
});
|
|
161
|
+
const json = (await res.json());
|
|
162
|
+
// The version we just produced becomes the new baseline, so back-to-back
|
|
163
|
+
// writes from this client never trip the staleness guard on themselves.
|
|
164
|
+
await recordBaseline(opts, json.id, json.version);
|
|
165
|
+
return { id: json.id, name: json.name ?? opts.name };
|
|
166
|
+
}
|
|
61
167
|
/**
|
|
62
168
|
* Creates or updates a file's content. Updates (fileId provided) use the
|
|
63
169
|
* media-upload endpoint with the raw body. Creates use a `FormData`
|
|
@@ -65,24 +171,36 @@ function toBlob(content, mimeType) {
|
|
|
65
171
|
* so file content that happens to contain a literal boundary-looking string
|
|
66
172
|
* can never corrupt the request (a known bug in a hand-rolled-boundary
|
|
67
173
|
* implementation this replaces).
|
|
174
|
+
*
|
|
175
|
+
* When no fileId is given but a name is, an existing non-trashed file with
|
|
176
|
+
* that name (within `folderId`, when given) is looked up first and updated
|
|
177
|
+
* in place. Without this, a caller that syncs by name — having no id to
|
|
178
|
+
* hand back, e.g. on a fresh page load — would mint a brand-new Drive file
|
|
179
|
+
* on every single sync instead of updating the file already in use.
|
|
180
|
+
*
|
|
181
|
+
* Every update path is guarded: if the file changed on Drive since this
|
|
182
|
+
* client last restored it (or was never restored here at all), the write is
|
|
183
|
+
* refused with a RemoteChangedError instead of clobbering someone else's
|
|
184
|
+
* edit. See that error's docs for the two ways forward.
|
|
68
185
|
*/
|
|
69
186
|
export async function write(opts) {
|
|
70
187
|
if (opts.fileId) {
|
|
71
|
-
|
|
188
|
+
return updateContent(opts, opts.fileId);
|
|
189
|
+
}
|
|
190
|
+
if (opts.name) {
|
|
191
|
+
const existing = await list({
|
|
72
192
|
appId: opts.appId,
|
|
73
193
|
projectId: opts.projectId,
|
|
74
194
|
clientId: opts.clientId,
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
headers: { 'Content-Type': opts.mimeType },
|
|
78
|
-
body: opts.content,
|
|
195
|
+
folderId: opts.folderId,
|
|
196
|
+
nameEquals: opts.name,
|
|
79
197
|
interactive: opts.interactive,
|
|
80
|
-
requiredScopes: REQUIRED_SCOPES,
|
|
81
198
|
logger: opts.logger,
|
|
82
199
|
fetchEmail: opts.fetchEmail,
|
|
83
200
|
});
|
|
84
|
-
|
|
85
|
-
|
|
201
|
+
if (existing.length > 0) {
|
|
202
|
+
return updateContent(opts, existing[0].id, existing[0].version);
|
|
203
|
+
}
|
|
86
204
|
}
|
|
87
205
|
const metadata = {
|
|
88
206
|
name: opts.name,
|
|
@@ -107,7 +225,7 @@ export async function write(opts) {
|
|
|
107
225
|
appId: opts.appId,
|
|
108
226
|
projectId: opts.projectId,
|
|
109
227
|
clientId: opts.clientId,
|
|
110
|
-
url: `${UPLOAD_BASE}/files?uploadType=multipart&fields
|
|
228
|
+
url: `${UPLOAD_BASE}/files?uploadType=multipart&fields=${encodeURIComponent('id,version')}`,
|
|
111
229
|
method: 'POST',
|
|
112
230
|
headers: multipartContentType ? { 'Content-Type': multipartContentType } : undefined,
|
|
113
231
|
body: multipartBody,
|
|
@@ -117,8 +235,46 @@ export async function write(opts) {
|
|
|
117
235
|
fetchEmail: opts.fetchEmail,
|
|
118
236
|
});
|
|
119
237
|
const json = (await res.json());
|
|
238
|
+
// This client authored the file, so it starts out fully in sync.
|
|
239
|
+
await recordBaseline(opts, json.id, json.version);
|
|
120
240
|
return { id: json.id, name: opts.name };
|
|
121
241
|
}
|
|
242
|
+
/**
|
|
243
|
+
* Cheap metadata-only drift probe — the primitive an app polls to notify the
|
|
244
|
+
* user that the file in use has moved on. Downloads no content, so it is safe
|
|
245
|
+
* to call on a timer or on window focus.
|
|
246
|
+
*
|
|
247
|
+
* `exists: false` means the file is gone (or invisible to this account).
|
|
248
|
+
* `changedSinceRestore: true` means a write would currently be refused.
|
|
249
|
+
*/
|
|
250
|
+
export async function status(opts) {
|
|
251
|
+
const [meta, baseline] = await Promise.all([
|
|
252
|
+
fetchRemoteVersion(opts),
|
|
253
|
+
getFileState(opts.appId, opts.projectId, opts.fileId),
|
|
254
|
+
]);
|
|
255
|
+
if (!meta) {
|
|
256
|
+
return {
|
|
257
|
+
fileId: opts.fileId,
|
|
258
|
+
exists: false,
|
|
259
|
+
baseVersion: baseline?.version ?? null,
|
|
260
|
+
remoteVersion: null,
|
|
261
|
+
changedSinceRestore: false,
|
|
262
|
+
lastRestoredAt: baseline?.syncedAt ?? null,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
return {
|
|
266
|
+
fileId: opts.fileId,
|
|
267
|
+
exists: true,
|
|
268
|
+
baseVersion: baseline?.version ?? null,
|
|
269
|
+
remoteVersion: meta.version,
|
|
270
|
+
// Never having restored the file counts as drift: this client cannot show
|
|
271
|
+
// that what it holds descends from what is on Drive, which is exactly the
|
|
272
|
+
// condition a write is refused under.
|
|
273
|
+
changedSinceRestore: !baseline || baseline.version !== meta.version,
|
|
274
|
+
remoteModifiedTime: meta.modifiedTime,
|
|
275
|
+
lastRestoredAt: baseline?.syncedAt ?? null,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
122
278
|
function buildQuery(opts) {
|
|
123
279
|
const clauses = [];
|
|
124
280
|
if (opts.folderId) {
|
|
@@ -135,7 +291,9 @@ function buildQuery(opts) {
|
|
|
135
291
|
}
|
|
136
292
|
export async function list(opts) {
|
|
137
293
|
const q = buildQuery(opts);
|
|
138
|
-
|
|
294
|
+
// `version` comes back so the name-resolution path in write() can run its
|
|
295
|
+
// staleness check without a follow-up metadata fetch.
|
|
296
|
+
const url = `${DRIVE_BASE}/files?q=${encodeURIComponent(q)}&fields=${encodeURIComponent('files(id,name,mimeType,version)')}`;
|
|
139
297
|
const res = await driveFetch({
|
|
140
298
|
appId: opts.appId,
|
|
141
299
|
projectId: opts.projectId,
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { CallOptions, Connection, DriveSyncOptions, DrivePermission, FileRef } from './types.js';
|
|
2
|
-
export type { DriveSyncOptions, Connection, StoredToken, FileRef, DrivePermission, CallOptions } from './types.js';
|
|
1
|
+
import type { CallOptions, Connection, DriveSyncOptions, DrivePermission, FileRef, FileState, PickFileOptions, PickedFile } from './types.js';
|
|
2
|
+
export type { DriveSyncOptions, Connection, StoredToken, FileRef, FileState, DrivePermission, CallOptions, WorkspaceMimeShorthand, PickFileOptions, PickedFile } from './types.js';
|
|
3
3
|
export * from './errors.js';
|
|
4
4
|
export interface FilesHandle {
|
|
5
5
|
list(opts?: {
|
|
@@ -7,7 +7,16 @@ export interface FilesHandle {
|
|
|
7
7
|
mimeType?: string;
|
|
8
8
|
nameEquals?: string;
|
|
9
9
|
}, callOpts?: CallOptions): Promise<FileRef[]>;
|
|
10
|
+
/**
|
|
11
|
+
* Fetches content AND records the file's current remote version as this
|
|
12
|
+
* client's baseline — the only way to clear a RemoteChangedError.
|
|
13
|
+
*/
|
|
10
14
|
read(fileId: string, callOpts?: CallOptions): Promise<string | Blob | null>;
|
|
15
|
+
/**
|
|
16
|
+
* Metadata-only check for whether the file changed since it was last
|
|
17
|
+
* restored here. Downloads no content; safe to poll.
|
|
18
|
+
*/
|
|
19
|
+
status(fileId: string, callOpts?: CallOptions): Promise<FileState>;
|
|
11
20
|
write(opts: {
|
|
12
21
|
fileId?: string;
|
|
13
22
|
folderId?: string;
|
|
@@ -40,6 +49,15 @@ export interface ProjectHandle {
|
|
|
40
49
|
getConnection(): Promise<Connection | null>;
|
|
41
50
|
disconnect(): Promise<void>;
|
|
42
51
|
ensureFolderPath(): Promise<string>;
|
|
52
|
+
/**
|
|
53
|
+
* Raw OAuth access token, for handing directly to Google Picker
|
|
54
|
+
* (`setOAuthToken()`) — see connection.ts's `getAccessToken` for why this
|
|
55
|
+
* is the one exception to this library otherwise never exposing token
|
|
56
|
+
* material. `interactive` defaults to true since callers use this to
|
|
57
|
+
* drive a UI the user is actively interacting with.
|
|
58
|
+
*/
|
|
59
|
+
getAccessToken(callOpts?: CallOptions): Promise<string>;
|
|
60
|
+
pickFile(options: PickFileOptions): Promise<PickedFile[]>;
|
|
43
61
|
files: FilesHandle;
|
|
44
62
|
permissions: PermissionsHandle;
|
|
45
63
|
}
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { noOpLogger } from './logger.js';
|
|
2
|
-
import { connect as connectImpl, getConnection as getConnectionImpl, disconnect as disconnectImpl } from './connection.js';
|
|
2
|
+
import { connect as connectImpl, getConnection as getConnectionImpl, disconnect as disconnectImpl, getAccessToken as getAccessTokenImpl } from './connection.js';
|
|
3
3
|
import { reconcile as reconcileImpl, dropProject as dropProjectImpl } from './reconcile.js';
|
|
4
4
|
import * as filesImpl from './files.js';
|
|
5
5
|
import * as permissionsImpl from './permissions.js';
|
|
6
|
+
import * as pickerImpl from './picker.js';
|
|
6
7
|
import { warmUpIfNeeded } from './refresh.js';
|
|
7
8
|
import { REQUIRED_SCOPES } from './files.js';
|
|
8
9
|
import { createBroadcast } from './broadcast.js';
|
|
@@ -138,6 +139,9 @@ export function createDriveSync(options) {
|
|
|
138
139
|
read(fileId, callOpts) {
|
|
139
140
|
return filesImpl.read({ ...base, fileId, interactive: callOpts?.interactive });
|
|
140
141
|
},
|
|
142
|
+
status(fileId, callOpts) {
|
|
143
|
+
return filesImpl.status({ ...base, fileId, interactive: callOpts?.interactive });
|
|
144
|
+
},
|
|
141
145
|
write(opts, callOpts) {
|
|
142
146
|
return filesImpl.write({ ...base, ...opts, interactive: callOpts?.interactive });
|
|
143
147
|
},
|
|
@@ -194,6 +198,40 @@ export function createDriveSync(options) {
|
|
|
194
198
|
ensureFolderPath() {
|
|
195
199
|
return filesImpl.ensureFolderPath({ ...base, folderPath });
|
|
196
200
|
},
|
|
201
|
+
getAccessToken(callOpts) {
|
|
202
|
+
return getAccessTokenImpl({
|
|
203
|
+
appId,
|
|
204
|
+
projectId,
|
|
205
|
+
clientId,
|
|
206
|
+
scopes: REQUIRED_SCOPES,
|
|
207
|
+
interactive: callOpts?.interactive ?? true,
|
|
208
|
+
logger,
|
|
209
|
+
});
|
|
210
|
+
},
|
|
211
|
+
async pickFile(options) {
|
|
212
|
+
const token = await getAccessTokenImpl({
|
|
213
|
+
appId,
|
|
214
|
+
projectId,
|
|
215
|
+
clientId,
|
|
216
|
+
scopes: REQUIRED_SCOPES,
|
|
217
|
+
interactive: true,
|
|
218
|
+
logger,
|
|
219
|
+
});
|
|
220
|
+
const picked = await pickerImpl.openPicker({
|
|
221
|
+
apiKey: options.apiKey,
|
|
222
|
+
oauthToken: token,
|
|
223
|
+
appId: options.appId,
|
|
224
|
+
mimeTypes: options.mimeTypes,
|
|
225
|
+
multiSelect: options.multiSelect,
|
|
226
|
+
parentFolderId: options.parentFolderId,
|
|
227
|
+
});
|
|
228
|
+
const results = [];
|
|
229
|
+
for (const p of picked) {
|
|
230
|
+
const content = await filesImpl.read({ ...base, fileId: p.fileId, interactive: true });
|
|
231
|
+
results.push({ fileId: p.fileId, name: p.name, mimeType: p.mimeType, content });
|
|
232
|
+
}
|
|
233
|
+
return results;
|
|
234
|
+
},
|
|
197
235
|
files,
|
|
198
236
|
permissions,
|
|
199
237
|
};
|