@tulip-systems/drive 0.7.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.
Files changed (70) hide show
  1. package/LICENSE +662 -0
  2. package/package.json +113 -0
  3. package/src/components/content.tsx +13 -0
  4. package/src/components/context.client.tsx +12 -0
  5. package/src/components/dnd.client.tsx +47 -0
  6. package/src/components/grid-card.client.tsx +252 -0
  7. package/src/components/grid.client.tsx +96 -0
  8. package/src/components/navigation/breadcrumbs.client.tsx +125 -0
  9. package/src/components/navigation/header.client.tsx +45 -0
  10. package/src/components/navigation/toolbar.client.tsx +35 -0
  11. package/src/components/navigation/view-switcher.client.tsx +32 -0
  12. package/src/components/selection.client.tsx +48 -0
  13. package/src/components/view.client.tsx +67 -0
  14. package/src/config/filters.ts +14 -0
  15. package/src/config/types.tsx +90 -0
  16. package/src/entry.client.ts +7 -0
  17. package/src/entry.server.ts +4 -0
  18. package/src/entry.ts +10 -0
  19. package/src/lib/constants.ts +19 -0
  20. package/src/lib/contracts.ts +121 -0
  21. package/src/lib/dto.ts +83 -0
  22. package/src/lib/helpers.server.ts +14 -0
  23. package/src/lib/helpers.ts +32 -0
  24. package/src/lib/search-params.ts +5 -0
  25. package/src/lib/validators.ts +89 -0
  26. package/src/providers/google/components/command-file-update.tsx +100 -0
  27. package/src/providers/google/components/command-folder-create.tsx +104 -0
  28. package/src/providers/google/components/command-folder-update.tsx +100 -0
  29. package/src/providers/google/components/content.client.tsx +6 -0
  30. package/src/providers/google/components/navigation.client.tsx +21 -0
  31. package/src/providers/google/components/provider.client.tsx +60 -0
  32. package/src/providers/google/components/view.client.tsx +158 -0
  33. package/src/providers/google/config/columns-data.tsx +81 -0
  34. package/src/providers/google/config/filters.ts +3 -0
  35. package/src/providers/google/entry.client.ts +10 -0
  36. package/src/providers/google/entry.server.ts +5 -0
  37. package/src/providers/google/entry.ts +12 -0
  38. package/src/providers/google/lib/constants.ts +10 -0
  39. package/src/providers/google/lib/dto.ts +104 -0
  40. package/src/providers/google/lib/helpers.ts +37 -0
  41. package/src/providers/google/lib/router.server.ts +62 -0
  42. package/src/providers/google/lib/schema.ts +9 -0
  43. package/src/providers/google/lib/search-params.ts +7 -0
  44. package/src/providers/google/lib/service.server.ts +792 -0
  45. package/src/providers/google/lib/validators.ts +148 -0
  46. package/src/providers/local/components/command-file-update.tsx +93 -0
  47. package/src/providers/local/components/command-file-upload.tsx +29 -0
  48. package/src/providers/local/components/command-folder-create.tsx +100 -0
  49. package/src/providers/local/components/command-folder-update.tsx +93 -0
  50. package/src/providers/local/components/content.client.tsx +3 -0
  51. package/src/providers/local/components/navigation.client.tsx +23 -0
  52. package/src/providers/local/components/provider.client.tsx +90 -0
  53. package/src/providers/local/components/upload-zone-context.client.tsx +43 -0
  54. package/src/providers/local/components/upload-zone.client.tsx +182 -0
  55. package/src/providers/local/components/view.client.tsx +145 -0
  56. package/src/providers/local/config/columns-data.tsx +81 -0
  57. package/src/providers/local/config/filters.ts +14 -0
  58. package/src/providers/local/entry.client.ts +18 -0
  59. package/src/providers/local/entry.server.ts +7 -0
  60. package/src/providers/local/entry.ts +14 -0
  61. package/src/providers/local/lib/constants.ts +23 -0
  62. package/src/providers/local/lib/helpers.ts +105 -0
  63. package/src/providers/local/lib/route-handler.server.ts +153 -0
  64. package/src/providers/local/lib/router.server.ts +137 -0
  65. package/src/providers/local/lib/schema.ts +104 -0
  66. package/src/providers/local/lib/search-params.ts +4 -0
  67. package/src/providers/local/lib/service.server.ts +1116 -0
  68. package/src/providers/local/lib/upload.client.ts +177 -0
  69. package/src/providers/local/lib/validators.ts +154 -0
  70. package/src/styles.css +1 -0
