@tulip-systems/drive-local 0.11.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.
Files changed (61) hide show
  1. package/LICENSE +662 -0
  2. package/dist/client.d.mts +12 -0
  3. package/dist/client.mjs +12 -0
  4. package/dist/database/drizzle/lib/adapter.d.mts +310 -0
  5. package/dist/database/drizzle/lib/adapter.mjs +520 -0
  6. package/dist/database/drizzle/lib/compilers.d.mts +395 -0
  7. package/dist/database/drizzle/lib/compilers.mjs +21 -0
  8. package/dist/database/drizzle/lib/schema.d.mts +61 -0
  9. package/dist/drizzle.d.mts +4 -0
  10. package/dist/drizzle.mjs +3 -0
  11. package/dist/index.d.mts +8 -0
  12. package/dist/index.mjs +8 -0
  13. package/dist/root/components/command-file-update.d.mts +11 -0
  14. package/dist/root/components/command-file-update.mjs +44 -0
  15. package/dist/root/components/command-file-upload.d.mts +5 -0
  16. package/dist/root/components/command-file-upload.mjs +31 -0
  17. package/dist/root/components/command-folder-create.d.mts +11 -0
  18. package/dist/root/components/command-folder-create.mjs +50 -0
  19. package/dist/root/components/command-folder-update.d.mts +11 -0
  20. package/dist/root/components/command-folder-update.mjs +44 -0
  21. package/dist/root/components/content.client.d.mts +5 -0
  22. package/dist/root/components/content.client.mjs +5 -0
  23. package/dist/root/components/navigation.client.d.mts +11 -0
  24. package/dist/root/components/navigation.client.mjs +12 -0
  25. package/dist/root/components/provider.client.d.mts +16 -0
  26. package/dist/root/components/provider.client.mjs +65 -0
  27. package/dist/root/components/upload-zone-context.client.d.mts +36 -0
  28. package/dist/root/components/upload-zone-context.client.mjs +19 -0
  29. package/dist/root/components/upload-zone.client.d.mts +16 -0
  30. package/dist/root/components/upload-zone.client.mjs +147 -0
  31. package/dist/root/components/view.client.d.mts +20 -0
  32. package/dist/root/components/view.client.mjs +86 -0
  33. package/dist/root/config/columns-data.d.mts +6 -0
  34. package/dist/root/config/columns-data.mjs +62 -0
  35. package/dist/root/config/filters.d.mts +404 -0
  36. package/dist/root/config/filters.mjs +122 -0
  37. package/dist/root/config/sorting.d.mts +38 -0
  38. package/dist/root/config/sorting.mjs +26 -0
  39. package/dist/root/lib/constants.d.mts +12 -0
  40. package/dist/root/lib/constants.mjs +27 -0
  41. package/dist/root/lib/database/contracts.d.mts +112 -0
  42. package/dist/root/lib/database/filters.d.mts +28 -0
  43. package/dist/root/lib/database/filters.mjs +73 -0
  44. package/dist/root/lib/helpers.d.mts +33 -0
  45. package/dist/root/lib/helpers.mjs +78 -0
  46. package/dist/root/lib/route-handler.server.d.mts +36 -0
  47. package/dist/root/lib/route-handler.server.mjs +111 -0
  48. package/dist/root/lib/router.server.d.mts +819 -0
  49. package/dist/root/lib/router.server.mjs +87 -0
  50. package/dist/root/lib/search-params.d.mts +15 -0
  51. package/dist/root/lib/search-params.mjs +6 -0
  52. package/dist/root/lib/service.server.d.mts +522 -0
  53. package/dist/root/lib/service.server.mjs +613 -0
  54. package/dist/root/lib/upload.client.d.mts +60 -0
  55. package/dist/root/lib/upload.client.mjs +97 -0
  56. package/dist/root/lib/validators.d.mts +710 -0
  57. package/dist/root/lib/validators.mjs +116 -0
  58. package/dist/server.d.mts +5 -0
  59. package/dist/server.mjs +4 -0
  60. package/package.json +83 -0
  61. package/styles.css +1 -0
