@open-webapp/drive-sync 0.3.0 → 0.4.1

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
@@ -29,7 +29,7 @@ const folderId = await p.ensureFolderPath()
29
29
  await p.files.write({ folderId, name: 'data.json', content: '{}', mimeType: 'application/json' })
30
30
  ```
31
31
 
32
- See `SPEC.md` for the full design: the 34 resolved decisions, storage layout,
32
+ See `SPEC.md` for the full design: the 36 resolved decisions, storage layout,
33
33
  and refresh state machine. `SPEC.md` is descriptive, written from the shipped
34
34
  code — if it ever disagrees with the source, the source wins.
35
35
 
package/SPEC.md CHANGED
@@ -50,7 +50,7 @@ Files implementing the surface: `index.ts` (factory + `ProjectHandle`/`FilesHand
50
50
 
51
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
52
 
53
- ## 2. The 35 resolved design decisions
53
+ ## 2. The 36 resolved design decisions
54
54
 
55
55
  **Bugs fixed (both source apps carried these):**
56
56
 
@@ -94,6 +94,8 @@ Files implementing the surface: `index.ts` (factory + `ProjectHandle`/`FilesHand
94
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
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).
96
96
 
97
+ 36. **`list()` returns `modifiedTime`** — `files.ts`'s `list()` requests `modifiedTime` alongside `id,name,mimeType,version` (same field `fetchRemoteVersion` already fetches per-file for `status()`); `FileRef.modifiedTime` (`types.ts`) is optional since older/unfetched responses may omit it.
98
+
97
99
  ## 3. Storage layout
98
100
 
99
101
  Each project gets its own IndexedDB database: **`owa-drive-{appId}-{projectId}`**, version 1, containing one object store, `auth` (`storage.ts`). The store holds exactly two keys:
package/dist/files.js CHANGED
@@ -292,8 +292,10 @@ function buildQuery(opts) {
292
292
  export async function list(opts) {
293
293
  const q = buildQuery(opts);
294
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)')}`;
295
+ // staleness check without a follow-up metadata fetch. `modifiedTime` is
296
+ // also requested so callers can get a last-modified timestamp per file
297
+ // without an extra round trip.
298
+ const url = `${DRIVE_BASE}/files?q=${encodeURIComponent(q)}&fields=${encodeURIComponent('files(id,name,mimeType,version,modifiedTime)')}`;
297
299
  const res = await driveFetch({
298
300
  appId: opts.appId,
299
301
  projectId: opts.projectId,
package/dist/http.js CHANGED
@@ -1,9 +1,11 @@
1
1
  import { acquireToken } from './token.js';
2
2
  import { getConnection, refreshSilently } from './connection.js';
3
- import { clearToken } from './storage.js';
3
+ import { clearToken, getToken } from './storage.js';
4
4
  import { DriveSyncError, NeedsReauthError, ScopeInsufficientError, NotFoundError, RateLimitedError, TransientError, WrongAccountError, } from './errors.js';
5
5
  const MAX_ATTEMPTS = 3;
6
6
  const BASE_DELAY_MS = 500;
7
+ /** Mirrors connection.ts's own buffer: a cached token this close to expiry is treated as unusable. */
8
+ const TOKEN_REUSE_BUFFER_MS = 5 * 60 * 1000;
7
9
  function isRetryableStatus(status) {
8
10
  return status === 429 || (status >= 500 && status <= 599);
9
11
  }
@@ -40,6 +42,17 @@ async function sleep(ms) {
40
42
  export async function driveFetch(opts) {
41
43
  const { appId, projectId, clientId, requiredScopes, logger } = opts;
42
44
  const interactive = !!opts.interactive;
45
+ // Reuse a still-valid cached token as-is on the non-interactive path,
46
+ // exactly like connection.ts's getAccessToken() already does — without
47
+ // this check every single Drive call (not just an actual token expiry)
48
+ // forced its own live, non-interactive GIS round-trip via acquireToken
49
+ // below, even when the cached token had plenty of life left.
50
+ if (!interactive) {
51
+ const cached = await getToken(appId, projectId);
52
+ if (cached && cached.expiresAt > Date.now() + TOKEN_REUSE_BUFFER_MS) {
53
+ return performFetch(opts, cached.accessToken, /* isRetryAfter401 */ false);
54
+ }
55
+ }
43
56
  // Resolve a hint email from the current connection (if any) so a
44
57
  // non-interactive silent refresh can target the right account. If there is
45
58
  // no connection at all and interactive is false, acquireToken's silent
@@ -25,6 +25,12 @@ export interface DriveFakeFile {
25
25
  * Optional so tests may seed files without it; treated as 1 when absent.
26
26
  */
27
27
  version?: number;
28
+ /**
29
+ * Drive's last-modified timestamp; optional so tests may seed files
30
+ * without it (omitted from the fake's response in that case, matching
31
+ * real Drive's `fields`-gated behavior).
32
+ */
33
+ modifiedTime?: string;
28
34
  }
29
35
  export interface DriveFakePermission {
30
36
  id: string;
@@ -142,7 +142,14 @@ 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, version: String(f.version ?? 1) };
145
+ return {
146
+ id: f.id,
147
+ name: f.name,
148
+ mimeType: f.mimeType,
149
+ parents: f.parents,
150
+ version: String(f.version ?? 1),
151
+ modifiedTime: f.modifiedTime,
152
+ };
146
153
  }
147
154
  async function handleFilesList(url) {
148
155
  const q = url.searchParams.get('q') ?? '';
package/dist/types.d.ts CHANGED
@@ -29,6 +29,8 @@ export interface FileRef {
29
29
  name?: string;
30
30
  /** Drive's monotonic change counter, when the call requested it. */
31
31
  version?: string;
32
+ /** Drive's last-modified timestamp (RFC3339), when the call requested it. */
33
+ modifiedTime?: string;
32
34
  }
33
35
  /**
34
36
  * Sync state of one file relative to what this client last restored.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-webapp/drive-sync",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",