@@ -0,0 +1,792 @@
1
+ import type { TDatabaseSchema } from "@tulip-systems/core/config";
2
+ import type { TableQuerySchema } from "@tulip-systems/core/data-tables";
3
+ import type { TableQueryResponse } from "@tulip-systems/core/data-tables/server";
4
+ import type { Database } from "@tulip-systems/core/database/server";
5
+ import { ServerError } from "@tulip-systems/core/router/server";
6
+ import type { ObjectBodyInput } from "@tulip-systems/core/storage";
7
+ import { type drive_v3, google } from "googleapis";
8
+ import type {
9
+ DriveArchive,
10
+ DriveDirectUpload,
11
+ DriveNodeMutations,
12
+ DriveReader,
13
+ } from "@/lib/contracts";
14
+ import { GOOGLE_DRIVE_FOLDER_MIME_TYPE, GOOGLE_DRIVE_NODE_FIELDS } from "./constants";
15
+ import { GoogleDriveNodeDTO } from "./dto";
16
+ import { escapeGoogleDriveQueryValue, isGoogleNotFound, toGoogleDriveReadable } from "./helpers";
17
+ import type {
18
+ CreateGoogleDriveFolderInput,
19
+ GetGoogleDriveNodesByParentIdSchema,
20
+ GoogleDriveNode,
21
+ GoogleDriveNodeChild,
22
+ GoogleDriveNodeWithChildren,
23
+ GoogleDriveTableFilters,
24
+ ListGoogleDriveTreeSchema,
25
+ UpdateGoogleDriveNodeInput,
26
+ UploadGoogleDriveFileInput,
27
+ } from "./validators";
28
+
29
+ const DEFAULT_PAGE_SIZE = 10;
30
+ const CHILDREN_PAGE_SIZE = 1000;
31
+
32
+ /**
33
+ * Drive Service Config
34
+ */
35
+ export type GoogleDriveConfig<TSchema extends TDatabaseSchema> = Pick<drive_v3.Options, "auth"> & {
36
+ db: Database<TSchema>;
37
+ };
38
+
39
+ /**
40
+ * Google Drive provider.
41
+ *
42
+ * A single service instance can operate on multiple Google shared drives. The
43
+ * shared Drive `namespace` is the Google `driveId`, so namespace is supplied by
44
+ * method inputs rather than by the constructor.
45
+ */
46
+ export class GoogleDrive<TSchema extends TDatabaseSchema>
47
+ implements
48
+ DriveReader<GoogleDriveNode, GoogleDriveNodeWithChildren, GoogleDriveNodeChild>,
49
+ DriveNodeMutations<GoogleDriveNode>,
50
+ DriveDirectUpload<GoogleDriveNode, ObjectBodyInput>,
51
+ DriveArchive<GoogleDriveNode>
52
+ {
53
+ readonly client: drive_v3.Drive;
54
+
55
+ constructor(config: GoogleDriveConfig<TSchema>) {
56
+ this.client = google.drive({ auth: config.auth, version: "v3" });
57
+ }
58
+
59
+ /**
60
+ * Fetches raw Google Drive metadata for a file or folder.
61
+ *
62
+ * Google Drive uses the same `files.get` endpoint for files and folders. We
63
+ * request only `GOOGLE_DRIVE_NODE_FIELDS` so DTO mapping receives a predictable
64
+ * shape and we avoid over-fetching provider metadata.
65
+ *
66
+ * A Google 404 is translated to `null` to match the public `getNodeById`
67
+ * contract. All other errors are rethrown because they represent real failures
68
+ * such as insufficient permissions, invalid credentials, quota limits, or API
69
+ * outages.
70
+ */
71
+ async #getFile(id: string): Promise<drive_v3.Schema$File | null> {
72
+ try {
73
+ const result = await this.client.files.get({
74
+ fileId: id,
75
+ supportsAllDrives: true,
76
+ fields: GOOGLE_DRIVE_NODE_FIELDS,
77
+ });
78
+
79
+ return result.data;
80
+ } catch (error) {
81
+ if (isGoogleNotFound(error)) return null;
82
+ throw error;
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Loads a single Google Drive file/folder by its Google file id.
88
+ *
89
+ * Returns `null` when Google reports the file does not exist or is not visible to
90
+ * the authenticated account. Other Google API errors are allowed to bubble up so
91
+ * callers do not accidentally treat permission, quota, or transport failures as
92
+ * missing data.
93
+ *
94
+ * The returned Google metadata is normalized into the shared Drive node shape by
95
+ * `#toNode`, which also validates that Google returned the minimum metadata we
96
+ * need to identify the node and its namespace.
97
+ */
98
+ async getNodeById(id: string): Promise<GoogleDriveNode | null> {
99
+ const file = await this.#getFile(id);
100
+ return file ? this.#toNode(file) : null;
101
+ }
102
+
103
+ /**
104
+ * Converts a table sort field into a Google Drive `orderBy` value.
105
+ *
106
+ * Google Drive only accepts a fixed set of field names in `files.list.orderBy`.
107
+ * The table layer uses provider-neutral names such as `createdAt` and
108
+ * `updatedAt`, so this method maps those aliases to Google's native field names.
109
+ *
110
+ * Unknown sort fields intentionally fall back to `modifiedTime desc` instead of
111
+ * being passed through, because Google returns a 400 for unsupported order fields.
112
+ */
113
+ #orderBy(input: Pick<TableQuerySchema, "sort" | "order">): string {
114
+ const direction = input.order === "asc" ? "" : " desc";
115
+
116
+ switch (input.sort) {
117
+ case "name":
118
+ return `name${direction}`;
119
+ case "createdAt":
120
+ case "createdTime":
121
+ return `createdTime${direction}`;
122
+ case "updatedAt":
123
+ case "modifiedTime":
124
+ return `modifiedTime${direction}`;
125
+ default:
126
+ return "modifiedTime desc";
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Scopes results to a parent folder.
132
+ *
133
+ * Google Drive represents folder membership as `'<folderId>' in parents`.
134
+ * A `null` parent maps to the provider namespace/root drive id.
135
+ * An `undefined` parent means no parent filter should be applied.
136
+ */
137
+ #parentQuery(input: Pick<GoogleDriveTableFilters, "namespace" | "parentId">): string | null {
138
+ if (input.parentId === undefined) return null;
139
+
140
+ const parentId = input.parentId ?? input.namespace;
141
+ return `'${escapeGoogleDriveQueryValue(parentId)}' in parents`;
142
+ }
143
+
144
+ /**
145
+ * Filters results to a specific set of Google file ids.
146
+ */
147
+ #idQuery(nodeIds?: string[] | null): string | null {
148
+ if (!nodeIds?.length) return null;
149
+ return `(${nodeIds.map((id) => `id = '${escapeGoogleDriveQueryValue(id)}'`).join(" or ")})`;
150
+ }
151
+
152
+ /**
153
+ * Converts provider-neutral node types to Google Drive MIME-type filters.
154
+ *
155
+ * Google Drive folders are identified by their folder MIME type. Every other
156
+ * MIME type is treated as a file.
157
+ */
158
+ #typeQuery(types?: Array<"file" | "folder"> | null): string | null {
159
+ if (types?.length !== 1) return null;
160
+
161
+ return types[0] === "folder"
162
+ ? `mimeType = '${GOOGLE_DRIVE_FOLDER_MIME_TYPE}'`
163
+ : `mimeType != '${GOOGLE_DRIVE_FOLDER_MIME_TYPE}'`;
164
+ }
165
+
166
+ /**
167
+ * Filters files by MIME type.
168
+ *
169
+ * This only applies to files. Folder filtering should use `#typeQuery`.
170
+ */
171
+ #contentTypeQuery(contentTypes?: string[] | null): string | null {
172
+ if (!contentTypes?.length) return null;
173
+
174
+ return `(${contentTypes
175
+ .map((contentType) => `mimeType = '${escapeGoogleDriveQueryValue(contentType)}'`)
176
+ .join(" or ")})`;
177
+ }
178
+
179
+ /**
180
+ * Filters archived/active nodes.
181
+ *
182
+ * Google Drive calls this state `trashed`; the shared Drive contract calls it
183
+ * archived/soft-deleted. By default we return active nodes only.
184
+ */
185
+ #archiveQuery(isArchived?: boolean | null): string | null {
186
+ return `trashed = ${isArchived === true ? "true" : "false"}`;
187
+ }
188
+
189
+ /**
190
+ * Applies a name search.
191
+ *
192
+ * Google Drive supports `name contains '<value>'`, which is a substring match.
193
+ */
194
+ #searchQuery(search?: string | null): string | null {
195
+ if (!search) return null;
196
+
197
+ return `name contains '${escapeGoogleDriveQueryValue(search)}'`;
198
+ }
199
+
200
+ #buildQuery(input: GoogleDriveTableFilters & Pick<TableQuerySchema, "search">): string {
201
+ return [
202
+ this.#parentQuery(input),
203
+ this.#idQuery(input.nodeIds),
204
+ this.#typeQuery(input.types),
205
+ this.#contentTypeQuery(input.contentTypes),
206
+ this.#archiveQuery(input.isArchived),
207
+ this.#searchQuery(input.search),
208
+ ]
209
+ .filter((clause): clause is string => typeof clause === "string" && clause.length > 0)
210
+ .join(" and ");
211
+ }
212
+
213
+ /**
214
+ * Lists the direct children of a Google Drive folder.
215
+ *
216
+ * Google Drive stores both files and folders behind the `files.list` endpoint.
217
+ * This helper translates our drive query input into a Google Drive search query
218
+ * and returns only the immediate children for the requested parent.
219
+ *
220
+ * The result is paginated by Google, so we keep requesting pages until all
221
+ * matching children are loaded. Recursive traversal is intentionally not handled
222
+ * here; callers that need full subtrees should use `getNodeChildren`.
223
+ */
224
+ async #listChildren(input: GetGoogleDriveNodesByParentIdSchema) {
225
+ const filters = input?.filters;
226
+ const files: drive_v3.Schema$File[] = [];
227
+ let pageToken: string | undefined;
228
+
229
+ do {
230
+ const result = await this.client.files.list({
231
+ pageToken,
232
+ pageSize: CHILDREN_PAGE_SIZE,
233
+ corpora: "drive",
234
+ driveId: input.filters.namespace,
235
+ includeItemsFromAllDrives: true,
236
+ supportsAllDrives: true,
237
+ q: this.#buildQuery({
238
+ namespace: input.filters.namespace,
239
+ parentId: input.filters.parentId,
240
+ nodeIds: filters?.nodeIds,
241
+ types: filters?.types,
242
+ contentTypes: filters?.contentTypes,
243
+ isArchived: filters?.isArchived,
244
+ search: input?.search,
245
+ }),
246
+ orderBy: this.#orderBy(input),
247
+ fields: `nextPageToken, files(${GOOGLE_DRIVE_NODE_FIELDS})`,
248
+ });
249
+
250
+ files.push(...(result.data.files ?? []));
251
+ pageToken = result.data.nextPageToken ?? undefined;
252
+ } while (pageToken);
253
+
254
+ return files.map((file) => this.#toNode(file));
255
+ }
256
+
257
+ /**
258
+ * Loads a node and its direct children within a namespace.
259
+ *
260
+ * Google Drive does not enforce our provider-neutral `namespace` at `files.get`
261
+ * time, so the node is fetched by id first and then checked against the requested
262
+ * namespace/shared drive. A missing node or namespace mismatch returns
263
+ * `null`, matching the shared Drive reader contract.
264
+ *
265
+ * Only direct children are loaded. Recursive traversal is handled separately by
266
+ * `getNodeChildren`.
267
+ */
268
+ async getNodeWithChildren(input: {
269
+ id: string;
270
+ namespace: string;
271
+ }): Promise<GoogleDriveNodeWithChildren | null> {
272
+ const node = await this.getNodeById(input.id);
273
+ if (!node || node.namespace !== input.namespace) return null;
274
+
275
+ const children = await this.#listChildren({
276
+ filters: {
277
+ parentId: node.id,
278
+ namespace: node.namespace,
279
+ isArchived: false,
280
+ },
281
+ });
282
+
283
+ return { ...node, children };
284
+ }
285
+
286
+ /**
287
+ * Lists the direct children of a parent folder.
288
+ *
289
+ * This is the Google Drive implementation of the shared Drive reader method. It
290
+ * delegates to `#listChildren`, which translates the provider-neutral filters,
291
+ * search, and sorting options into a Google Drive `files.list` query.
292
+ *
293
+ * Only immediate children are returned. Recursive traversal is handled by
294
+ * `getNodeChildren`.
295
+ */
296
+ async getNodesByParentId(input: GetGoogleDriveNodesByParentIdSchema): Promise<GoogleDriveNode[]> {
297
+ return this.#listChildren(input);
298
+ }
299
+
300
+ /**
301
+ * Resolves the Google Drive page token for a numeric table cursor.
302
+ *
303
+ * The shared table API uses numeric cursors (`0`, `1`, `2`, ...), while Google
304
+ * Drive pagination uses opaque `nextPageToken` values. To bridge that mismatch,
305
+ * we walk Google pages until we reach the requested table page.
306
+ *
307
+ * The intermediate requests only fetch file ids because their content is thrown
308
+ * away; we only need each response's `nextPageToken`.
309
+ *
310
+ * Returns `exhausted: true` when Google runs out of pages before the requested
311
+ * cursor is reached.
312
+ */
313
+ async #pageCursorAt(
314
+ input: {
315
+ namespace: string;
316
+ q: string;
317
+ limit: number;
318
+ cursor?: number;
319
+ orderBy?: string;
320
+ },
321
+ remainingPages: number,
322
+ pageToken?: string,
323
+ ): Promise<{ pageToken?: string; exhausted: boolean }> {
324
+ if (remainingPages === 0) return { pageToken, exhausted: false };
325
+
326
+ const result = await this.client.files.list({
327
+ pageSize: input.limit,
328
+ pageToken,
329
+ corpora: "drive",
330
+ driveId: input.namespace,
331
+ includeItemsFromAllDrives: true,
332
+ supportsAllDrives: true,
333
+ q: input.q,
334
+ orderBy: input.orderBy,
335
+ fields: "nextPageToken, files(id)",
336
+ });
337
+
338
+ const nextPageToken = result.data.nextPageToken ?? undefined;
339
+ if (!nextPageToken) return { exhausted: true };
340
+
341
+ return this.#pageCursorAt(input, remainingPages - 1, nextPageToken);
342
+ }
343
+
344
+ /**
345
+ * Lists Google Drive nodes using the shared table pagination shape.
346
+ *
347
+ * The shared table API uses numeric cursors, but Google Drive uses opaque page
348
+ * tokens. Before fetching the requested page, `#pageCursorAt` advances through
349
+ * Google pages until it resolves the page token for the requested numeric cursor.
350
+ *
351
+ * Google Drive does not return a total row count for `files.list`, so pagination
352
+ * totals are estimated from the current cursor, returned page size, and whether
353
+ * Google reports another page. The estimate is only meant to support table/infinite
354
+ * pagination controls; it should not be treated as an exact item count.
355
+ */
356
+ async #listNodes(input: {
357
+ namespace: string;
358
+ q: string;
359
+ limit?: number;
360
+ cursor?: number;
361
+ orderBy?: string;
362
+ }): Promise<TableQueryResponse<GoogleDriveNode>> {
363
+ const limit = input.limit ?? DEFAULT_PAGE_SIZE;
364
+ const cursor = input.cursor ?? 0;
365
+ const pageCursor = await this.#pageCursorAt({ ...input, limit }, cursor);
366
+
367
+ if (pageCursor.exhausted) {
368
+ return {
369
+ data: [],
370
+ pagination: {
371
+ total: cursor * limit,
372
+ totalPages: Math.max(1, cursor),
373
+ limit,
374
+ hasNextPage: false,
375
+ hasPreviousPage: cursor > 0,
376
+ cursor,
377
+ nextCursor: null,
378
+ previousCursor: cursor > 0 ? cursor - 1 : null,
379
+ },
380
+ };
381
+ }
382
+
383
+ const result = await this.client.files.list({
384
+ pageSize: limit,
385
+ pageToken: pageCursor.pageToken,
386
+ corpora: "drive",
387
+ driveId: input.namespace,
388
+ includeItemsFromAllDrives: true,
389
+ supportsAllDrives: true,
390
+ q: input.q,
391
+ orderBy: input.orderBy,
392
+ fields: `nextPageToken, files(${GOOGLE_DRIVE_NODE_FIELDS})`,
393
+ });
394
+
395
+ const data = result.data.files?.map((file) => this.#toNode(file)) ?? [];
396
+ const hasNextPage = result.data.nextPageToken != null;
397
+
398
+ // Google does not expose an exact total for this query. Estimate enough rows
399
+ // for the current page and one possible next page so pagination remains usable.
400
+ const total = cursor * limit + data.length + (hasNextPage ? limit : 0);
401
+
402
+ return {
403
+ data,
404
+ pagination: {
405
+ total,
406
+ totalPages: Math.max(1, Math.ceil(total / limit)),
407
+ limit,
408
+ hasNextPage,
409
+ hasPreviousPage: cursor > 0,
410
+ cursor,
411
+ nextCursor: hasNextPage ? cursor + 1 : null,
412
+ previousCursor: cursor > 0 ? cursor - 1 : null,
413
+ },
414
+ };
415
+ }
416
+
417
+ /**
418
+ * Lists one page of Google Drive nodes for a tree/folder view.
419
+ *
420
+ * The public input uses the shared table-query shape: pagination, sorting,
421
+ * search, and drive filters. This method translates that input into the internal
422
+ * Google Drive list request shape used by `#listNodes`.
423
+ *
424
+ * Google Drive does not expose a native tree endpoint. This returns only the
425
+ * direct children for the requested `parentId`; recursive traversal is handled
426
+ * by `getNodeChildren`.
427
+ */
428
+ async listTree(input: ListGoogleDriveTreeSchema): Promise<TableQueryResponse<GoogleDriveNode>> {
429
+ return this.#listNodes({
430
+ namespace: input.filters.namespace,
431
+ limit: input.limit,
432
+ cursor: input.cursor,
433
+ orderBy: this.#orderBy(input),
434
+ q: this.#buildQuery({
435
+ namespace: input.filters.namespace,
436
+ parentId: input.filters.parentId,
437
+ nodeIds: input.filters.nodeIds,
438
+ types: input.filters.types,
439
+ contentTypes: input.filters.contentTypes,
440
+ isArchived: input.filters.isArchived,
441
+ search: input.search,
442
+ }),
443
+ });
444
+ }
445
+
446
+ /**
447
+ * Walks from a Google Drive node up through its parent chain.
448
+ *
449
+ * Google Drive only exposes direct parents, so breadcrumbs must be resolved one
450
+ * node at a time. The `seen` set prevents accidental infinite loops if Google
451
+ * ever returns cyclic or inconsistent parent metadata.
452
+ *
453
+ * `seen` is treated immutably so each recursive call receives its own traversal
454
+ * state instead of mutating caller-owned state.
455
+ */
456
+ async #walkParents(
457
+ currentId: string | null,
458
+ namespace: string,
459
+ depth = 0,
460
+ seen = new Set<string>(),
461
+ ): Promise<Array<Pick<GoogleDriveNode, "id" | "name" | "parentId"> & { depth: number }>> {
462
+ if (!currentId || seen.has(currentId)) return [];
463
+
464
+ const node = await this.getNodeById(currentId);
465
+ if (!node || node.namespace !== namespace) return [];
466
+
467
+ const nextSeen = new Set([...seen, node.id]);
468
+
469
+ return [
470
+ { id: node.id, name: node.name, parentId: node.parentId, depth },
471
+ ...(await this.#walkParents(node.parentId, namespace, depth + 1, nextSeen)),
472
+ ];
473
+ }
474
+
475
+ /**
476
+ * Resolves the breadcrumb parent chain for a Google Drive node.
477
+ *
478
+ * A `null` id represents the namespace root and therefore has no parent chain.
479
+ * The returned list is ordered from the root-most ancestor to the requested
480
+ * node, matching breadcrumb display order.
481
+ */
482
+ async getFolderParents(input: {
483
+ id: string | null;
484
+ namespace: string;
485
+ }): Promise<Array<Pick<GoogleDriveNode, "id" | "name" | "parentId"> & { depth: number }>> {
486
+ if (!input.id) return [];
487
+
488
+ const parents = await this.#walkParents(input.id, input.namespace);
489
+ return parents.toSorted((a, b) => Number(b.depth) - Number(a.depth));
490
+ }
491
+
492
+ /**
493
+ * Walks a Google Drive subtree breadth-first.
494
+ *
495
+ * Google Drive has no recursive children endpoint, so folder descendants must be
496
+ * loaded one folder at a time. The returned rows include the starting nodes and
497
+ * all descendants with their relative depth.
498
+ */
499
+ async #walkChildren(
500
+ queue: { id: string; depth: number }[],
501
+ seen: ReadonlySet<string> = new Set(),
502
+ ): Promise<GoogleDriveNodeChild[]> {
503
+ const [current, ...rest] = queue;
504
+ if (!current) return [];
505
+
506
+ if (seen.has(current.id)) {
507
+ return this.#walkChildren(rest, seen);
508
+ }
509
+
510
+ const node = await this.getNodeById(current.id);
511
+ if (!node) {
512
+ return this.#walkChildren(rest, seen);
513
+ }
514
+
515
+ const nextSeen = new Set([...seen, node.id]);
516
+ const currentChild = {
517
+ id: node.id,
518
+ type: node.type,
519
+ parentId: node.parentId,
520
+ depth: current.depth,
521
+ } satisfies GoogleDriveNodeChild;
522
+
523
+ if (node.type !== "folder") {
524
+ return [currentChild, ...(await this.#walkChildren(rest, nextSeen))];
525
+ }
526
+
527
+ const children = await this.#listChildren({
528
+ filters: {
529
+ namespace: node.namespace,
530
+ parentId: node.id,
531
+ isArchived: false,
532
+ },
533
+ });
534
+
535
+ const nextQueue = [
536
+ ...rest,
537
+ ...children.map((child) => ({
538
+ id: child.id,
539
+ depth: current.depth + 1,
540
+ })),
541
+ ];
542
+
543
+ return [currentChild, ...(await this.#walkChildren(nextQueue, nextSeen))];
544
+ }
545
+
546
+ /**
547
+ * Resolves the subtree for one or more Google Drive nodes.
548
+ */
549
+ async getNodeChildren(ids: string[]): Promise<GoogleDriveNodeChild[]> {
550
+ const uniqueIds = Array.from(new Set(ids));
551
+ return this.#walkChildren(uniqueIds.map((id) => ({ id, depth: 0 })));
552
+ }
553
+
554
+ /**
555
+ * Creates a Google Drive folder in the requested parent.
556
+ *
557
+ * Google Drive represents folders as files with the special folder MIME type.
558
+ * Creating a folder therefore uses the same `files.create` endpoint as file
559
+ * uploads, but without a media body.
560
+ *
561
+ * A `null` or missing `parentId` means "create in the namespace root", where the
562
+ * namespace is the shared drive id/root folder used by this provider.
563
+ */
564
+ async createFolder(input: CreateGoogleDriveFolderInput): Promise<GoogleDriveNode> {
565
+ const result = await this.client.files.create({
566
+ requestBody: {
567
+ name: input.name,
568
+ parents: [input.parentId ?? input.namespace],
569
+ mimeType: GOOGLE_DRIVE_FOLDER_MIME_TYPE,
570
+ },
571
+ supportsAllDrives: true,
572
+ fields: GOOGLE_DRIVE_NODE_FIELDS,
573
+ });
574
+
575
+ return this.#toNode(result.data);
576
+ }
577
+
578
+ /**
579
+ * Uploads a file body to Google Drive and returns the created node.
580
+ *
581
+ * Google Drive creates files through `files.create` with two parts:
582
+ * metadata in `requestBody` and the actual file content in `media.body`.
583
+ *
584
+ * A `null` or missing `parentId` means "upload to the namespace root", where the
585
+ * namespace is the Google shared drive/root id used by this provider.
586
+ *
587
+ * The input body can be a provider-neutral object body type. `toReadable`
588
+ * normalizes it into a Node.js readable stream because the Google API client
589
+ * expects stream-compatible media bodies.
590
+ */
591
+ async uploadFile(
592
+ input: UploadGoogleDriveFileInput & { body: ObjectBodyInput },
593
+ ): Promise<GoogleDriveNode> {
594
+ const result = await this.client.files.create({
595
+ requestBody: {
596
+ name: input.name,
597
+ parents: [input.parentId ?? input.namespace],
598
+ mimeType: input.contentType ?? undefined,
599
+ },
600
+ media: {
601
+ mimeType: input.contentType ?? undefined,
602
+ body: toGoogleDriveReadable(input.body),
603
+ },
604
+ supportsAllDrives: true,
605
+ fields: GOOGLE_DRIVE_NODE_FIELDS,
606
+ });
607
+
608
+ return this.#toNode(result.data);
609
+ }
610
+
611
+ /**
612
+ * Updates mutable Google Drive node metadata.
613
+ *
614
+ * Google Drive uses `files.update` for metadata changes such as renaming and
615
+ * moving a file/folder to or from the trash. The shared Drive API calls this
616
+ * soft-delete state "archived"; Google Drive exposes it as `trashed`, so
617
+ * `data.isArchived` is translated directly to Google's `trashed` field.
618
+ *
619
+ * Parent changes are intentionally not handled here. Moving a node requires
620
+ * Google Drive's `addParents`/`removeParents` API and should go through
621
+ * `moveNode` instead.
622
+ */
623
+ async updateNode(id: string, data: UpdateGoogleDriveNodeInput): Promise<GoogleDriveNode> {
624
+ const result = await this.client.files.update({
625
+ fileId: id,
626
+ requestBody: {
627
+ name: data.name,
628
+ trashed: data.isArchived,
629
+ },
630
+ supportsAllDrives: true,
631
+ fields: GOOGLE_DRIVE_NODE_FIELDS,
632
+ });
633
+
634
+ return this.#toNode(result.data);
635
+ }
636
+
637
+ /**
638
+ * Moves a Google Drive node to another parent folder.
639
+ *
640
+ * Google Drive does not allow parent changes through the normal metadata
641
+ * `requestBody`. Moving requires `addParents` and `removeParents` on
642
+ * `files.update`, so this operation is intentionally separate from `updateNode`.
643
+ *
644
+ * A `null` parent means "move to the namespace root". For this provider, the
645
+ * namespace is the Google shared drive/root id.
646
+ */
647
+ async moveNode(input: { id: string; parentId: string | null }): Promise<GoogleDriveNode> {
648
+ const node = await this.getNodeById(input.id);
649
+ if (!node) {
650
+ throw new ServerError("NOT_FOUND", { message: "Node not found" });
651
+ }
652
+
653
+ if (node.readonly) {
654
+ throw new ServerError("BAD_REQUEST", { message: "Node is readonly and cannot be changed" });
655
+ }
656
+
657
+ if (input.parentId === input.id) {
658
+ throw new ServerError("BAD_REQUEST", { message: "Node cannot be moved into itself" });
659
+ }
660
+
661
+ // Prevent creating cycles by moving a folder into one of its descendants.
662
+ if (input.parentId) {
663
+ const subtree = await this.getNodeChildren([input.id]);
664
+
665
+ if (subtree.some((child) => child.id === input.parentId)) {
666
+ throw new ServerError("BAD_REQUEST", {
667
+ message: "Node cannot be moved into one of its descendants",
668
+ });
669
+ }
670
+ }
671
+
672
+ const result = await this.client.files.update({
673
+ fileId: input.id,
674
+ addParents: input.parentId ?? node.namespace,
675
+ removeParents: node.googleParents.join(",") || undefined,
676
+ supportsAllDrives: true,
677
+ fields: GOOGLE_DRIVE_NODE_FIELDS,
678
+ });
679
+
680
+ return this.#toNode(result.data);
681
+ }
682
+
683
+ /**
684
+ * Updates the Google Drive trash state for multiple nodes.
685
+ *
686
+ * Google Drive exposes archive/restore behavior through the `trashed` metadata
687
+ * field on `files.update`. Each node requires a separate API request, so updates
688
+ * are executed in parallel after duplicate ids are removed.
689
+ */
690
+ async #setTrashed(ids: string[], trashed: boolean): Promise<GoogleDriveNode[]> {
691
+ const uniqueIds = [...new Set(ids)];
692
+
693
+ const results = await Promise.all(
694
+ uniqueIds.map((id) =>
695
+ this.client.files.update({
696
+ fileId: id,
697
+ requestBody: { trashed },
698
+ supportsAllDrives: true,
699
+ fields: GOOGLE_DRIVE_NODE_FIELDS,
700
+ }),
701
+ ),
702
+ );
703
+
704
+ return results.map((result) => this.#toNode(result.data));
705
+ }
706
+
707
+ /**
708
+ * Moves Google Drive nodes to trash.
709
+ *
710
+ * The shared Drive API calls this "archive", while Google Drive calls the same
711
+ * state `trashed`. This is a soft-delete operation; nodes can be restored with
712
+ * `restoreNodes`.
713
+ */
714
+ async archiveNodes(ids: string[]): Promise<GoogleDriveNode[]> {
715
+ return this.#setTrashed(ids, true);
716
+ }
717
+
718
+ /**
719
+ * Restores Google Drive nodes from trash.
720
+ *
721
+ * This reverses `archiveNodes` by setting Google Drive's `trashed` flag back to
722
+ * `false`.
723
+ */
724
+ async restoreNodes(ids: string[]): Promise<GoogleDriveNode[]> {
725
+ return this.#setTrashed(ids, false);
726
+ }
727
+
728
+ /**
729
+ * Permanently deletes a Google Drive node.
730
+ *
731
+ * This uses Google Drive `files.delete`, which removes the file/folder rather
732
+ * than moving it to trash. Soft deletion/trashing should go through the archive
733
+ * method instead.
734
+ */
735
+ async deleteNode(id: string): Promise<void> {
736
+ await this.client.files.delete({
737
+ fileId: id,
738
+ supportsAllDrives: true,
739
+ });
740
+ }
741
+
742
+ /**
743
+ * Permanently deletes multiple Google Drive nodes.
744
+ *
745
+ * Duplicate ids are removed before issuing delete requests so the same Google
746
+ * file is not deleted more than once. Deletions are executed in parallel because
747
+ * Google Drive exposes deletion as one request per file id.
748
+ *
749
+ * This is a hard delete. Soft deletion/trashing should go through the archive
750
+ * method instead.
751
+ */
752
+ async deleteNodes(ids: string[]): Promise<void> {
753
+ const uniqueIds = [...new Set(ids)];
754
+
755
+ await Promise.all(
756
+ uniqueIds.map((id) =>
757
+ this.client.files.delete({
758
+ fileId: id,
759
+ supportsAllDrives: true,
760
+ }),
761
+ ),
762
+ );
763
+ }
764
+
765
+ /**
766
+ * Converts raw Google Drive file metadata into the provider's normalized node DTO.
767
+ *
768
+ * Google Drive API responses are loosely typed and may omit fields that our
769
+ * drive abstraction requires, such as `id` or a namespace/root identifier. Those
770
+ * cases indicate an invalid provider response rather than a caller error, so
771
+ * they are normalized to an internal server error.
772
+ *
773
+ * Any unexpected mapping error is rethrown so it can be handled by the normal
774
+ * server error boundary/logging path.
775
+ */
776
+ #toNode(file: drive_v3.Schema$File): GoogleDriveNode {
777
+ try {
778
+ return GoogleDriveNodeDTO.create({ file });
779
+ } catch (error) {
780
+ if (
781
+ error instanceof Error &&
782
+ ["Google Drive file has no id", "Google Drive file has no namespace"].includes(
783
+ error.message,
784
+ )
785
+ ) {
786
+ throw new ServerError("INTERNAL_SERVER_ERROR", { message: error.message });
787
+ }
788
+
789
+ throw error;
790
+ }
791
+ }
792
+ }