@open-webapp/drive-sync 0.7.1 → 0.8.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 +16 -0
- package/SPEC.md +22 -1
- package/dist/connectionSnapshot.d.ts +17 -0
- package/dist/connectionSnapshot.js +48 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +64 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -38,6 +38,22 @@ const ref = await p.files.update(fileId, {
|
|
|
38
38
|
})
|
|
39
39
|
```
|
|
40
40
|
|
|
41
|
+
### Synchronous connection snapshot
|
|
42
|
+
|
|
43
|
+
`p.getConnectionSync()` / `p.subscribeConnection()` give a framework store a
|
|
44
|
+
synchronous, referentially-stable `Connection | null` without polling:
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
const store = {
|
|
48
|
+
get: () => p.getConnectionSync(),
|
|
49
|
+
subscribe: (onChange: () => void) => p.subscribeConnection(onChange),
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
This pairing is the intended backing for `@open-webapp/drive-connect`'s React
|
|
54
|
+
hook (`useDriveConnection`), and works equally well for any other
|
|
55
|
+
`useSyncExternalStore`-shaped store.
|
|
56
|
+
|
|
41
57
|
See `SPEC.md` for the full design: the 36 resolved decisions, storage layout,
|
|
42
58
|
and refresh state machine. `SPEC.md` is descriptive, written from the shipped
|
|
43
59
|
code — if it ever disagrees with the source, the source wins.
|
package/SPEC.md
CHANGED
|
@@ -30,6 +30,8 @@ await drive.reconcile(knownProjectIds); // drop orphaned per-project au
|
|
|
30
30
|
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
|
+
const snap = p.getConnectionSync(); // Connection | null, synchronous, referentially stable
|
|
34
|
+
const unsub = p.subscribeConnection(() => {}); // fires when the snapshot reference changes
|
|
33
35
|
|
|
34
36
|
const picked = await p.pickFile({ apiKey: PICKER_API_KEY, appId: GCP_PROJECT_NUMBER }); // file-selection via Google Picker
|
|
35
37
|
const folderId = await p.ensureFolderPath();
|
|
@@ -46,10 +48,27 @@ dispose();
|
|
|
46
48
|
|
|
47
49
|
`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.
|
|
48
50
|
|
|
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`).
|
|
51
|
+
Files implementing the surface: `index.ts` (factory + `ProjectHandle`/`FilesHandle`/`PermissionsHandle`, plus `getConnectionSync`/`subscribeConnection`), `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`).
|
|
50
52
|
|
|
51
53
|
`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
54
|
|
|
55
|
+
### Connection snapshot
|
|
56
|
+
|
|
57
|
+
`index.ts` holds one in-memory `Connection | null` per `projectId`, in a `snapshotStores` Map inside the `createDriveSync` closure (alongside `trackedProjectIds`) — one `ConnectionSnapshotStore` (`connectionSnapshot.ts`) per project, created lazily on first access. Each store shallow-compares the incoming `Connection` on `email`/`needsReauth`/`expiresAt` before replacing its held reference, so `get()` returns a referentially-stable value suitable for `useSyncExternalStore`.
|
|
58
|
+
|
|
59
|
+
`null` from `getConnectionSync()` is ambiguous by design: it means either "disconnected" or "not yet hydrated" (the first background re-read from IndexedDB hasn't resolved). Callers cannot distinguish the two from the return value alone.
|
|
60
|
+
|
|
61
|
+
The snapshot is (re-)read from IndexedDB via `getConnection()` in several places, all but one of them fire-and-forget:
|
|
62
|
+
|
|
63
|
+
- **Lazily, on first access** — the first call to `getConnectionSync()` or `subscribeConnection()` for a project kicks off a `void`-ed re-read.
|
|
64
|
+
- **On a background warm-up** — `refresh.ts`'s `warmUpIfNeeded`, run from `activate()`'s `visibilitychange`/`pageshow` handlers.
|
|
65
|
+
- **On a cross-tab `logout`/`token` broadcast** — `handleBroadcast` re-reads the snapshot for the affected project. This only fires while `activate()` has been called (broadcast listening starts there).
|
|
66
|
+
- **For every tracked project, at `activate()` itself** — so already-registered projects get an immediate re-read when activation starts.
|
|
67
|
+
|
|
68
|
+
**The one exception:** after `connect()`/`disconnect()`, the re-read is `await`ed *before* the call resolves. This is the one place callers can rely on synchronous-after-await freshness — the moment `await p.connect()` (or `disconnect()`) returns, `p.getConnectionSync()` already reflects the new state. Every other re-read above is intentionally fire-and-forget, since nothing is awaiting them to observe the snapshot synchronously.
|
|
69
|
+
|
|
70
|
+
One behavioral consequence worth calling out: because the broadcast handler re-reads on `logout`, a `disconnect()` in one tab becomes observable to `subscribeConnection()` listeners in every other open tab — a capability the async-only `getConnection()` never had (nothing pushes to it).
|
|
71
|
+
|
|
53
72
|
## 2. The 41 resolved design decisions
|
|
54
73
|
|
|
55
74
|
**Bugs fixed (both source apps carried these):**
|
|
@@ -109,6 +128,8 @@ Files implementing the surface: `index.ts` (factory + `ProjectHandle`/`FilesHand
|
|
|
109
128
|
|
|
110
129
|
42. **`list()` passes through `thumbnailLink` + `imageMediaMetadata`, unfiltered and verbatim** — `files.ts`'s `list()` extends the same `fields` mask touched in #36's `modifiedTime` change to also request `thumbnailLink` and `imageMediaMetadata(width,height,rotation)`; `FileRef` (`types.ts`) gains three optional fields — `mimeType?` (already fetched, previously just untyped), `thumbnailLink?`, and `imageMediaMetadata?: { width?; height?; rotation? }` — all `fields`-gated and may be absent on older or partial responses. `list()` stays unfiltered: it returns every file of any MIME type with no `image/` check and no opt-in flag, and `thumbnailLink` is passed through exactly as Drive returns it — no blob fetch, no URL rewrite, no `=s220` size munging, and no `files.thumbnail()` helper. Caveat: `thumbnailLink` is a short-lived URL (good for only ~hours) that can require the browser to be carrying Google auth context for the file's owning account, so a cross-origin bare `<img src>` may 403; rendering is the consuming app's responsibility, and it can fall back to `getAccessToken()` + fetch-to-blob itself. (Label is `42` though this is only the 41st entry — the section carries a duplicate `7.` label and a merged `25–27.` entry, so the labels have always run one ahead of the item count; no existing entry is renumbered.)
|
|
111
130
|
|
|
131
|
+
43. **Synchronous connection snapshot, per project** — `index.ts`'s `ProjectHandle` gains `getConnectionSync(): Connection | null` and `subscribeConnection(cb): () => void`, backed by a new `connectionSnapshot.ts` (`createConnectionSnapshotStore`) held per `projectId` in the `snapshotStores` Map alongside `trackedProjectIds`. The store shallow-compares `email`/`needsReauth`/`expiresAt` so its reference stays stable across no-op re-reads (`useSyncExternalStore`-friendly). It is populated lazily on first access, kept warm by the same warm-up/broadcast/`activate()` paths that already existed, and — the one synchronous-after-await guarantee in the package — re-read and `await`ed to completion inside `connect()`/`disconnect()` before those calls resolve. A side effect: cross-tab `logout` broadcasts now push a visible state change to `subscribeConnection()` listeners, which the async-only `getConnection()` never did.
|
|
132
|
+
|
|
112
133
|
## 3. Storage layout
|
|
113
134
|
|
|
114
135
|
Each project gets its own IndexedDB database: **`owa-drive-{appId}-{projectId}`**, version 1, containing one object store, `auth` (`storage.ts`). The store holds up to three keys (`conn`/`token` always; `envelope` only in server-facilitated token-exchange mode):
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Connection } from './types.js';
|
|
2
|
+
export interface ConnectionSnapshotStore {
|
|
3
|
+
get(): Connection | null;
|
|
4
|
+
commit(next: Connection | null): void;
|
|
5
|
+
subscribe(fn: () => void): () => void;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Tiny non-React store suitable for use with useSyncExternalStore, holding
|
|
9
|
+
* the durable Connection snapshot (or null when disconnected).
|
|
10
|
+
*
|
|
11
|
+
* - `get()` returns a stable reference; the internal snapshot is only
|
|
12
|
+
* replaced when at least one of the 3 fields differs (shallow compare).
|
|
13
|
+
* - `commit` notifies listeners only when the snapshot reference changed.
|
|
14
|
+
* - `subscribe` returns an unsubscribe fn; unsubscribing during a notify
|
|
15
|
+
* pass is safe (listeners are iterated over a copy).
|
|
16
|
+
*/
|
|
17
|
+
export declare function createConnectionSnapshotStore(): ConnectionSnapshotStore;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
function shallowEqualConn(a, b) {
|
|
2
|
+
if (a === null && b === null)
|
|
3
|
+
return true;
|
|
4
|
+
if (a === null || b === null)
|
|
5
|
+
return false;
|
|
6
|
+
return (a.email === b.email &&
|
|
7
|
+
a.needsReauth === b.needsReauth &&
|
|
8
|
+
a.expiresAt === b.expiresAt);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Tiny non-React store suitable for use with useSyncExternalStore, holding
|
|
12
|
+
* the durable Connection snapshot (or null when disconnected).
|
|
13
|
+
*
|
|
14
|
+
* - `get()` returns a stable reference; the internal snapshot is only
|
|
15
|
+
* replaced when at least one of the 3 fields differs (shallow compare).
|
|
16
|
+
* - `commit` notifies listeners only when the snapshot reference changed.
|
|
17
|
+
* - `subscribe` returns an unsubscribe fn; unsubscribing during a notify
|
|
18
|
+
* pass is safe (listeners are iterated over a copy).
|
|
19
|
+
*/
|
|
20
|
+
export function createConnectionSnapshotStore() {
|
|
21
|
+
let snapshot = null;
|
|
22
|
+
const listeners = new Set();
|
|
23
|
+
function notify() {
|
|
24
|
+
for (const fn of [...listeners]) {
|
|
25
|
+
fn();
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function commit(next) {
|
|
29
|
+
if (shallowEqualConn(snapshot, next))
|
|
30
|
+
return;
|
|
31
|
+
snapshot = next;
|
|
32
|
+
notify();
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
get() {
|
|
36
|
+
return snapshot;
|
|
37
|
+
},
|
|
38
|
+
commit(next) {
|
|
39
|
+
commit(next);
|
|
40
|
+
},
|
|
41
|
+
subscribe(fn) {
|
|
42
|
+
listeners.add(fn);
|
|
43
|
+
return () => {
|
|
44
|
+
listeners.delete(fn);
|
|
45
|
+
};
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -58,6 +58,20 @@ export interface ProjectHandle {
|
|
|
58
58
|
*/
|
|
59
59
|
getAccessToken(callOpts?: CallOptions): Promise<string>;
|
|
60
60
|
pickFile(options: PickFileOptions): Promise<PickedFile[]>;
|
|
61
|
+
/**
|
|
62
|
+
* Synchronous snapshot of the current Connection, backed by the
|
|
63
|
+
* per-project connectionSnapshot store (see connectionSnapshot.ts).
|
|
64
|
+
* Never returns a Promise and never throws. `null` means either
|
|
65
|
+
* disconnected, or not-yet-hydrated (the first background re-read from
|
|
66
|
+
* IndexedDB hasn't resolved yet) — callers cannot distinguish the two
|
|
67
|
+
* from this alone.
|
|
68
|
+
*/
|
|
69
|
+
getConnectionSync(): Connection | null;
|
|
70
|
+
/**
|
|
71
|
+
* Subscribes to changes in the synchronous Connection snapshot (suitable
|
|
72
|
+
* for `useSyncExternalStore`). Returns an unsubscribe function.
|
|
73
|
+
*/
|
|
74
|
+
subscribeConnection(cb: () => void): () => void;
|
|
61
75
|
files: FilesHandle;
|
|
62
76
|
permissions: PermissionsHandle;
|
|
63
77
|
}
|
package/dist/index.js
CHANGED
|
@@ -6,6 +6,7 @@ import * as permissionsImpl from './permissions.js';
|
|
|
6
6
|
import * as pickerImpl from './picker.js';
|
|
7
7
|
import { warmUpIfNeeded } from './refresh.js';
|
|
8
8
|
import { REQUIRED_SCOPES } from './files.js';
|
|
9
|
+
import { createConnectionSnapshotStore } from './connectionSnapshot.js';
|
|
9
10
|
import { createBroadcast } from './broadcast.js';
|
|
10
11
|
import { evictDbHandle } from './storage.js';
|
|
11
12
|
import { notifyExternalTokenRefresh } from './token.js';
|
|
@@ -68,6 +69,43 @@ export function createDriveSync(options) {
|
|
|
68
69
|
function trackProject(projectId) {
|
|
69
70
|
trackedProjectIds.add(projectId);
|
|
70
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* Per-project synchronous Connection snapshot stores (see
|
|
74
|
+
* connectionSnapshot.ts). Populated lazily by `getOrInitStore` the first
|
|
75
|
+
* time either `getConnectionSync()` or `subscribeConnection()` is called
|
|
76
|
+
* for a given project; a later task (T3) will also commit into these
|
|
77
|
+
* stores after connect()/disconnect() resolve.
|
|
78
|
+
*/
|
|
79
|
+
const snapshotStores = new Map();
|
|
80
|
+
/**
|
|
81
|
+
* Re-reads the durable Connection for `projectId` from IndexedDB (via
|
|
82
|
+
* connection.ts's `getConnection`) and commits the result into that
|
|
83
|
+
* project's snapshot store. Returns the underlying promise (rather than
|
|
84
|
+
* `void`-ing it here) so a LATER task (T3) can `await` it after
|
|
85
|
+
* connect()/disconnect(); call sites in THIS task fire it with `void`.
|
|
86
|
+
*/
|
|
87
|
+
function reReadConnection(projectId) {
|
|
88
|
+
return getConnectionImpl({
|
|
89
|
+
appId,
|
|
90
|
+
projectId,
|
|
91
|
+
requiredScopes: REQUIRED_SCOPES,
|
|
92
|
+
})
|
|
93
|
+
.then((conn) => {
|
|
94
|
+
getOrInitStore(projectId).commit(conn);
|
|
95
|
+
})
|
|
96
|
+
.catch((err) => {
|
|
97
|
+
logger.warn('drive-sync: connection snapshot re-read failed', { err });
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
function getOrInitStore(projectId) {
|
|
101
|
+
let store = snapshotStores.get(projectId);
|
|
102
|
+
if (!store) {
|
|
103
|
+
store = createConnectionSnapshotStore();
|
|
104
|
+
snapshotStores.set(projectId, store);
|
|
105
|
+
void reReadConnection(projectId);
|
|
106
|
+
}
|
|
107
|
+
return store;
|
|
108
|
+
}
|
|
71
109
|
/**
|
|
72
110
|
* Handles a cross-tab broadcast (see broadcast.ts) received while active.
|
|
73
111
|
* `logout`: another tab's disconnect() already cleared BOTH IndexedDB
|
|
@@ -87,19 +125,23 @@ export function createDriveSync(options) {
|
|
|
87
125
|
function handleBroadcast(msg) {
|
|
88
126
|
if (msg.type === 'logout') {
|
|
89
127
|
void evictDbHandle(appId, msg.projectId);
|
|
128
|
+
reReadConnection(msg.projectId);
|
|
90
129
|
}
|
|
91
130
|
else if (msg.type === 'token') {
|
|
92
131
|
notifyExternalTokenRefresh(msg.projectId);
|
|
132
|
+
reReadConnection(msg.projectId);
|
|
93
133
|
}
|
|
94
134
|
}
|
|
95
135
|
function activate() {
|
|
96
136
|
const disposeBroadcast = createBroadcast(appId).onMessage(handleBroadcast);
|
|
137
|
+
for (const projectId of trackedProjectIds)
|
|
138
|
+
reReadConnection(projectId);
|
|
97
139
|
if (typeof document === 'undefined') {
|
|
98
140
|
return disposeBroadcast;
|
|
99
141
|
}
|
|
100
142
|
const runWarmUps = () => {
|
|
101
143
|
for (const projectId of trackedProjectIds) {
|
|
102
|
-
void warmUpIfNeeded({ appId, projectId, clientId, tokenExchangeUrl, fetchEmail, logger });
|
|
144
|
+
void warmUpIfNeeded({ appId, projectId, clientId, tokenExchangeUrl, fetchEmail, logger }).then(() => reReadConnection(projectId), () => reReadConnection(projectId));
|
|
103
145
|
}
|
|
104
146
|
};
|
|
105
147
|
const onVisibilityChange = () => {
|
|
@@ -164,8 +206,8 @@ export function createDriveSync(options) {
|
|
|
164
206
|
},
|
|
165
207
|
};
|
|
166
208
|
return {
|
|
167
|
-
connect() {
|
|
168
|
-
|
|
209
|
+
async connect() {
|
|
210
|
+
const connection = await connectImpl({
|
|
169
211
|
appId,
|
|
170
212
|
projectId,
|
|
171
213
|
clientId,
|
|
@@ -174,6 +216,15 @@ export function createDriveSync(options) {
|
|
|
174
216
|
logger,
|
|
175
217
|
fetchEmail,
|
|
176
218
|
});
|
|
219
|
+
// Awaited (not fire-and-forget): callers of `await connect()` must
|
|
220
|
+
// see `getConnectionSync()` already reflect this new connection the
|
|
221
|
+
// moment the promise settles — see reReadConnection's docstring.
|
|
222
|
+
// Contrast with warm-up/broadcast/lazy-kick re-reads elsewhere in
|
|
223
|
+
// this file, which are intentionally fire-and-forget (`void
|
|
224
|
+
// reReadConnection(...)`) since nothing is awaiting them to observe
|
|
225
|
+
// the snapshot synchronously.
|
|
226
|
+
await reReadConnection(projectId);
|
|
227
|
+
return connection;
|
|
177
228
|
},
|
|
178
229
|
getConnection() {
|
|
179
230
|
return getConnectionImpl({
|
|
@@ -201,6 +252,10 @@ export function createDriveSync(options) {
|
|
|
201
252
|
},
|
|
202
253
|
};
|
|
203
254
|
await disconnectImpl(disconnectOpts);
|
|
255
|
+
// Awaited, same reasoning as connect() above: `await disconnect()`
|
|
256
|
+
// must not settle until `getConnectionSync()` reflects the
|
|
257
|
+
// post-disconnect snapshot.
|
|
258
|
+
await reReadConnection(projectId);
|
|
204
259
|
},
|
|
205
260
|
ensureFolderPath() {
|
|
206
261
|
return filesImpl.ensureFolderPath({ ...base, folderPath });
|
|
@@ -245,6 +300,12 @@ export function createDriveSync(options) {
|
|
|
245
300
|
}
|
|
246
301
|
return results;
|
|
247
302
|
},
|
|
303
|
+
getConnectionSync() {
|
|
304
|
+
return getOrInitStore(projectId).get();
|
|
305
|
+
},
|
|
306
|
+
subscribeConnection(cb) {
|
|
307
|
+
return getOrInitStore(projectId).subscribe(cb);
|
|
308
|
+
},
|
|
248
309
|
files,
|
|
249
310
|
permissions,
|
|
250
311
|
};
|