@@ -0,0 +1,613 @@
1
+ import { deviceSizes } from "./constants.mjs";
2
+ import { inferLocalDriveNodeSubtype, isLocalDriveFile, isLocalDriveFolder, toLocalDriveNode } from "./helpers.mjs";
3
+ import { getLocalDriveNodesByParentIdInputSchema, getLocalFileURLSchema, getLocalFileURLSchemaDefaults } from "./validators.mjs";
4
+ import { offsetPaginationResponse, parseOffsetPagination } from "@tulip-systems/query";
5
+ import { ORPCError } from "@orpc/server";
6
+ import { after } from "next/server";
7
+ import stream from "node:stream/consumers";
8
+ import { addSeconds } from "date-fns";
9
+ //#region src/root/lib/service.server.ts
10
+ /**
11
+ * Drive Service
12
+ */
13
+ var LocalDrive = class {
14
+ #database;
15
+ storage;
16
+ constructor({ database, storage }) {
17
+ this.#database = database;
18
+ this.storage = storage;
19
+ }
20
+ /**
21
+ * Loads and validates the parent folder for write operations.
22
+ *
23
+ * The returned parent is guaranteed to exist, be a folder, belong to the
24
+ * same namespace, and not be archived. Root-level operations return `null`.
25
+ *
26
+ * @param input - Parent identifier and target namespace.
27
+ * @returns The validated parent node or `null` for root operations.
28
+ */
29
+ async #getWritableParent(input) {
30
+ if (!input.parentId) return null;
31
+ const parent = await this.#database.findNodeById(input.parentId);
32
+ if (!parent) throw new ORPCError("BAD_REQUEST", { message: "Parent not found" });
33
+ if (!isLocalDriveFolder(parent)) throw new ORPCError("BAD_REQUEST", { message: "Parent is not a folder" });
34
+ if (parent.namespace !== input.namespace) throw new ORPCError("BAD_REQUEST", { message: "Parent is not in the same namespace" });
35
+ if (parent.archivedAt) throw new ORPCError("BAD_REQUEST", { message: "Parent is archived" });
36
+ return parent;
37
+ }
38
+ /**
39
+ * Collects the storage asset ids associated with a node subtree.
40
+ *
41
+ * This includes both the main file assets attached to file this.#nodes and any
42
+ * generated variant assets stored in `node_variants`.
43
+ *
44
+ * @param nodeIds - Node ids that belong to the subtree.
45
+ * @param subtree - Lightweight subtree rows returned by `getNodeChildren`.
46
+ * @returns A de-duplicated list of storage asset ids.
47
+ */
48
+ async #getSubtreeAssetIds(nodeIds, subtree) {
49
+ const fileAssetIds = [...new Set(subtree.filter((node) => node.type === "file").map((node) => node.assetId).filter((assetId) => assetId != null))];
50
+ const variantAssetIds = (await this.#database.findVariants({ filters: { nodeIds } })).map((variant) => variant.assetId);
51
+ return [.../* @__PURE__ */ new Set([...fileAssetIds, ...variantAssetIds])];
52
+ }
53
+ /**
54
+ * Retrieves the node row for a given id.
55
+ *
56
+ * This is a thin lookup helper that returns the raw database result for the
57
+ * matching node. It does not load children, variants, or perform namespace
58
+ * validation.
59
+ *
60
+ * @param id - The node identifier.
61
+ * @returns A promise resolving to the matching node row as a single-item array,
62
+ * or an empty array when no node exists.
63
+ */
64
+ async getNodeById(id) {
65
+ const node = await this.#database.findNodeById(id);
66
+ return node ? toLocalDriveNode(node) : null;
67
+ }
68
+ /**
69
+ * Retrieves a node together with its direct children for a namespace.
70
+ *
71
+ * This is used by the drive UI when opening a node detail view that expects
72
+ * the immediate child this.#nodes in the same payload.
73
+ *
74
+ * @param input - The target node id and namespace.
75
+ * @returns The node with its children when found, otherwise `null`.
76
+ */
77
+ async getNodeWithChildren(input) {
78
+ const node = await this.getNodeById(input.id);
79
+ if (!node || node.namespace !== input.namespace) return null;
80
+ const children = await this.getNodesByParentId({
81
+ filters: {
82
+ namespace: input.namespace,
83
+ parentId: node.id,
84
+ hidden: false,
85
+ isArchived: false
86
+ },
87
+ sorts: []
88
+ });
89
+ return {
90
+ ...node,
91
+ children
92
+ };
93
+ }
94
+ /**
95
+ * Lists this.#nodes within a folder scope using the current table-query filters.
96
+ *
97
+ * Behavior:
98
+ * - Scopes results to a namespace
99
+ * - Resolves either root this.#nodes (`parentId = null`) or a specific parent folder
100
+ * - Applies optional filters such as ids, types, hidden state, and archive state
101
+ * - Applies search and sorting through the shared table-query utilities
102
+ *
103
+ * @param input - Table query input containing drive filters, search, and sorting.
104
+ * @returns A promise resolving to the matching child this.#nodes.
105
+ */
106
+ async getNodesByParentId(input) {
107
+ const query = getLocalDriveNodesByParentIdInputSchema.parse(input);
108
+ return (await this.#database.findNodes({
109
+ filters: query.filters,
110
+ sorts: query.sorts
111
+ })).map(toLocalDriveNode);
112
+ }
113
+ /**
114
+ * Lists tree-scoped this.#nodes with pagination, search, and richer local filters.
115
+ *
116
+ * This query is intended for admin table views where additional local fields
117
+ * such as subtype and contentType are part of the filtering contract.
118
+ *
119
+ * @param input - Table query input with tree filters.
120
+ * @returns A paginated table-query response.
121
+ */
122
+ async listTree({ filters, ...query }) {
123
+ const pagination = parseOffsetPagination(query.pagination);
124
+ const result = await this.#database.listNodesWithAsset({
125
+ filters,
126
+ sorts: query.sorts,
127
+ limit: pagination.limit,
128
+ offset: pagination.offset
129
+ });
130
+ return offsetPaginationResponse({
131
+ data: result.data.map(({ asset, ...node }) => ({
132
+ ...toLocalDriveNode(node),
133
+ asset
134
+ })),
135
+ pagination: query.pagination,
136
+ total: result.total
137
+ });
138
+ }
139
+ /**
140
+ * Lists flat this.#nodes with pagination, search, and richer local filters.
141
+ *
142
+ * Unlike `listTree`, this query ignores parent scoping and is intended for
143
+ * flat file-manager style views.
144
+ *
145
+ * @param input - Table query input with flat filters.
146
+ * @returns A paginated table-query response.
147
+ */
148
+ async listFlat({ filters, ...query }) {
149
+ const pagination = parseOffsetPagination(query.pagination);
150
+ const result = await this.#database.listNodesWithAsset({
151
+ filters,
152
+ sorts: query.sorts,
153
+ limit: pagination.limit,
154
+ offset: pagination.offset
155
+ });
156
+ return offsetPaginationResponse({
157
+ data: result.data.map(({ asset, ...node }) => ({
158
+ ...toLocalDriveNode(node),
159
+ asset
160
+ })),
161
+ pagination: query.pagination,
162
+ total: result.total
163
+ });
164
+ }
165
+ /**
166
+ * Resolves the ancestor chain for a folder or file node.
167
+ *
168
+ * The returned list starts with the root-most ancestor and ends with the
169
+ * requested node. This is primarily used for breadcrumb navigation.
170
+ *
171
+ * @param input - The node id and namespace to resolve.
172
+ * @returns A promise resolving to the ordered ancestor chain.
173
+ */
174
+ async getFolderParents(input) {
175
+ return (await this.#database.getNodeParents(input)).map(({ depth, ...parent }) => ({
176
+ ...parent,
177
+ depth: Number(depth)
178
+ })).toSorted((a, b) => b.depth - a.depth);
179
+ }
180
+ /**
181
+ * Retrieves the structural subtree for one or more root node ids.
182
+ *
183
+ * The result includes the provided start this.#nodes and all descendant this.#nodes,
184
+ * returned as a lightweight shape containing only the fields needed for
185
+ * recursive drive operations such as delete, archive, and validation.
186
+ *
187
+ * Depth starts at `0` for the provided root this.#nodes and increases for each
188
+ * descendant level. Duplicate paths are collapsed by keeping the minimum depth.
189
+ *
190
+ * @param ids - Root node ids to traverse from.
191
+ * @returns A promise resolving to the subtree rows for the provided this.#nodes.
192
+ */
193
+ async getNodeChildren(ids) {
194
+ return (await this.#database.getNodeChildren(ids)).map(({ depth, ...node }) => ({
195
+ ...toLocalDriveNode(node),
196
+ depth
197
+ }));
198
+ }
199
+ /**
200
+ * Resolves an access URL for a file node and the requested variant.
201
+ *
202
+ * Behavior:
203
+ * - Rejects folder this.#nodes because only file this.#nodes can resolve a file URL
204
+ * - Reuses a cached URL when an unexpired entry exists for the requested
205
+ * variant and disposition
206
+ * - Resolves the requested preview variant asset when available
207
+ * - Falls back to the main file asset when the requested variant does not exist
208
+ * - Delegates final URL generation to the storage service
209
+ * - Stores the generated URL in the local cache table for future reuse
210
+ *
211
+ * @param node - The file node to resolve.
212
+ * @param options - URL options such as variant and content disposition.
213
+ * @returns A promise resolving to a temporary file URL.
214
+ */
215
+ async getURL(node, options = getLocalFileURLSchemaDefaults) {
216
+ if (!isLocalDriveFile(node)) throw new ORPCError("BAD_REQUEST", { message: "Node is not a file" });
217
+ const parsedOptions = getLocalFileURLSchema.parse({
218
+ ...options,
219
+ variant: options.variant === "original" ? "main" : options.variant
220
+ });
221
+ const presignedUrl = await this.#database.findFirstPresignedUrl({ filters: {
222
+ nodeId: node.id,
223
+ variant: parsedOptions.variant,
224
+ disposition: parsedOptions.disposition
225
+ } });
226
+ if (presignedUrl && presignedUrl.expiresAt > /* @__PURE__ */ new Date()) return presignedUrl.url;
227
+ const expiresIn = 86400;
228
+ const [variantRecord] = parsedOptions.variant === "main" ? [] : await this.#database.findVariants({
229
+ filters: {
230
+ nodeIds: [node.id],
231
+ variants: [parsedOptions.variant]
232
+ },
233
+ limit: 1
234
+ });
235
+ const variant = variantRecord ? parsedOptions.variant : "main";
236
+ const assetId = variantRecord?.assetId ?? node.assetId;
237
+ if (!assetId) throw new ORPCError("NOT_FOUND", { message: "Storage asset not found" });
238
+ console.info(`Generating new signed url for file: ${node.id} with variant: ${variant} and disposition: ${parsedOptions.disposition}`);
239
+ const url = await this.storage.getObjectURL(assetId, {
240
+ disposition: `${parsedOptions.disposition}; filename="${node.name}"`,
241
+ expiresIn
242
+ });
243
+ after(async () => {
244
+ await this.#database.upsertPresignedUrl({
245
+ nodeId: node.id,
246
+ url,
247
+ variant,
248
+ variantId: variantRecord?.id,
249
+ disposition: parsedOptions.disposition,
250
+ expiresAt: addSeconds(/* @__PURE__ */ new Date(), expiresIn)
251
+ });
252
+ });
253
+ return url;
254
+ }
255
+ /**
256
+ * Moves a node to a different parent folder.
257
+ *
258
+ * The target parent is validated before the move is persisted. Moving a node
259
+ * into itself or into one of its descendants is rejected to keep the tree
260
+ * structure valid.
261
+ *
262
+ * @param input - The node id and the target parent id.
263
+ * @returns A promise resolving to the updated node.
264
+ * @throws {ORPCError} If the move is invalid.
265
+ */
266
+ async moveNode(input) {
267
+ const node = await this.#database.findNodeById(input.id);
268
+ if (!node) throw new ORPCError("BAD_REQUEST", { message: "Node not found" });
269
+ if (node.readonly) throw new ORPCError("BAD_REQUEST", { message: "Deze node is alleen leesbaar en kan niet worden gewijzigd" });
270
+ if (input.parentId === node.id) throw new ORPCError("BAD_REQUEST", { message: "Node cannot be moved into itself" });
271
+ if (input.parentId !== void 0) await this.#getWritableParent({
272
+ parentId: input.parentId,
273
+ namespace: node.namespace
274
+ });
275
+ if (input.parentId) {
276
+ if ((await this.getNodeChildren([input.id])).some((child) => child.id === input.parentId)) throw new ORPCError("BAD_REQUEST", { message: "Node cannot be moved into one of its descendants" });
277
+ }
278
+ if (node.parentId === input.parentId) return toLocalDriveNode(node);
279
+ const result = await this.#database.updateNodeById(input.id, { parentId: input.parentId });
280
+ if (!result) throw new ORPCError("INTERNAL_SERVER_ERROR", { message: "Node kon niet worden gewijzigd" });
281
+ return toLocalDriveNode(result);
282
+ }
283
+ /**
284
+ * Uploads a file through the storage service and creates the matching node.
285
+ *
286
+ * Behavior:
287
+ * - Validates the parent folder when `parentId` is provided
288
+ * - Uploads the file bytes through the storage service
289
+ * - Persists the created storage asset id on the node
290
+ * - Cleans up the uploaded asset when node creation fails
291
+ *
292
+ * @param input - File metadata and body.
293
+ * @returns A promise resolving to the created file node.
294
+ * @throws {ORPCError} If validation or persistence fails.
295
+ */
296
+ async uploadFile(input) {
297
+ await this.#getWritableParent({
298
+ parentId: input.parentId,
299
+ namespace: input.namespace
300
+ });
301
+ const asset = await this.storage.upload({
302
+ name: input.name,
303
+ body: input.body,
304
+ contentType: input.contentType,
305
+ size: input.size,
306
+ visibility: "private"
307
+ });
308
+ try {
309
+ const result = await this.#database.createNode({
310
+ type: "file",
311
+ name: input.name,
312
+ namespace: input.namespace,
313
+ parentId: input.parentId,
314
+ size: asset.size ?? input.size,
315
+ contentType: asset.contentType ?? input.contentType,
316
+ hidden: input.hidden ?? false,
317
+ readonly: input.readonly ?? false,
318
+ subtype: inferLocalDriveNodeSubtype(input),
319
+ assetId: asset.id
320
+ });
321
+ if (!result) throw new ORPCError("INTERNAL_SERVER_ERROR", { message: "Oep! Er is iets fout gegaan" });
322
+ return toLocalDriveNode(result);
323
+ } catch (error) {
324
+ await this.storage.purgeAsset(asset.id).catch(() => void 0);
325
+ throw error;
326
+ }
327
+ }
328
+ /**
329
+ * Creates a direct upload intent and the matching file node.
330
+ *
331
+ * Behavior:
332
+ * - Validates the parent folder when `parentId` is provided
333
+ * - Requests a presigned upload from the storage service
334
+ * - Creates the file node linked to the pending storage asset
335
+ * - Purges the pending asset when node creation fails
336
+ *
337
+ * @param input - File metadata used to create the upload intent.
338
+ * @returns The created asset intent, presigned URL, and file node.
339
+ * @throws {ORPCError} If validation or node creation fails.
340
+ */
341
+ async presignUpload(input) {
342
+ await this.#getWritableParent(input);
343
+ const { presignedUrl, ...asset } = await this.storage.presignUpload({
344
+ uploadId: input.uploadId,
345
+ name: input.name,
346
+ contentType: input.contentType,
347
+ size: input.size,
348
+ visibility: input.visibility
349
+ });
350
+ try {
351
+ const node = await this.#database.createNode({
352
+ type: "file",
353
+ name: input.name,
354
+ namespace: input.namespace,
355
+ parentId: input.parentId,
356
+ size: asset.size ?? input.size,
357
+ contentType: asset.contentType ?? input.contentType,
358
+ hidden: input.hidden ?? false,
359
+ readonly: input.readonly ?? false,
360
+ assetId: asset.id,
361
+ subtype: inferLocalDriveNodeSubtype(input)
362
+ });
363
+ if (!node) throw new ORPCError("INTERNAL_SERVER_ERROR", { message: "Oep! Er is iets fout gegaan" });
364
+ return {
365
+ uploadId: asset.uploadId,
366
+ presignedUrl,
367
+ node: toLocalDriveNode(node),
368
+ asset
369
+ };
370
+ } catch (error) {
371
+ await this.storage.purgeAsset(asset.id).catch(() => void 0);
372
+ throw error;
373
+ }
374
+ }
375
+ /**
376
+ * Finalizes a presigned upload and synchronizes the node metadata.
377
+ *
378
+ * Behavior:
379
+ * - Confirms the pending asset through the storage service
380
+ * - Finds the matching node by `assetId`
381
+ * - Synchronizes final asset metadata onto the node
382
+ * - Schedules preview generation for image files
383
+ *
384
+ * @param uploadId - The storage upload id returned by the presign step.
385
+ * @returns A promise resolving to the finalized file node.
386
+ * @throws {ORPCError} If the asset or node cannot be resolved.
387
+ */
388
+ async confirmUpload(uploadId) {
389
+ const asset = await this.storage.confirmUpload(uploadId);
390
+ const node = await this.#database.findFirstNode({ filters: { assetIds: [asset.id] } });
391
+ if (!node) throw new ORPCError("NOT_FOUND", { message: "File not found" });
392
+ const result = await this.#database.updateNodeById(node.id, {
393
+ size: asset.size ?? node.size,
394
+ contentType: asset.contentType ?? node.contentType
395
+ });
396
+ if (!result) throw new ORPCError("NOT_FOUND", { message: "File not found" });
397
+ /**
398
+ * Generate the preview version of the file
399
+ */
400
+ after(async () => {
401
+ await this.generatePreviews({ id: result.id });
402
+ });
403
+ return toLocalDriveNode(result);
404
+ }
405
+ /**
406
+ * Generates preview assets for an image node.
407
+ *
408
+ * The original file is loaded from storage, resized to the configured device
409
+ * widths, uploaded as separate storage assets, and linked through
410
+ * `node_variants`. Existing preview variants are skipped.
411
+ *
412
+ * @param input - The target file node identifier.
413
+ * @returns A promise that resolves once preview generation completes.
414
+ * @throws {ORPCError} If the source node or asset cannot be resolved.
415
+ */
416
+ async generatePreviews(input) {
417
+ const node = await this.#database.findNodeById(input.id);
418
+ if (!node) throw new ORPCError("NOT_FOUND", { message: "Node not found" });
419
+ if (!node.assetId) throw new ORPCError("BAD_REQUEST", { message: "Node has no storage asset" });
420
+ /**
421
+ * Get the main version of the file
422
+ */
423
+ const response = await this.storage.getObject(node.assetId);
424
+ const contentType = response.contentType;
425
+ if (!response.body) throw new ORPCError("INTERNAL_SERVER_ERROR", { message: "Oeps! Er is iets fout gegaan" });
426
+ /**
427
+ * Transform the main version of the file to a buffer
428
+ */
429
+ const buffer = await stream.buffer(response.body);
430
+ /**
431
+ * Generate the preview versions for images
432
+ */
433
+ if (contentType?.startsWith("image/")) {
434
+ const sharp = await import("sharp");
435
+ const sourceAsset = await this.storage.getAssetById(node.assetId);
436
+ if (!sourceAsset) throw new ORPCError("NOT_FOUND", { message: "Storage asset not found" });
437
+ const existingVariantNames = new Set((await this.#database.findVariants({ filters: { nodeIds: [input.id] } })).map((variant) => variant.variant));
438
+ await Promise.allSettled(deviceSizes.flatMap(async (width) => {
439
+ const variant = `preview-${width}`;
440
+ if (existingVariantNames.has(variant)) return;
441
+ const preview = await sharp.default(buffer).resize({ width }).webp().toBuffer();
442
+ const asset = await this.storage.upload({
443
+ body: preview,
444
+ name: `${input.id}-preview-${width}.webp`,
445
+ contentType: "image/webp",
446
+ size: preview.byteLength,
447
+ visibility: sourceAsset.visibility
448
+ });
449
+ try {
450
+ await this.#database.createVariant({
451
+ nodeId: input.id,
452
+ assetId: asset.id,
453
+ variant,
454
+ width
455
+ });
456
+ } catch (error) {
457
+ await this.storage.purgeAsset(asset.id).catch(() => void 0);
458
+ throw error;
459
+ }
460
+ }));
461
+ }
462
+ }
463
+ /**
464
+ * Creates a folder node in the drive tree.
465
+ *
466
+ * The parent folder is validated before the folder is inserted. Folders do
467
+ * not own a storage asset and are stored purely as drive metadata.
468
+ *
469
+ * @param input - Folder metadata.
470
+ * @returns A promise resolving to the created folder node.
471
+ * @throws {ORPCError} If validation or insertion fails.
472
+ */
473
+ async createFolder(input) {
474
+ await this.#getWritableParent({
475
+ parentId: input.parentId,
476
+ namespace: input.namespace
477
+ });
478
+ /**
479
+ * Create the folder
480
+ */
481
+ const result = await this.#database.createNode({
482
+ type: "folder",
483
+ name: input.name,
484
+ namespace: input.namespace,
485
+ parentId: input.parentId,
486
+ hidden: input.hidden ?? false,
487
+ readonly: input.readonly ?? false
488
+ });
489
+ if (!result) throw new ORPCError("INTERNAL_SERVER_ERROR", { message: "Folder kon niet worden aangemaakt" });
490
+ return toLocalDriveNode(result);
491
+ }
492
+ /**
493
+ * Updates mutable node metadata.
494
+ *
495
+ * Supported updates currently include renaming, reparenting, visibility flags,
496
+ * and archive state. Structural updates are validated before being persisted.
497
+ *
498
+ * @param id - The target node id.
499
+ * @param data - Partial node fields to update.
500
+ * @returns A promise resolving to the updated node.
501
+ * @throws {ORPCError} If the node does not exist or the update is invalid.
502
+ */
503
+ async updateNode(id, data) {
504
+ const node = await this.#database.findNodeById(id);
505
+ if (!node) throw new ORPCError("NOT_FOUND", { message: "Node not found" });
506
+ if (data.parentId !== void 0) await this.#getWritableParent({
507
+ parentId: data.parentId,
508
+ namespace: node.namespace
509
+ });
510
+ const result = await this.#database.updateNodeById(id, data);
511
+ if (!result) throw new ORPCError("INTERNAL_SERVER_ERROR", { message: "Node kon niet worden gewijzigd" });
512
+ return toLocalDriveNode(result);
513
+ }
514
+ /**
515
+ * Sets the readonly state for multiple this.#nodes.
516
+ *
517
+ * This method is intended for bulk lock and unlock operations in the drive UI.
518
+ *
519
+ * @param ids - The node ids to update.
520
+ * @param readonly - The readonly state to apply.
521
+ * @returns A promise resolving to the updated this.#nodes.
522
+ * @throws {ORPCError} If no ids are provided or no matching this.#nodes are found.
523
+ */
524
+ async setReadonly(ids, readonly) {
525
+ const nodeIds = [...new Set(ids)];
526
+ if (nodeIds.length === 0) throw new ORPCError("BAD_REQUEST", { message: "No node ids provided" });
527
+ const result = await this.#database.updateNodes({
528
+ filters: { nodeIds },
529
+ data: { readonly }
530
+ });
531
+ if (result.length === 0) throw new ORPCError("BAD_REQUEST", { message: "No matching this.#nodes found" });
532
+ return result.map(toLocalDriveNode);
533
+ }
534
+ /**
535
+ * Archives this.#nodes by setting their archive timestamp.
536
+ *
537
+ * This is a drive-level archive state and does not delete the underlying
538
+ * storage assets.
539
+ *
540
+ * @param ids - The node ids to archive.
541
+ * @returns A promise resolving to the archived this.#nodes.
542
+ * @throws {ORPCError} If no ids are provided or no matching this.#nodes are found.
543
+ */
544
+ async archiveNodes(ids) {
545
+ const nodeIds = [...new Set(ids)];
546
+ if (nodeIds.length === 0) throw new ORPCError("BAD_REQUEST", { message: "No node ids provided" });
547
+ const result = await this.#database.updateNodes({
548
+ filters: { nodeIds },
549
+ data: { archivedAt: /* @__PURE__ */ new Date() }
550
+ });
551
+ if (result.length === 0) throw new ORPCError("BAD_REQUEST", { message: "No matching this.#nodes found" });
552
+ return result.map(toLocalDriveNode);
553
+ }
554
+ /**
555
+ * Restores archived this.#nodes by clearing their archive timestamp.
556
+ *
557
+ * @param ids - The node ids to restore.
558
+ * @returns A promise resolving to the restored this.#nodes.
559
+ * @throws {ORPCError} If no ids are provided or no matching this.#nodes are found.
560
+ */
561
+ async restoreNodes(ids) {
562
+ const nodeIds = [...new Set(ids)];
563
+ if (nodeIds.length === 0) throw new ORPCError("BAD_REQUEST", { message: "No node ids provided" });
564
+ const result = await this.#database.updateNodes({
565
+ filters: { nodeIds },
566
+ data: { archivedAt: null }
567
+ });
568
+ if (result.length === 0) throw new ORPCError("BAD_REQUEST", { message: "No matching this.#nodes found" });
569
+ return result.map(toLocalDriveNode);
570
+ }
571
+ /**
572
+ * Permanently deletes a node subtree and all linked assets.
573
+ *
574
+ * The subtree includes the provided node and all descendants. Associated file
575
+ * assets and generated preview assets are purged from storage after the this.#nodes
576
+ * are removed from the drive catalog.
577
+ *
578
+ * @param id - The root node id to delete.
579
+ * @throws {ORPCError} If the node does not exist.
580
+ */
581
+ async deleteNode(id) {
582
+ const subtree = await this.getNodeChildren([id]);
583
+ if (subtree.length === 0) throw new ORPCError("NOT_FOUND", { message: "Node not found" });
584
+ const nodeIds = [...new Set(subtree.map((node) => node.id))];
585
+ const assetIds = await this.#getSubtreeAssetIds(nodeIds, subtree);
586
+ await this.#database.deleteNodes({ filters: { nodeIds } });
587
+ if (assetIds.length > 0) await this.storage.purgeAssets(assetIds);
588
+ }
589
+ /**
590
+ * Permanently deletes multiple node subtrees and their linked assets.
591
+ *
592
+ * Each provided id is treated as a subtree root. All descendant this.#nodes are
593
+ * removed together with their main file assets and generated preview assets.
594
+ *
595
+ * @param ids - Root node ids to delete.
596
+ * @returns A promise that resolves when deletion completes.
597
+ * @throws {ORPCError} If no ids are provided or no matching this.#nodes are found.
598
+ */
599
+ async deleteNodes(ids) {
600
+ if (ids.length === 0) throw new ORPCError("BAD_REQUEST", { message: "No node ids provided" });
601
+ const subtree = await this.getNodeChildren(ids);
602
+ if (subtree.length === 0) throw new ORPCError("BAD_REQUEST", { message: "No matching this.#nodes found" });
603
+ const nodeIds = [...new Set(subtree.map((node) => node.id))];
604
+ const assetIds = await this.#getSubtreeAssetIds(nodeIds, subtree);
605
+ /**
606
+ * Delete files and folders in a database
607
+ */
608
+ await this.#database.deleteNodes({ filters: { nodeIds } });
609
+ if (assetIds.length > 0) await this.storage.purgeAssets(assetIds);
610
+ }
611
+ };
612
+ //#endregion
613
+ export { LocalDrive };
@@ -0,0 +1,60 @@
1
+ import { LocalDriveFileNode, LocalDriveNode, PresignLocalDriveFileInput, UpdateLocalDriveNodeInput } from "./validators.mjs";
2
+ import { BulkActionSchema } from "@tulip-systems/query";
3
+ import { SelectStorageAssetSchema } from "@tulip-systems/storage";
4
+ //#region src/root/lib/upload.client.d.ts
5
+ /**
6
+ * Upload request
7
+ */
8
+ type PrepareLocalDriveUploadInput = Omit<LocalDriveUploadFileRequest, "name" | "size" | "contentType">;
9
+ type LocalDriveUploadFileRequest = PresignLocalDriveFileInput & {
10
+ file: File;
11
+ };
12
+ type LocalDriveUploadHooks = {
13
+ beforePresign?: (input: LocalDriveUploadFileRequest) => Promise<void> | void;
14
+ afterPresign?: (presignResult: {
15
+ asset: SelectStorageAssetSchema;
16
+ node: LocalDriveFileNode;
17
+ uploadId: string;
18
+ presignedUrl: string;
19
+ }) => Promise<void> | void;
20
+ beforeConfirm?: (presignResult: {
21
+ asset: SelectStorageAssetSchema;
22
+ node: LocalDriveFileNode;
23
+ uploadId: string;
24
+ presignedUrl: string;
25
+ }) => Promise<void> | void;
26
+ afterConfirm?: (node: LocalDriveFileNode) => Promise<void> | void;
27
+ };
28
+ /**
29
+ * Upload client
30
+ */
31
+ type LocalDriveUploadClient = {
32
+ prepareUpload: (input: PrepareLocalDriveUploadInput) => LocalDriveUploadFileRequest;
33
+ upload: (input: LocalDriveUploadFileRequest, hooks?: LocalDriveUploadHooks) => Promise<LocalDriveFileNode>;
34
+ deleteFiles: (ids: string[]) => Promise<void>;
35
+ updateNode: (id: string, data: UpdateLocalDriveNodeInput) => Promise<LocalDriveNode>;
36
+ };
37
+ /**
38
+ * Create upload client
39
+ */
40
+ type CreateLocalDriveUploadClientProps = {
41
+ endpoints: {
42
+ presignUpload: (input: PresignLocalDriveFileInput) => Promise<{
43
+ asset: SelectStorageAssetSchema;
44
+ node: LocalDriveFileNode;
45
+ uploadId: string;
46
+ presignedUrl: string;
47
+ }>;
48
+ confirmUpload: (input: {
49
+ uploadId: string;
50
+ }) => Promise<LocalDriveFileNode>;
51
+ deleteNodes: (input: BulkActionSchema) => Promise<void>;
52
+ updateNode: (input: {
53
+ id: string;
54
+ data: UpdateLocalDriveNodeInput;
55
+ }) => Promise<LocalDriveNode>;
56
+ };
57
+ };
58
+ declare function createLocalDriveUploadClient(props: CreateLocalDriveUploadClientProps): LocalDriveUploadClient;
59
+ //#endregion
60
+ export { LocalDriveUploadClient, LocalDriveUploadFileRequest, LocalDriveUploadHooks, PrepareLocalDriveUploadInput, createLocalDriveUploadClient };