@open-webapp/drive-sync 0.2.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 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,8 +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();
35
- const token = await p.getAccessToken(); // raw token, for Google Picker's setOAuthToken() only
36
+ const token = await p.getAccessToken(); // raw token for advanced/custom Picker wiring; pickFile() is the preferred path for most uses
36
37
  const files = await p.files.list({ folderId });
37
38
  const text = await p.files.read(fileId); // string | Blob | null (null on 404)
38
39
  const ref = await p.files.write({ folderId, name: 'x.json', content, mimeType: 'application/json' });
@@ -45,11 +46,11 @@ dispose();
45
46
 
46
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.
47
48
 
48
- 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), `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`).
49
50
 
50
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).
51
52
 
52
- ## 2. The 34 resolved design decisions
53
+ ## 2. The 35 resolved design decisions
53
54
 
54
55
  **Bugs fixed (both source apps carried these):**
55
56
 
@@ -91,6 +92,7 @@ Files implementing the surface: `index.ts` (factory + `ProjectHandle`/`FilesHand
91
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.
92
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)`.
93
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).
94
96
 
95
97
  ## 3. Storage layout
96
98
 
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
- return isTextual ? await res.text() : await res.blob();
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
- const res = await driveFetch({
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
- url: `${UPLOAD_BASE}/files/${encodeURIComponent(opts.fileId)}?uploadType=media`,
76
- method: 'PATCH',
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
- const json = (await res.json());
85
- return { id: json.id, name: json.name ?? opts.name };
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=id`,
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
- const url = `${DRIVE_BASE}/files?q=${encodeURIComponent(q)}&fields=${encodeURIComponent('files(id,name,mimeType)')}`;
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;
@@ -48,6 +57,7 @@ export interface ProjectHandle {
48
57
  * drive a UI the user is actively interacting with.
49
58
  */
50
59
  getAccessToken(callOpts?: CallOptions): Promise<string>;
60
+ pickFile(options: PickFileOptions): Promise<PickedFile[]>;
51
61
  files: FilesHandle;
52
62
  permissions: PermissionsHandle;
53
63
  }
package/dist/index.js CHANGED
@@ -3,6 +3,7 @@ import { connect as connectImpl, getConnection as getConnectionImpl, disconnect
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
  },
@@ -204,6 +208,30 @@ export function createDriveSync(options) {
204
208
  logger,
205
209
  });
206
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
+ },
207
235
  files,
208
236
  permissions,
209
237
  };
@@ -0,0 +1,95 @@
1
+ import type { WorkspaceMimeShorthand } from './types.js';
2
+ /**
3
+ * Minimal ambient type declarations for Google Picker and GIS APIs.
4
+ * These allow TypeScript to recognize window.gapi and window.google.picker
5
+ * without importing the actual Google libraries.
6
+ */
7
+ declare global {
8
+ interface Window {
9
+ gapi?: {
10
+ load(api: string, callback: () => void): void;
11
+ };
12
+ google?: {
13
+ picker?: {
14
+ PickerBuilder: new (...args: any[]) => PickerBuilder;
15
+ DocsView: new (...args: any[]) => DocsView;
16
+ Action: {
17
+ PICKED: string;
18
+ CANCEL: string;
19
+ };
20
+ ViewId: {
21
+ DOCS: string;
22
+ };
23
+ Feature: {
24
+ MULTISELECT_ENABLED: string;
25
+ };
26
+ };
27
+ };
28
+ }
29
+ }
30
+ interface PickerBuilder {
31
+ addView(view: DocsView): PickerBuilder;
32
+ setOAuthToken(token: string): PickerBuilder;
33
+ setDeveloperKey(key: string): PickerBuilder;
34
+ setAppId(appId: string): PickerBuilder;
35
+ enableFeature(feature: string): PickerBuilder;
36
+ setCallback(callback: (data: PickerResponse) => void): PickerBuilder;
37
+ build(): PickerInstance;
38
+ }
39
+ interface DocsView {
40
+ setMimeTypes(types: string): DocsView;
41
+ setParent(folderId: string): DocsView;
42
+ }
43
+ interface PickerInstance {
44
+ setVisible(visible: boolean): void;
45
+ }
46
+ interface PickerResponse {
47
+ action?: string;
48
+ docs?: PickerDocument[];
49
+ }
50
+ interface PickerDocument {
51
+ id: string;
52
+ name: string;
53
+ mimeType: string;
54
+ }
55
+ /**
56
+ * Test-only export: resets the script load cache, allowing tests to
57
+ * re-inject or mock the picker script. Exported normally (not conditionally gated)
58
+ * so tests can import and call it directly.
59
+ */
60
+ export declare function __resetPickerScriptCacheForTests(): void;
61
+ export interface OpenPickerOptions {
62
+ apiKey: string;
63
+ oauthToken: string;
64
+ /**
65
+ * The Cloud project *number* of the OAuth client that minted `oauthToken`.
66
+ * Required: this library requests only the `drive.file` scope, and Picker
67
+ * refuses to run a scoped session it cannot attribute to an app. Omit it and
68
+ * Picker discards the OAuth token, falls back to its own sign-in prompt, and
69
+ * then fails the unauthenticated developer-key check with "The API developer
70
+ * key is invalid" — even though the key and its referrer restrictions are fine.
71
+ */
72
+ appId: string;
73
+ mimeTypes?: (string | WorkspaceMimeShorthand)[];
74
+ multiSelect?: boolean;
75
+ parentFolderId?: string;
76
+ }
77
+ export interface PickedFile {
78
+ fileId: string;
79
+ name: string;
80
+ mimeType: string;
81
+ }
82
+ /**
83
+ * Opens the Google Picker dialog for file selection, returning a promise
84
+ * that resolves with the selected files or rejects if the user cancels.
85
+ *
86
+ * The picker is configured with optional MIME type filters, multi-select
87
+ * support, and a parent folder constraint.
88
+ *
89
+ * @param opts - Configuration including API key, OAuth token, and picker options
90
+ * @returns Promise resolving to an array of picked files
91
+ * @throws PickerCancelledError if the user cancels the dialog
92
+ * @throws Error if the Picker script fails to load or configuration fails
93
+ */
94
+ export declare function openPicker(opts: OpenPickerOptions): Promise<PickedFile[]>;
95
+ export {};
package/dist/picker.js ADDED
@@ -0,0 +1,140 @@
1
+ import { PickerCancelledError } from './errors.js';
2
+ /**
3
+ * Maps Google Workspace document type shorthands to their full MIME types.
4
+ */
5
+ const WORKSPACE_MIME_SHORTHAND = {
6
+ docs: 'application/vnd.google-apps.document',
7
+ sheets: 'application/vnd.google-apps.spreadsheet',
8
+ slides: 'application/vnd.google-apps.presentation',
9
+ forms: 'application/vnd.google-apps.form',
10
+ drawings: 'application/vnd.google-apps.drawing',
11
+ };
12
+ /**
13
+ * Module-level cache for the Picker script loading promise.
14
+ * Reused across multiple openPicker() calls to avoid re-injecting the script.
15
+ */
16
+ let scriptLoadPromise = null;
17
+ /**
18
+ * Resolves an optional array of MIME types, expanding shorthand tokens
19
+ * (e.g., 'docs', 'sheets') to their full `application/vnd.google-apps.*` equivalents
20
+ * and passing literal MIME strings through unchanged.
21
+ *
22
+ * @param mimeTypes - Array containing strings and/or WorkspaceMimeShorthand tokens
23
+ * @returns Expanded array of MIME type strings, or undefined if input is undefined
24
+ */
25
+ function resolveMimeTypes(mimeTypes) {
26
+ if (!mimeTypes) {
27
+ return undefined;
28
+ }
29
+ return mimeTypes.map((type) => {
30
+ if (type in WORKSPACE_MIME_SHORTHAND) {
31
+ return WORKSPACE_MIME_SHORTHAND[type];
32
+ }
33
+ return type;
34
+ });
35
+ }
36
+ /**
37
+ * Ensures the Google Picker API is loaded by:
38
+ * 1. Checking if window.google.picker already exists (e.g., from a test fake)
39
+ * 2. Reusing a pending script load if one is already in progress
40
+ * 3. Otherwise, injecting the picker script and waiting for gapi.load('picker')
41
+ *
42
+ * On script error, resets the cache so a retry will re-inject.
43
+ *
44
+ * @throws Error if script load fails
45
+ */
46
+ async function ensurePickerLoaded() {
47
+ // Already loaded (e.g., by a test fake or prior successful load)
48
+ if (window.google?.picker) {
49
+ return;
50
+ }
51
+ // Script load already in progress; reuse the existing promise
52
+ if (scriptLoadPromise) {
53
+ return scriptLoadPromise;
54
+ }
55
+ // Inject the Picker script and set up the load promise
56
+ scriptLoadPromise = new Promise((resolve, reject) => {
57
+ const script = document.createElement('script');
58
+ script.src = 'https://apis.google.com/js/api.js';
59
+ script.onload = () => {
60
+ // Script loaded; now request the Picker API
61
+ window.gapi.load('picker', () => {
62
+ resolve();
63
+ });
64
+ };
65
+ script.onerror = () => {
66
+ // Script failed to load; reset cache so retry can re-inject
67
+ scriptLoadPromise = null;
68
+ reject(new Error('Failed to load Google Picker script'));
69
+ };
70
+ document.head.appendChild(script);
71
+ });
72
+ return scriptLoadPromise;
73
+ }
74
+ /**
75
+ * Test-only export: resets the script load cache, allowing tests to
76
+ * re-inject or mock the picker script. Exported normally (not conditionally gated)
77
+ * so tests can import and call it directly.
78
+ */
79
+ export function __resetPickerScriptCacheForTests() {
80
+ scriptLoadPromise = null;
81
+ }
82
+ /**
83
+ * Opens the Google Picker dialog for file selection, returning a promise
84
+ * that resolves with the selected files or rejects if the user cancels.
85
+ *
86
+ * The picker is configured with optional MIME type filters, multi-select
87
+ * support, and a parent folder constraint.
88
+ *
89
+ * @param opts - Configuration including API key, OAuth token, and picker options
90
+ * @returns Promise resolving to an array of picked files
91
+ * @throws PickerCancelledError if the user cancels the dialog
92
+ * @throws Error if the Picker script fails to load or configuration fails
93
+ */
94
+ export async function openPicker(opts) {
95
+ // Ensure the Picker API is available
96
+ await ensurePickerLoaded();
97
+ // Create and configure a DocsView for file browsing
98
+ const docsView = new window.google.picker.DocsView();
99
+ // Apply MIME type filters if provided
100
+ const resolvedMimes = resolveMimeTypes(opts.mimeTypes);
101
+ if (resolvedMimes) {
102
+ docsView.setMimeTypes(resolvedMimes.join(','));
103
+ }
104
+ // Constrain to a specific parent folder if provided
105
+ if (opts.parentFolderId) {
106
+ docsView.setParent(opts.parentFolderId);
107
+ }
108
+ // Create and configure the PickerBuilder
109
+ const pickerBuilder = new window.google.picker.PickerBuilder();
110
+ pickerBuilder
111
+ .addView(docsView)
112
+ .setOAuthToken(opts.oauthToken)
113
+ .setDeveloperKey(opts.apiKey)
114
+ .setAppId(opts.appId);
115
+ // Enable multi-select if requested
116
+ if (opts.multiSelect) {
117
+ pickerBuilder.enableFeature(window.google.picker.Feature.MULTISELECT_ENABLED);
118
+ }
119
+ // Set up the response handler and build the picker
120
+ return new Promise((resolve, reject) => {
121
+ pickerBuilder.setCallback((data) => {
122
+ // User picked files
123
+ if (data.action === window.google.picker.Action.PICKED && data.docs) {
124
+ const picked = data.docs.map((doc) => ({
125
+ fileId: doc.id,
126
+ name: doc.name,
127
+ mimeType: doc.mimeType,
128
+ }));
129
+ resolve(picked);
130
+ return;
131
+ }
132
+ // User cancelled
133
+ if (data.action === window.google.picker.Action.CANCEL) {
134
+ reject(new PickerCancelledError());
135
+ return;
136
+ }
137
+ });
138
+ pickerBuilder.build().setVisible(true);
139
+ });
140
+ }
package/dist/storage.d.ts CHANGED
@@ -6,10 +6,21 @@ export interface ConnRecord {
6
6
  grantedScopes: string[];
7
7
  connectedAt: number;
8
8
  }
9
+ /**
10
+ * Per-file sync baseline: the Drive `version` this client last restored (via
11
+ * files.read()) or last successfully wrote. A write is only allowed when the
12
+ * file's current remote version still matches this — see files.ts.
13
+ */
14
+ export interface FileStateRecord {
15
+ fileId: string;
16
+ version: string;
17
+ /** Epoch ms at which this baseline was recorded. */
18
+ syncedAt: number;
19
+ }
9
20
  interface AuthDbSchema extends DBSchema {
10
21
  auth: {
11
22
  key: string;
12
- value: ConnRecord | StoredToken;
23
+ value: ConnRecord | StoredToken | FileStateRecord;
13
24
  };
14
25
  }
15
26
  export declare function openAuthDb(appId: string, projectId: string): Promise<IDBPDatabase<AuthDbSchema>>;
@@ -24,4 +35,7 @@ export declare function clearConn(appId: string, projectId: string): Promise<voi
24
35
  export declare function getToken(appId: string, projectId: string): Promise<StoredToken | undefined>;
25
36
  export declare function setToken(appId: string, projectId: string, token: StoredToken): Promise<void>;
26
37
  export declare function clearToken(appId: string, projectId: string): Promise<void>;
38
+ export declare function getFileState(appId: string, projectId: string, fileId: string): Promise<FileStateRecord | undefined>;
39
+ export declare function setFileState(appId: string, projectId: string, state: FileStateRecord): Promise<void>;
40
+ export declare function clearFileState(appId: string, projectId: string, fileId: string): Promise<void>;
27
41
  export {};
package/dist/storage.js CHANGED
@@ -2,6 +2,18 @@ import { openDB } from 'idb';
2
2
  const AUTH_STORE = 'auth';
3
3
  const CONN_KEY = 'conn';
4
4
  const TOKEN_KEY = 'token';
5
+ /**
6
+ * File baselines live in the existing 'auth' store under a namespaced key
7
+ * rather than in a store of their own, deliberately: adding a store means
8
+ * bumping the DB version, and an upgrade cannot run while ANY other
9
+ * connection still holds the old version open. Older tabs run older code
10
+ * that has no way to know it should close, so the upgrade would block
11
+ * indefinitely and every Drive call — which needs this DB for its token —
12
+ * would hang rather than fail. Keeping the schema at v1 avoids that entirely.
13
+ */
14
+ function fileKey(fileId) {
15
+ return `file:${fileId}`;
16
+ }
5
17
  function dbName(appId, projectId) {
6
18
  return `owa-drive-${appId}-${projectId}`;
7
19
  }
@@ -19,6 +31,11 @@ export function openAuthDb(appId, projectId) {
19
31
  db.createObjectStore(AUTH_STORE);
20
32
  }
21
33
  },
34
+ // Defence for any FUTURE version bump: close this connection as soon as
35
+ // another tab needs to upgrade, so it is never the thing blocking.
36
+ blocking() {
37
+ void evictDbHandle(appId, projectId);
38
+ },
22
39
  });
23
40
  dbCache.set(key, handle);
24
41
  }
@@ -68,3 +85,16 @@ export async function clearToken(appId, projectId) {
68
85
  const db = await openAuthDb(appId, projectId);
69
86
  await db.delete(AUTH_STORE, TOKEN_KEY);
70
87
  }
88
+ export async function getFileState(appId, projectId, fileId) {
89
+ const db = await openAuthDb(appId, projectId);
90
+ const value = await db.get(AUTH_STORE, fileKey(fileId));
91
+ return value;
92
+ }
93
+ export async function setFileState(appId, projectId, state) {
94
+ const db = await openAuthDb(appId, projectId);
95
+ await db.put(AUTH_STORE, state, fileKey(state.fileId));
96
+ }
97
+ export async function clearFileState(appId, projectId, fileId) {
98
+ const db = await openAuthDb(appId, projectId);
99
+ await db.delete(AUTH_STORE, fileKey(fileId));
100
+ }
@@ -20,6 +20,11 @@ export interface DriveFakeFile {
20
20
  content: string;
21
21
  contentType?: string;
22
22
  trashed?: boolean;
23
+ /**
24
+ * Drive's monotonic per-file change counter, bumped on every mutation.
25
+ * Optional so tests may seed files without it; treated as 1 when absent.
26
+ */
27
+ version?: number;
23
28
  }
24
29
  export interface DriveFakePermission {
25
30
  id: string;
@@ -44,6 +49,11 @@ export interface DriveFake {
44
49
  reset(): void;
45
50
  /** Inspect current in-memory files, keyed by id. */
46
51
  readonly files: Map<string, DriveFakeFile>;
52
+ /**
53
+ * Simulate another client editing the file: replaces content and bumps
54
+ * `version`, exactly as a real out-of-band Drive write would.
55
+ */
56
+ externalEdit(fileId: string, content: string): void;
47
57
  /** Inspect current in-memory permissions, keyed by fileId then permission id. */
48
58
  readonly permissions: Map<string, Map<string, DriveFakePermission>>;
49
59
  }
@@ -142,7 +142,7 @@ export function createDriveFake() {
142
142
  return override;
143
143
  }
144
144
  function fileToMetadata(f) {
145
- return { id: f.id, name: f.name, mimeType: f.mimeType, parents: f.parents };
145
+ return { id: f.id, name: f.name, mimeType: f.mimeType, parents: f.parents, version: String(f.version ?? 1) };
146
146
  }
147
147
  async function handleFilesList(url) {
148
148
  const q = url.searchParams.get('q') ?? '';
@@ -218,6 +218,7 @@ export function createDriveFake() {
218
218
  parents: Array.isArray(metadata.parents) ? metadata.parents : [],
219
219
  content,
220
220
  contentType: mediaType,
221
+ version: 1,
221
222
  };
222
223
  files.set(id, file);
223
224
  if (mimeType === FOLDER_MIME_TYPE) {
@@ -237,6 +238,7 @@ export function createDriveFake() {
237
238
  const contentType = getHeader(init, 'Content-Type');
238
239
  if (contentType)
239
240
  file.contentType = contentType;
241
+ file.version = (file.version ?? 1) + 1;
240
242
  return jsonResponse(fileToMetadata(file));
241
243
  }
242
244
  if (uploadType === 'multipart') {
@@ -254,6 +256,7 @@ export function createDriveFake() {
254
256
  file.content = parsed.mediaContent;
255
257
  if (parsed.mediaType)
256
258
  file.contentType = parsed.mediaType;
259
+ file.version = (file.version ?? 1) + 1;
257
260
  }
258
261
  return jsonResponse(fileToMetadata(file));
259
262
  }
@@ -285,6 +288,7 @@ export function createDriveFake() {
285
288
  const removeSet = new Set(removeParents.split(','));
286
289
  file.parents = file.parents.filter((p) => !removeSet.has(p));
287
290
  }
291
+ file.version = (file.version ?? 1) + 1;
288
292
  return jsonResponse(fileToMetadata(file));
289
293
  }
290
294
  async function handleFileDelete(id) {
@@ -411,6 +415,13 @@ export function createDriveFake() {
411
415
  remaining: opts?.times ?? 1,
412
416
  });
413
417
  },
418
+ externalEdit(fileId, content) {
419
+ const file = files.get(fileId);
420
+ if (!file)
421
+ throw new Error(`driveFake.externalEdit: unknown file ${fileId}`);
422
+ file.content = content;
423
+ file.version = (file.version ?? 1) + 1;
424
+ },
414
425
  reset() {
415
426
  files.clear();
416
427
  permissions.clear();
@@ -50,6 +50,12 @@ export interface GisFake {
50
50
  calls: GisRecordedCall[];
51
51
  /** Queue a response to be delivered to the next `requestAccessToken` call. */
52
52
  queueResponse(response: GisTokenResponse): void;
53
+ /**
54
+ * Queue a popup-level failure for the next `requestAccessToken` call,
55
+ * delivered via `error_callback` — the channel the real GIS client uses
56
+ * for a blocked or dismissed popup, which never reaches `callback`.
57
+ */
58
+ queuePopupError(type: string): void;
53
59
  /** Stub `window.google.accounts.oauth2.initTokenClient` with this fake. */
54
60
  install(): void;
55
61
  /** Remove the stub installed by `install()`, restoring prior state. */
@@ -15,6 +15,7 @@
15
15
  */
16
16
  export function createGisFake() {
17
17
  const responseQueue = [];
18
+ const popupErrorQueue = [];
18
19
  const calls = [];
19
20
  let previousGoogle;
20
21
  let hadGoogle = false;
@@ -32,6 +33,14 @@ export function createGisFake() {
32
33
  const hint = overrideConfig?.hint ?? config.hint;
33
34
  const scope = overrideConfig?.scope ?? config.scope ?? '';
34
35
  calls.push({ prompt, hint, scope });
36
+ const popupError = popupErrorQueue.shift();
37
+ if (popupError) {
38
+ const errorCallback = config.error_callback;
39
+ queueMicrotask(() => {
40
+ errorCallback?.({ type: popupError });
41
+ });
42
+ return;
43
+ }
35
44
  const response = nextResponse();
36
45
  const callback = config.callback;
37
46
  // Deliver asynchronously (microtask), matching the real GIS client's
@@ -54,6 +63,9 @@ export function createGisFake() {
54
63
  queueResponse(response) {
55
64
  responseQueue.push(response);
56
65
  },
66
+ queuePopupError(type) {
67
+ popupErrorQueue.push(type);
68
+ },
57
69
  install() {
58
70
  const w = globalThis;
59
71
  hadGoogle = Object.prototype.hasOwnProperty.call(w, 'google');
@@ -80,6 +92,7 @@ export function createGisFake() {
80
92
  },
81
93
  reset() {
82
94
  responseQueue.length = 0;
95
+ popupErrorQueue.length = 0;
83
96
  calls.length = 0;
84
97
  },
85
98
  };
@@ -2,3 +2,5 @@ export { createGisFake } from './gisFake.js';
2
2
  export type { GisFake, GisTokenResponse, GisRecordedCall } from './gisFake.js';
3
3
  export { createDriveFake } from './driveFake.js';
4
4
  export type { DriveFake, DriveFakeFile, DriveFakePermission, StatusOverrideOptions } from './driveFake.js';
5
+ export { createPickerFake } from './pickerFake.js';
6
+ export type { PickerFake, PickerFakeFile, PickerRecordedCall } from './pickerFake.js';
@@ -1,2 +1,3 @@
1
1
  export { createGisFake } from './gisFake.js';
2
2
  export { createDriveFake } from './driveFake.js';
3
+ export { createPickerFake } from './pickerFake.js';
@@ -0,0 +1,46 @@
1
+ /**
2
+ * A scriptable double for Google Picker (google.picker),
3
+ * for use in tests of code that calls `window.google.picker.PickerBuilder`
4
+ * and related APIs.
5
+ *
6
+ * Usage:
7
+ *
8
+ * ```ts
9
+ * const pickerFake = createPickerFake()
10
+ * pickerFake.install()
11
+ * // ... exercise code under test that calls openPicker() ...
12
+ * pickerFake.simulatePick([{ fileId: '123', name: 'doc.txt', mimeType: 'text/plain' }])
13
+ * await expect(openPickerPromise).resolves.toEqual([...])
14
+ * expect(pickerFake.calls).toHaveLength(1)
15
+ * ```
16
+ */
17
+ export interface PickerFakeFile {
18
+ fileId: string;
19
+ name: string;
20
+ mimeType: string;
21
+ }
22
+ export interface PickerRecordedCall {
23
+ oauthToken?: string;
24
+ developerKey?: string;
25
+ appId?: string;
26
+ views: Array<{
27
+ mimeTypes?: string;
28
+ parentId?: string;
29
+ }>;
30
+ features: string[];
31
+ }
32
+ export interface PickerFake {
33
+ /** All PickerBuilder constructions and their configurations, in order. */
34
+ calls: PickerRecordedCall[];
35
+ /** Simulate user picking files; invokes the picker's callback asynchronously. */
36
+ simulatePick(files: PickerFakeFile[]): void;
37
+ /** Simulate user cancelling; invokes the picker's callback asynchronously. */
38
+ simulateCancel(): void;
39
+ /** Stub window.gapi and window.google.picker with this fake. */
40
+ install(): void;
41
+ /** Remove the stub installed by `install()`, restoring prior state. */
42
+ uninstall(): void;
43
+ /** Clear call history and stored callback state. */
44
+ reset(): void;
45
+ }
46
+ export declare function createPickerFake(): PickerFake;
@@ -0,0 +1,189 @@
1
+ /**
2
+ * A scriptable double for Google Picker (google.picker),
3
+ * for use in tests of code that calls `window.google.picker.PickerBuilder`
4
+ * and related APIs.
5
+ *
6
+ * Usage:
7
+ *
8
+ * ```ts
9
+ * const pickerFake = createPickerFake()
10
+ * pickerFake.install()
11
+ * // ... exercise code under test that calls openPicker() ...
12
+ * pickerFake.simulatePick([{ fileId: '123', name: 'doc.txt', mimeType: 'text/plain' }])
13
+ * await expect(openPickerPromise).resolves.toEqual([...])
14
+ * expect(pickerFake.calls).toHaveLength(1)
15
+ * ```
16
+ */
17
+ export function createPickerFake() {
18
+ const calls = [];
19
+ let storedCallback = null;
20
+ let previousGapi;
21
+ let hadGapi = false;
22
+ let previousGoogle;
23
+ let hadGoogle = false;
24
+ let previousGooglePicker;
25
+ let hadGooglePicker = false;
26
+ /**
27
+ * Fake DocsView class for stubbing window.google.picker.DocsView.
28
+ * Stores MIME types and parent folder ID, and is chainable.
29
+ */
30
+ class FakeDocsView {
31
+ mimeTypes;
32
+ parentId;
33
+ setMimeTypes(types) {
34
+ this.mimeTypes = types;
35
+ return this;
36
+ }
37
+ setParent(folderId) {
38
+ this.parentId = folderId;
39
+ return this;
40
+ }
41
+ }
42
+ /**
43
+ * Fake PickerInstance (returned by PickerBuilder.build()).
44
+ * When setVisible(true) is called, it's recorded but callback is not fired
45
+ * until simulatePick or simulateCancel is called.
46
+ */
47
+ class FakePickerInstance {
48
+ setVisible(visible) {
49
+ // Visibility change is recorded implicitly by the builder construction.
50
+ // The callback will be triggered by simulatePick/simulateCancel.
51
+ }
52
+ }
53
+ /**
54
+ * Fake PickerBuilder class for stubbing window.google.picker.PickerBuilder.
55
+ * Chainable methods all return `this`, and build() returns a FakePickerInstance.
56
+ */
57
+ class FakePickerBuilder {
58
+ currentCall = {
59
+ views: [],
60
+ features: [],
61
+ };
62
+ addView(view) {
63
+ this.currentCall.views.push({
64
+ mimeTypes: view.mimeTypes,
65
+ parentId: view.parentId,
66
+ });
67
+ return this;
68
+ }
69
+ setOAuthToken(token) {
70
+ this.currentCall.oauthToken = token;
71
+ return this;
72
+ }
73
+ setDeveloperKey(key) {
74
+ this.currentCall.developerKey = key;
75
+ return this;
76
+ }
77
+ setAppId(appId) {
78
+ this.currentCall.appId = appId;
79
+ return this;
80
+ }
81
+ enableFeature(feature) {
82
+ this.currentCall.features.push(feature);
83
+ return this;
84
+ }
85
+ setCallback(callback) {
86
+ storedCallback = callback;
87
+ return this;
88
+ }
89
+ build() {
90
+ // Record the call once build() is invoked (not before, so all config is captured)
91
+ calls.push(this.currentCall);
92
+ return new FakePickerInstance();
93
+ }
94
+ }
95
+ return {
96
+ calls,
97
+ simulatePick(files) {
98
+ if (!storedCallback) {
99
+ return;
100
+ }
101
+ const callback = storedCallback;
102
+ queueMicrotask(() => {
103
+ callback({
104
+ action: 'picked',
105
+ docs: files.map((file) => ({
106
+ id: file.fileId,
107
+ name: file.name,
108
+ mimeType: file.mimeType,
109
+ })),
110
+ });
111
+ });
112
+ },
113
+ simulateCancel() {
114
+ if (!storedCallback) {
115
+ return;
116
+ }
117
+ const callback = storedCallback;
118
+ queueMicrotask(() => {
119
+ callback({
120
+ action: 'cancel',
121
+ });
122
+ });
123
+ },
124
+ install() {
125
+ const w = globalThis;
126
+ // Save and replace window.gapi
127
+ hadGapi = Object.prototype.hasOwnProperty.call(w, 'gapi');
128
+ previousGapi = w.gapi;
129
+ w.gapi = {
130
+ load(api, callback) {
131
+ if (api === 'picker') {
132
+ queueMicrotask(callback);
133
+ }
134
+ },
135
+ };
136
+ // Save and replace window.google
137
+ hadGoogle = Object.prototype.hasOwnProperty.call(w, 'google');
138
+ previousGoogle = w.google;
139
+ if (!w.google) {
140
+ w.google = {};
141
+ }
142
+ // Save and replace window.google.picker
143
+ hadGooglePicker = Object.prototype.hasOwnProperty.call(w.google, 'picker');
144
+ previousGooglePicker = w.google.picker;
145
+ w.google.picker = {
146
+ PickerBuilder: FakePickerBuilder,
147
+ DocsView: FakeDocsView,
148
+ Action: {
149
+ PICKED: 'picked',
150
+ CANCEL: 'cancel',
151
+ },
152
+ ViewId: {
153
+ DOCS: 'docs',
154
+ },
155
+ Feature: {
156
+ MULTISELECT_ENABLED: 'multiselect',
157
+ },
158
+ };
159
+ },
160
+ uninstall() {
161
+ const w = globalThis;
162
+ // Restore window.gapi
163
+ if (hadGapi) {
164
+ w.gapi = previousGapi;
165
+ }
166
+ else {
167
+ delete w.gapi;
168
+ }
169
+ // Restore window.google.picker
170
+ if (hadGooglePicker) {
171
+ w.google.picker = previousGooglePicker;
172
+ }
173
+ else if (w.google) {
174
+ delete w.google.picker;
175
+ }
176
+ // Restore window.google (if it was only created by install())
177
+ if (!hadGoogle) {
178
+ delete w.google;
179
+ }
180
+ else {
181
+ w.google = previousGoogle;
182
+ }
183
+ },
184
+ reset() {
185
+ calls.length = 0;
186
+ storedCallback = null;
187
+ },
188
+ };
189
+ }
package/dist/token.js CHANGED
@@ -114,6 +114,17 @@ async function acquireTokenUncoalesced(opts) {
114
114
  }
115
115
  resolve(res);
116
116
  },
117
+ // Without this, a popup that the browser blocks or the user closes
118
+ // settles NOTHING: GIS reports those through error_callback only, so
119
+ // the promise below would stay pending forever and every awaiting
120
+ // Drive call would hang until the caller's own timeout (if any).
121
+ error_callback: (err) => {
122
+ reject(new NeedsReauthError(err?.type === 'popup_failed_to_open'
123
+ ? 'Google sign-in popup was blocked by the browser'
124
+ : err?.type === 'popup_closed'
125
+ ? 'Google sign-in popup was closed before completing'
126
+ : `Google sign-in failed: ${err?.type ?? 'unknown error'}`, { reason: err?.type ?? 'gis_error' }));
127
+ },
117
128
  });
118
129
  opts.logger?.debug('drive-sync: requesting access token', {
119
130
  projectId: opts.projectId,
package/dist/types.d.ts CHANGED
@@ -27,6 +27,26 @@ export interface StoredToken {
27
27
  export interface FileRef {
28
28
  id: string;
29
29
  name?: string;
30
+ /** Drive's monotonic change counter, when the call requested it. */
31
+ version?: string;
32
+ }
33
+ /**
34
+ * Sync state of one file relative to what this client last restored.
35
+ * Returned by `files.status()`.
36
+ */
37
+ export interface FileState {
38
+ fileId: string;
39
+ /** False when the file is missing or not visible to the connected account. */
40
+ exists: boolean;
41
+ /** Version last restored/written by this client; null if never. */
42
+ baseVersion: string | null;
43
+ /** Version currently on Drive; null when the file does not exist. */
44
+ remoteVersion: string | null;
45
+ /** True when a write would be refused with a RemoteChangedError. */
46
+ changedSinceRestore: boolean;
47
+ remoteModifiedTime?: string;
48
+ /** Epoch ms of the last restore/write by this client; null if never. */
49
+ lastRestoredAt: number | null;
30
50
  }
31
51
  /** Drive permission shape as returned/accepted by the Drive Permissions API. */
32
52
  export interface DrivePermission {
@@ -35,6 +55,28 @@ export interface DrivePermission {
35
55
  role: string;
36
56
  emailAddress?: string;
37
57
  }
58
+ /** Google Workspace MIME type shorthands for common document types. */
59
+ export type WorkspaceMimeShorthand = 'docs' | 'sheets' | 'slides' | 'forms' | 'drawings';
60
+ /** Options for opening the Google Picker to select files. */
61
+ export interface PickFileOptions {
62
+ apiKey: string;
63
+ /**
64
+ * Cloud project *number* of the OAuth client configured on
65
+ * `DriveSyncOptions.clientId`. Passed to Picker's `setAppId()`; required
66
+ * because drive-sync only ever holds a `drive.file`-scoped token.
67
+ */
68
+ appId: string;
69
+ mimeTypes?: (string | WorkspaceMimeShorthand)[];
70
+ multiSelect?: boolean;
71
+ parentFolderId?: string;
72
+ }
73
+ /** File information returned after selection from Google Picker. */
74
+ export interface PickedFile {
75
+ fileId: string;
76
+ name: string;
77
+ mimeType: string;
78
+ content: string | Blob | null;
79
+ }
38
80
  /** Options accepted on every Drive-op call site. */
39
81
  export interface CallOptions {
40
82
  /** Whether an interactive (popup/redirect) auth flow may be triggered. Defaults to false. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-webapp/drive-sync",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",