@open-webapp/drive-sync 0.1.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/dist/index.js ADDED
@@ -0,0 +1,207 @@
1
+ import { noOpLogger } from './logger.js';
2
+ import { connect as connectImpl, getConnection as getConnectionImpl, disconnect as disconnectImpl } from './connection.js';
3
+ import { reconcile as reconcileImpl, dropProject as dropProjectImpl } from './reconcile.js';
4
+ import * as filesImpl from './files.js';
5
+ import * as permissionsImpl from './permissions.js';
6
+ import { warmUpIfNeeded } from './refresh.js';
7
+ import { REQUIRED_SCOPES } from './files.js';
8
+ import { createBroadcast } from './broadcast.js';
9
+ import { evictDbHandle } from './storage.js';
10
+ import { notifyExternalTokenRefresh } from './token.js';
11
+ export * from './errors.js';
12
+ const USERINFO_URL = 'https://www.googleapis.com/oauth2/v3/userinfo';
13
+ const REVOKE_URL = 'https://oauth2.googleapis.com/revoke';
14
+ /**
15
+ * Resolves the connected account's email from a freshly-acquired access
16
+ * token. Implemented with a plain `fetch` (not `driveFetch`) because it is
17
+ * authenticated with a token obtained moments earlier in the SAME connect()
18
+ * call — there is no independent auth/retry concern here, so going through
19
+ * driveFetch's own internal token acquisition would be redundant and wrong
20
+ * (driveFetch always acquires its own token rather than accepting one).
21
+ */
22
+ async function fetchEmail(accessToken) {
23
+ const res = await fetch(USERINFO_URL, {
24
+ headers: { Authorization: `Bearer ${accessToken}` },
25
+ });
26
+ if (!res.ok) {
27
+ throw new Error(`Failed to fetch userinfo: ${res.status}`);
28
+ }
29
+ const json = (await res.json());
30
+ if (!json.email) {
31
+ throw new Error('userinfo response did not include an email');
32
+ }
33
+ return json.email;
34
+ }
35
+ /** Fire-and-forget revoke against Google's real token revocation endpoint. */
36
+ async function revokeToken(accessToken) {
37
+ await fetch(REVOKE_URL, {
38
+ method: 'POST',
39
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
40
+ body: `token=${encodeURIComponent(accessToken)}`,
41
+ });
42
+ }
43
+ /**
44
+ * Creates the drive-sync facade. `createDriveSync` itself attaches NO
45
+ * listeners and makes NO network calls — everything is deferred until the
46
+ * returned object's methods are actually called.
47
+ */
48
+ export function createDriveSync(options) {
49
+ const { appId, clientId, folderPath } = options;
50
+ const logger = options.logger ?? noOpLogger;
51
+ /**
52
+ * Design choice for `.activate()` (T30): the frozen public API's
53
+ * `activate()` takes no project argument, but the actual background
54
+ * token-refresh work (T29, refresh.ts) is inherently per-project. There is
55
+ * no explicit registry of "active projects" anywhere in the frozen API
56
+ * sketch, so this fills that gap by tracking, in this closure, every
57
+ * projectId that `.project(id)` has ever been called with. `.activate()`
58
+ * attaches exactly ONE global `visibilitychange`/`pageshow` listener pair
59
+ * (not one pair per project); when either fires, the handler iterates
60
+ * this live Set and runs refresh.ts's `warmUpIfNeeded` check for each
61
+ * tracked project. Because the Set is read at event-fire time (not
62
+ * snapshotted when `.activate()` is called), projects registered via
63
+ * `.project(id)` AFTER `.activate()` runs are still covered — call order
64
+ * between `.activate()` and `.project(id)` does not matter.
65
+ */
66
+ const trackedProjectIds = new Set();
67
+ function trackProject(projectId) {
68
+ trackedProjectIds.add(projectId);
69
+ }
70
+ /**
71
+ * Handles a cross-tab broadcast (see broadcast.ts) received while active.
72
+ * `logout`: another tab's disconnect() already cleared BOTH IndexedDB
73
+ * records (connection + token) before broadcasting, and this tab's own
74
+ * getConnection()/getToken() calls always read that same shared
75
+ * IndexedDB directly, so there is no stale in-memory Connection/email
76
+ * cache here to clear (index.ts, connection.ts, and refresh.ts hold no
77
+ * such cache). The one piece of genuinely in-memory, per-tab state is
78
+ * storage.ts's cached IDB *handle* — evicting it is a defensive no-op in
79
+ * practice (reads against an already-open handle already see the other
80
+ * tab's writes), but costs nothing and guards against any future
81
+ * handle-level caching assumptions.
82
+ * `token`: signals token.ts's per-tab in-flight-coalescing layer that a
83
+ * fresh token now sits in shared storage, so the NEXT non-interactive
84
+ * acquireToken() call for this project can skip its own GIS round-trip.
85
+ */
86
+ function handleBroadcast(msg) {
87
+ if (msg.type === 'logout') {
88
+ void evictDbHandle(appId, msg.projectId);
89
+ }
90
+ else if (msg.type === 'token') {
91
+ notifyExternalTokenRefresh(msg.projectId);
92
+ }
93
+ }
94
+ function activate() {
95
+ const disposeBroadcast = createBroadcast(appId).onMessage(handleBroadcast);
96
+ if (typeof document === 'undefined') {
97
+ return disposeBroadcast;
98
+ }
99
+ const runWarmUps = () => {
100
+ for (const projectId of trackedProjectIds) {
101
+ void warmUpIfNeeded({ appId, projectId, clientId, fetchEmail, logger });
102
+ }
103
+ };
104
+ const onVisibilityChange = () => {
105
+ if (document.visibilityState !== 'visible')
106
+ return;
107
+ runWarmUps();
108
+ };
109
+ const onPageShow = (event) => {
110
+ const persisted = event.persisted;
111
+ if (!persisted)
112
+ return;
113
+ if (document.hidden)
114
+ return;
115
+ runWarmUps();
116
+ };
117
+ document.addEventListener('visibilitychange', onVisibilityChange);
118
+ window.addEventListener('pageshow', onPageShow);
119
+ return () => {
120
+ disposeBroadcast();
121
+ document.removeEventListener('visibilitychange', onVisibilityChange);
122
+ window.removeEventListener('pageshow', onPageShow);
123
+ };
124
+ }
125
+ function reconcile(knownProjectIds) {
126
+ return reconcileImpl(appId, knownProjectIds);
127
+ }
128
+ function dropProject(projectId) {
129
+ return dropProjectImpl(appId, projectId);
130
+ }
131
+ function project(projectId) {
132
+ trackProject(projectId);
133
+ const base = { appId, projectId, clientId, logger, fetchEmail };
134
+ const files = {
135
+ list(opts, callOpts) {
136
+ return filesImpl.list({ ...base, ...opts, interactive: callOpts?.interactive });
137
+ },
138
+ read(fileId, callOpts) {
139
+ return filesImpl.read({ ...base, fileId, interactive: callOpts?.interactive });
140
+ },
141
+ write(opts, callOpts) {
142
+ return filesImpl.write({ ...base, ...opts, interactive: callOpts?.interactive });
143
+ },
144
+ remove(fileId, callOpts) {
145
+ return filesImpl.remove({ ...base, fileId, interactive: callOpts?.interactive });
146
+ },
147
+ };
148
+ const permissions = {
149
+ list(fileId, callOpts) {
150
+ return permissionsImpl.list({ ...base, fileId, interactive: callOpts?.interactive });
151
+ },
152
+ grant(opts, callOpts) {
153
+ return permissionsImpl.grant({ ...base, ...opts, interactive: callOpts?.interactive });
154
+ },
155
+ update(opts, callOpts) {
156
+ return permissionsImpl.update({ ...base, ...opts, interactive: callOpts?.interactive });
157
+ },
158
+ revoke(opts, callOpts) {
159
+ return permissionsImpl.revoke({ ...base, ...opts, interactive: callOpts?.interactive });
160
+ },
161
+ };
162
+ return {
163
+ connect() {
164
+ return connectImpl({
165
+ appId,
166
+ projectId,
167
+ clientId,
168
+ scopes: REQUIRED_SCOPES,
169
+ logger,
170
+ fetchEmail,
171
+ });
172
+ },
173
+ getConnection() {
174
+ return getConnectionImpl({
175
+ appId,
176
+ projectId,
177
+ requiredScopes: REQUIRED_SCOPES,
178
+ });
179
+ },
180
+ async disconnect() {
181
+ await disconnectImpl({
182
+ appId,
183
+ projectId,
184
+ revokeFn: async (accessToken) => {
185
+ try {
186
+ await revokeToken(accessToken);
187
+ }
188
+ catch (err) {
189
+ logger.warn('drive-sync: token revocation failed', { err });
190
+ }
191
+ },
192
+ });
193
+ },
194
+ ensureFolderPath() {
195
+ return filesImpl.ensureFolderPath({ ...base, folderPath });
196
+ },
197
+ files,
198
+ permissions,
199
+ };
200
+ }
201
+ return {
202
+ activate,
203
+ reconcile,
204
+ dropProject,
205
+ project,
206
+ };
207
+ }
@@ -0,0 +1,7 @@
1
+ export interface Logger {
2
+ debug(...args: unknown[]): void;
3
+ info(...args: unknown[]): void;
4
+ warn(...args: unknown[]): void;
5
+ error(...args: unknown[]): void;
6
+ }
7
+ export declare const noOpLogger: Logger;
package/dist/logger.js ADDED
@@ -0,0 +1,6 @@
1
+ export const noOpLogger = {
2
+ debug() { },
3
+ info() { },
4
+ warn() { },
5
+ error() { },
6
+ };
@@ -0,0 +1,37 @@
1
+ import type { Logger } from './logger.js';
2
+ import type { DrivePermission } from './types.js';
3
+ interface BaseCallOptions {
4
+ appId: string;
5
+ projectId: string;
6
+ clientId: string;
7
+ interactive?: boolean;
8
+ logger?: Logger;
9
+ fetchEmail?: (accessToken: string) => Promise<string>;
10
+ }
11
+ export interface ListPermissionsOptions extends BaseCallOptions {
12
+ fileId: string;
13
+ }
14
+ export declare function list(opts: ListPermissionsOptions): Promise<DrivePermission[]>;
15
+ export interface GrantPermissionOptions extends BaseCallOptions {
16
+ fileId: string;
17
+ type: 'user' | 'anyone';
18
+ role: string;
19
+ emailAddress?: string;
20
+ }
21
+ /**
22
+ * Folds the old separate "createAnyonePermission" helper into a single
23
+ * function keyed by `type`.
24
+ */
25
+ export declare function grant(opts: GrantPermissionOptions): Promise<DrivePermission>;
26
+ export interface UpdatePermissionOptions extends BaseCallOptions {
27
+ fileId: string;
28
+ permissionId: string;
29
+ role: string;
30
+ }
31
+ export declare function update(opts: UpdatePermissionOptions): Promise<DrivePermission>;
32
+ export interface RevokePermissionOptions extends BaseCallOptions {
33
+ fileId: string;
34
+ permissionId: string;
35
+ }
36
+ export declare function revoke(opts: RevokePermissionOptions): Promise<void>;
37
+ export {};
@@ -0,0 +1,71 @@
1
+ import { driveFetch } from './http.js';
2
+ import { REQUIRED_SCOPES } from './files.js';
3
+ const DRIVE_BASE = 'https://www.googleapis.com/drive/v3';
4
+ export async function list(opts) {
5
+ const res = await driveFetch({
6
+ appId: opts.appId,
7
+ projectId: opts.projectId,
8
+ clientId: opts.clientId,
9
+ url: `${DRIVE_BASE}/files/${encodeURIComponent(opts.fileId)}/permissions?fields=${encodeURIComponent('permissions(id,type,role,emailAddress)')}`,
10
+ method: 'GET',
11
+ interactive: opts.interactive,
12
+ requiredScopes: REQUIRED_SCOPES,
13
+ logger: opts.logger,
14
+ fetchEmail: opts.fetchEmail,
15
+ });
16
+ const json = (await res.json());
17
+ return json.permissions ?? [];
18
+ }
19
+ /**
20
+ * Folds the old separate "createAnyonePermission" helper into a single
21
+ * function keyed by `type`.
22
+ */
23
+ export async function grant(opts) {
24
+ const res = await driveFetch({
25
+ appId: opts.appId,
26
+ projectId: opts.projectId,
27
+ clientId: opts.clientId,
28
+ url: `${DRIVE_BASE}/files/${encodeURIComponent(opts.fileId)}/permissions`,
29
+ method: 'POST',
30
+ headers: { 'Content-Type': 'application/json' },
31
+ body: JSON.stringify({
32
+ type: opts.type,
33
+ role: opts.role,
34
+ emailAddress: opts.type === 'user' ? opts.emailAddress : undefined,
35
+ }),
36
+ interactive: opts.interactive,
37
+ requiredScopes: REQUIRED_SCOPES,
38
+ logger: opts.logger,
39
+ fetchEmail: opts.fetchEmail,
40
+ });
41
+ return (await res.json());
42
+ }
43
+ export async function update(opts) {
44
+ const res = await driveFetch({
45
+ appId: opts.appId,
46
+ projectId: opts.projectId,
47
+ clientId: opts.clientId,
48
+ url: `${DRIVE_BASE}/files/${encodeURIComponent(opts.fileId)}/permissions/${encodeURIComponent(opts.permissionId)}`,
49
+ method: 'PATCH',
50
+ headers: { 'Content-Type': 'application/json' },
51
+ body: JSON.stringify({ role: opts.role }),
52
+ interactive: opts.interactive,
53
+ requiredScopes: REQUIRED_SCOPES,
54
+ logger: opts.logger,
55
+ fetchEmail: opts.fetchEmail,
56
+ });
57
+ return (await res.json());
58
+ }
59
+ export async function revoke(opts) {
60
+ await driveFetch({
61
+ appId: opts.appId,
62
+ projectId: opts.projectId,
63
+ clientId: opts.clientId,
64
+ url: `${DRIVE_BASE}/files/${encodeURIComponent(opts.fileId)}/permissions/${encodeURIComponent(opts.permissionId)}`,
65
+ method: 'DELETE',
66
+ interactive: opts.interactive,
67
+ requiredScopes: REQUIRED_SCOPES,
68
+ logger: opts.logger,
69
+ fetchEmail: opts.fetchEmail,
70
+ });
71
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Escapes a literal string for safe embedding inside a Drive `q=` search
3
+ * parameter value, e.g. `name='...'`. Per Drive's query escaping rules,
4
+ * backslashes must be escaped FIRST, then single quotes — escaping the quote
5
+ * first would double-escape the backslashes just inserted for the quote.
6
+ */
7
+ export declare function escapeQ(s: string): string;
package/dist/query.js ADDED
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Escapes a literal string for safe embedding inside a Drive `q=` search
3
+ * parameter value, e.g. `name='...'`. Per Drive's query escaping rules,
4
+ * backslashes must be escaped FIRST, then single quotes — escaping the quote
5
+ * first would double-escape the backslashes just inserted for the quote.
6
+ */
7
+ export function escapeQ(s) {
8
+ return s.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
9
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Safety-net cleanup: enumerate all owa-drive databases for this appId and
3
+ * delete any whose projectId is not in knownProjectIds. No-ops if
4
+ * indexedDB.databases() is unsupported (e.g. Firefox, older Safari).
5
+ */
6
+ export declare function reconcile(appId: string, knownProjectIds: string[]): Promise<void>;
7
+ /**
8
+ * Primary cleanup path: eagerly delete one specific project's database.
9
+ * Apps should call this directly when a project is removed rather than
10
+ * relying on reconcile() as the only mechanism.
11
+ */
12
+ export declare function dropProject(appId: string, projectId: string): Promise<void>;
@@ -0,0 +1,45 @@
1
+ import { evictDbHandle } from './storage.js';
2
+ function dbPrefix(appId) {
3
+ return `owa-drive-${appId}-`;
4
+ }
5
+ function deleteDatabase(name) {
6
+ return new Promise((resolve, reject) => {
7
+ const request = indexedDB.deleteDatabase(name);
8
+ request.onsuccess = () => resolve();
9
+ request.onerror = () => reject(request.error ?? new Error(`Failed to delete database ${name}`));
10
+ request.onblocked = () => resolve();
11
+ });
12
+ }
13
+ /**
14
+ * Safety-net cleanup: enumerate all owa-drive databases for this appId and
15
+ * delete any whose projectId is not in knownProjectIds. No-ops if
16
+ * indexedDB.databases() is unsupported (e.g. Firefox, older Safari).
17
+ */
18
+ export async function reconcile(appId, knownProjectIds) {
19
+ if (typeof indexedDB === 'undefined' || typeof indexedDB.databases !== 'function') {
20
+ return;
21
+ }
22
+ const prefix = dbPrefix(appId);
23
+ const known = new Set(knownProjectIds);
24
+ const databases = await indexedDB.databases();
25
+ for (const { name } of databases) {
26
+ if (!name || !name.startsWith(prefix)) {
27
+ continue;
28
+ }
29
+ const projectId = name.slice(prefix.length);
30
+ if (!known.has(projectId)) {
31
+ await evictDbHandle(appId, projectId);
32
+ await deleteDatabase(name);
33
+ }
34
+ }
35
+ }
36
+ /**
37
+ * Primary cleanup path: eagerly delete one specific project's database.
38
+ * Apps should call this directly when a project is removed rather than
39
+ * relying on reconcile() as the only mechanism.
40
+ */
41
+ export async function dropProject(appId, projectId) {
42
+ await evictDbHandle(appId, projectId);
43
+ const name = `${dbPrefix(appId)}${projectId}`;
44
+ await deleteDatabase(name);
45
+ }
@@ -0,0 +1,34 @@
1
+ import type { Logger } from './logger.js';
2
+ export interface ActivateOptions {
3
+ appId: string;
4
+ projectId: string;
5
+ clientId: string;
6
+ /**
7
+ * Resolves the connected account's email from a fresh access token. When
8
+ * supplied, the warm-up's non-interactive refresh goes through
9
+ * `refreshSilently` (connection.ts) so a token GIS silently hands back for
10
+ * the wrong Google account is caught here in the background rather than
11
+ * only being discovered on the next foreground Drive call.
12
+ */
13
+ fetchEmail?: (accessToken: string) => Promise<string>;
14
+ logger?: Logger;
15
+ }
16
+ /**
17
+ * Per-project token warm-up check: if a connection exists AND its cached
18
+ * token is missing or within the refresh buffer of expiring, fires a
19
+ * non-interactive `acquireToken` warm-up in the background. Never starts a
20
+ * new attempt while the document is hidden (an already in-flight attempt
21
+ * from before the tab hid is allowed to run to completion — this function
22
+ * simply never being called with the tab hidden is enough to guarantee that,
23
+ * since it is only invoked from the visibility/pageshow handlers below,
24
+ * which already gate on visibility).
25
+ */
26
+ export declare function warmUpIfNeeded(opts: ActivateOptions): Promise<void>;
27
+ /**
28
+ * Attaches `visibilitychange` and `pageshow` (bfcache-restore) listeners for
29
+ * a single project, and returns a disposer that removes both. No listeners
30
+ * are attached at import time — only when this is called (from
31
+ * index.ts's top-level `activate()`, once per known project — see index.ts
32
+ * for how the project set is tracked).
33
+ */
34
+ export declare function activate(opts: ActivateOptions): () => void;
@@ -0,0 +1,89 @@
1
+ import { getConnection, refreshSilently } from './connection.js';
2
+ import { getToken } from './storage.js';
3
+ import { acquireToken } from './token.js';
4
+ import { REQUIRED_SCOPES } from './files.js';
5
+ const REFRESH_BUFFER_MS = 5 * 60 * 1000;
6
+ /**
7
+ * Per-project token warm-up check: if a connection exists AND its cached
8
+ * token is missing or within the refresh buffer of expiring, fires a
9
+ * non-interactive `acquireToken` warm-up in the background. Never starts a
10
+ * new attempt while the document is hidden (an already in-flight attempt
11
+ * from before the tab hid is allowed to run to completion — this function
12
+ * simply never being called with the tab hidden is enough to guarantee that,
13
+ * since it is only invoked from the visibility/pageshow handlers below,
14
+ * which already gate on visibility).
15
+ */
16
+ export async function warmUpIfNeeded(opts) {
17
+ try {
18
+ const conn = await getConnection({
19
+ appId: opts.appId,
20
+ projectId: opts.projectId,
21
+ requiredScopes: REQUIRED_SCOPES,
22
+ });
23
+ if (!conn) {
24
+ // No connection at all -> no warm-up.
25
+ return;
26
+ }
27
+ const token = await getToken(opts.appId, opts.projectId);
28
+ const isStale = !token || token.expiresAt - Date.now() <= REFRESH_BUFFER_MS;
29
+ if (!isStale) {
30
+ return;
31
+ }
32
+ if (opts.fetchEmail) {
33
+ await refreshSilently({
34
+ appId: opts.appId,
35
+ projectId: opts.projectId,
36
+ clientId: opts.clientId,
37
+ scopes: REQUIRED_SCOPES,
38
+ expectedEmail: conn.email,
39
+ fetchEmail: opts.fetchEmail,
40
+ logger: opts.logger,
41
+ });
42
+ }
43
+ else {
44
+ await acquireToken({
45
+ appId: opts.appId,
46
+ projectId: opts.projectId,
47
+ clientId: opts.clientId,
48
+ scopes: REQUIRED_SCOPES,
49
+ interactive: false,
50
+ hint: conn.email,
51
+ logger: opts.logger,
52
+ });
53
+ }
54
+ }
55
+ catch (err) {
56
+ opts.logger?.warn('drive-sync: background token warm-up failed', { err });
57
+ }
58
+ }
59
+ /**
60
+ * Attaches `visibilitychange` and `pageshow` (bfcache-restore) listeners for
61
+ * a single project, and returns a disposer that removes both. No listeners
62
+ * are attached at import time — only when this is called (from
63
+ * index.ts's top-level `activate()`, once per known project — see index.ts
64
+ * for how the project set is tracked).
65
+ */
66
+ export function activate(opts) {
67
+ if (typeof document === 'undefined') {
68
+ return () => { };
69
+ }
70
+ const onVisibilityChange = () => {
71
+ if (document.visibilityState !== 'visible')
72
+ return;
73
+ void warmUpIfNeeded(opts);
74
+ };
75
+ const onPageShow = (event) => {
76
+ const persisted = event.persisted;
77
+ if (!persisted)
78
+ return;
79
+ if (document.hidden)
80
+ return;
81
+ void warmUpIfNeeded(opts);
82
+ };
83
+ document.addEventListener('visibilitychange', onVisibilityChange);
84
+ window.addEventListener('pageshow', onPageShow);
85
+ return () => {
86
+ document.removeEventListener('visibilitychange', onVisibilityChange);
87
+ window.removeEventListener('pageshow', onPageShow);
88
+ };
89
+ }
@@ -0,0 +1,27 @@
1
+ import { type IDBPDatabase, type DBSchema } from 'idb';
2
+ import type { StoredToken } from './types.js';
3
+ /** Durable connection record persisted under the 'conn' key of the auth store. */
4
+ export interface ConnRecord {
5
+ email: string;
6
+ grantedScopes: string[];
7
+ connectedAt: number;
8
+ }
9
+ interface AuthDbSchema extends DBSchema {
10
+ auth: {
11
+ key: string;
12
+ value: ConnRecord | StoredToken;
13
+ };
14
+ }
15
+ export declare function openAuthDb(appId: string, projectId: string): Promise<IDBPDatabase<AuthDbSchema>>;
16
+ /**
17
+ * Evict a cached DB handle from the in-memory cache (and close it if open).
18
+ * Does not delete the underlying IndexedDB database — see reconcile.ts for that.
19
+ */
20
+ export declare function evictDbHandle(appId: string, projectId: string): Promise<void>;
21
+ export declare function getConn(appId: string, projectId: string): Promise<ConnRecord | undefined>;
22
+ export declare function setConn(appId: string, projectId: string, conn: ConnRecord): Promise<void>;
23
+ export declare function clearConn(appId: string, projectId: string): Promise<void>;
24
+ export declare function getToken(appId: string, projectId: string): Promise<StoredToken | undefined>;
25
+ export declare function setToken(appId: string, projectId: string, token: StoredToken): Promise<void>;
26
+ export declare function clearToken(appId: string, projectId: string): Promise<void>;
27
+ export {};
@@ -0,0 +1,70 @@
1
+ import { openDB } from 'idb';
2
+ const AUTH_STORE = 'auth';
3
+ const CONN_KEY = 'conn';
4
+ const TOKEN_KEY = 'token';
5
+ function dbName(appId, projectId) {
6
+ return `owa-drive-${appId}-${projectId}`;
7
+ }
8
+ function cacheKey(appId, projectId) {
9
+ return `${appId}:${projectId}`;
10
+ }
11
+ const dbCache = new Map();
12
+ export function openAuthDb(appId, projectId) {
13
+ const key = cacheKey(appId, projectId);
14
+ let handle = dbCache.get(key);
15
+ if (!handle) {
16
+ handle = openDB(dbName(appId, projectId), 1, {
17
+ upgrade(db) {
18
+ if (!db.objectStoreNames.contains(AUTH_STORE)) {
19
+ db.createObjectStore(AUTH_STORE);
20
+ }
21
+ },
22
+ });
23
+ dbCache.set(key, handle);
24
+ }
25
+ return handle;
26
+ }
27
+ /**
28
+ * Evict a cached DB handle from the in-memory cache (and close it if open).
29
+ * Does not delete the underlying IndexedDB database — see reconcile.ts for that.
30
+ */
31
+ export async function evictDbHandle(appId, projectId) {
32
+ const key = cacheKey(appId, projectId);
33
+ const handle = dbCache.get(key);
34
+ dbCache.delete(key);
35
+ if (handle) {
36
+ try {
37
+ const db = await handle;
38
+ db.close();
39
+ }
40
+ catch {
41
+ // Ignore errors closing an already-broken handle.
42
+ }
43
+ }
44
+ }
45
+ export async function getConn(appId, projectId) {
46
+ const db = await openAuthDb(appId, projectId);
47
+ const value = await db.get(AUTH_STORE, CONN_KEY);
48
+ return value;
49
+ }
50
+ export async function setConn(appId, projectId, conn) {
51
+ const db = await openAuthDb(appId, projectId);
52
+ await db.put(AUTH_STORE, conn, CONN_KEY);
53
+ }
54
+ export async function clearConn(appId, projectId) {
55
+ const db = await openAuthDb(appId, projectId);
56
+ await db.delete(AUTH_STORE, CONN_KEY);
57
+ }
58
+ export async function getToken(appId, projectId) {
59
+ const db = await openAuthDb(appId, projectId);
60
+ const value = await db.get(AUTH_STORE, TOKEN_KEY);
61
+ return value;
62
+ }
63
+ export async function setToken(appId, projectId, token) {
64
+ const db = await openAuthDb(appId, projectId);
65
+ await db.put(AUTH_STORE, token, TOKEN_KEY);
66
+ }
67
+ export async function clearToken(appId, projectId) {
68
+ const db = await openAuthDb(appId, projectId);
69
+ await db.delete(AUTH_STORE, TOKEN_KEY);
70
+ }