@lobb-js/lobb-ext-storage 0.14.0 → 0.15.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 CHANGED
@@ -18,6 +18,7 @@ export default function extension(utils) {
18
18
  href: "/studio/extensions/storage/file_manager",
19
19
  icon: utils.components.Icons.Folders,
20
20
  label: "File Manager",
21
+ represents: "storage_fs"
21
22
  },
22
23
  ],
23
24
  },
@@ -2,7 +2,7 @@
2
2
  import { FolderX } from "lucide-svelte";
3
3
  </script>
4
4
 
5
- <div class="border rounded-md p-4 bg-muted-soft flex items-center flex-col text-muted-foreground gap-2">
5
+ <div class="border rounded-md p-4 bg-muted flex items-center flex-col text-muted-foreground gap-2">
6
6
  <FolderX size={35} />
7
7
  <div class="flex flex-col justify-center items-center">
8
8
  <div class="text-sm font-bold">Storage file system is not supported here</div>
@@ -189,7 +189,7 @@
189
189
  <div class="flex h-full w-full flex-col bg-background {className}">
190
190
  <!-- file explorer header -->
191
191
  <div
192
- class="flex h-10 items-center justify-between border-b px-2"
192
+ class="flex h-12 items-center justify-between border-b px-2"
193
193
  >
194
194
  <div class="flex items-center justify-between gap-2">
195
195
  {@render topLeftHeader?.()}
@@ -199,14 +199,14 @@
199
199
  <!-- entries view toggle -->
200
200
  <Button
201
201
  variant="outline"
202
- class="h-7 px-3 text-xs font-normal"
202
+ size="sm"
203
203
  Icon={icons.FolderPlus}
204
204
  onclick={handleCreateFolder}
205
205
  >
206
206
  New folder
207
207
  </Button>
208
208
  <Button
209
- class="h-7 px-3 text-xs font-normal"
209
+ size="sm"
210
210
  Icon={icons.FilePlus}
211
211
  onclick={() => { handleFileUpload() }}
212
212
  >
@@ -215,7 +215,7 @@
215
215
  </div>
216
216
  </div>
217
217
  <!-- file explorer body -->
218
- <div class="flex flex-1 overflow-auto h-[calc(100vh-7.5rem)]">
218
+ <div class="flex flex-1 overflow-auto h-[calc(100vh-8rem)]">
219
219
  <ContextMenu.Root bind:open={contextMenuOpen}>
220
220
  <ContextMenu.Trigger
221
221
  class="z-10 flex flex-1 flex-wrap content-start items-start justify-start gap-4 p-4"
@@ -28,7 +28,7 @@
28
28
  }
29
29
  </script>
30
30
 
31
- <button onclick={handleClick} class="flex flex-col items-center gap-2 w-full border p-4 rounded-md bg-muted-soft">
31
+ <button onclick={handleClick} class="flex flex-col items-center gap-2 w-full border p-4 rounded-md bg-muted">
32
32
  {#if value}
33
33
  <FileIcon entry={value} {utils} />
34
34
  {:else}
@@ -16,6 +16,20 @@ export function getFileSystemCollection(config: ExtensionConfig): CollectionConf
16
16
  "length": 255,
17
17
  "required": true,
18
18
  },
19
+ // Source of truth for the hierarchy. null = root entry.
20
+ // The directory itself is referenced; deleting a directory cascades
21
+ // through the storage service (see services.ts) so the FK doesn't need
22
+ // to model ON DELETE — the service handles it.
23
+ "parent_id": {
24
+ "type": "integer",
25
+ "references": {
26
+ "collection": "storage_fs",
27
+ "field": "id",
28
+ },
29
+ },
30
+ // Derived cache, NOT the source of truth. Maintained on every write by
31
+ // the storage service so subtree queries and breadcrumbs stay O(1).
32
+ // Invariant: path === parent.path + parent.name + "/" (or "/" at root).
19
33
  "path": {
20
34
  "type": "string",
21
35
  "length": 255,
@@ -1,3 +1,67 @@
1
- import type { MigrationProps, Migrations } from "@lobb-js/core";
1
+ import type { Migrations } from "@lobb-js/core";
2
+ import { Lobb } from "@lobb-js/core";
2
3
 
3
- export const migrations: Migrations = {};
4
+ /**
5
+ * Backfill `parent_id` for any row in storage_fs that still has it null.
6
+ * Resolves the parent by walking the existing `path` string and matching
7
+ * the (parent_path, name) of every directory record. Idempotent — running
8
+ * it twice is safe; rows that already have parent_id are skipped.
9
+ */
10
+ export async function backfillStorageFsParentId(lobb: Lobb): Promise<void> {
11
+ const rows = (await lobb.collectionService.findAll({
12
+ collectionName: "storage_fs",
13
+ params: { limit: 1000000 },
14
+ })).data;
15
+ if (rows.length === 0) return;
16
+
17
+ // Index every directory by (path, name) so we can resolve any child's
18
+ // parent in one lookup. This is the same key that resolveParentIdFromPath
19
+ // walks at runtime, just precomputed.
20
+ const dirByKey = new Map<string, number>();
21
+ for (const r of rows as any[]) {
22
+ if (r.type === "directory") {
23
+ dirByKey.set(`${r.path}::${r.name}`, r.id);
24
+ }
25
+ }
26
+
27
+ function parentIdFor(row: any): number | null {
28
+ const path: string = row.path ?? "/";
29
+ if (path === "/" || path === "") return null;
30
+ const segments = path.split("/").filter(Boolean);
31
+ if (segments.length === 0) return null;
32
+ const lastName = segments[segments.length - 1];
33
+ // No-trailing-slash convention: root is "/", deeper levels are
34
+ // "/foo" or "/foo/bar". Older data may have either form; this
35
+ // resolves only the normalized one. Loose-format rows get retried
36
+ // on the next pass.
37
+ const parentDirPath =
38
+ segments.length > 1 ? "/" + segments.slice(0, -1).join("/") : "/";
39
+ const key = `${parentDirPath}::${lastName}`;
40
+ return dirByKey.get(key) ?? null;
41
+ }
42
+
43
+ for (const row of rows as any[]) {
44
+ if (row.parent_id != null) continue;
45
+ const pid = parentIdFor(row);
46
+ if (pid != null) {
47
+ await lobb.collectionStore.updateOne({
48
+ collectionName: "storage_fs",
49
+ id: row.id,
50
+ data: { parent_id: pid },
51
+ });
52
+ }
53
+ }
54
+ }
55
+
56
+ export const migrations: Migrations = {
57
+ // The storage_fs schema gained `parent_id` (FK → storage_fs.id). It is now
58
+ // the source of truth for the hierarchy; `path` is a derived cache. For
59
+ // existing rows, walk the old path strings and stamp each row's parent_id
60
+ // with the directory that already represents its parent path. Rows whose
61
+ // path is "/" stay at parent_id = null (root entries).
62
+ "backfill_storage_fs_parent_id": {
63
+ up: async () => {
64
+ await backfillStorageFsParentId(Lobb.instance);
65
+ },
66
+ },
67
+ };
@@ -21,6 +21,7 @@ export default function extension(utils: ExtensionUtils): Extension {
21
21
  href: "/studio/extensions/storage/file_manager",
22
22
  icon: utils.components.Icons.Folders,
23
23
  label: "File Manager",
24
+ represents: "storage_fs"
24
25
  },
25
26
  ],
26
27
  },
@@ -2,7 +2,7 @@
2
2
  import { FolderX } from "lucide-svelte";
3
3
  </script>
4
4
 
5
- <div class="border rounded-md p-4 bg-muted-soft flex items-center flex-col text-muted-foreground gap-2">
5
+ <div class="border rounded-md p-4 bg-muted flex items-center flex-col text-muted-foreground gap-2">
6
6
  <FolderX size={35} />
7
7
  <div class="flex flex-col justify-center items-center">
8
8
  <div class="text-sm font-bold">Storage file system is not supported here</div>
@@ -189,7 +189,7 @@
189
189
  <div class="flex h-full w-full flex-col bg-background {className}">
190
190
  <!-- file explorer header -->
191
191
  <div
192
- class="flex h-10 items-center justify-between border-b px-2"
192
+ class="flex h-12 items-center justify-between border-b px-2"
193
193
  >
194
194
  <div class="flex items-center justify-between gap-2">
195
195
  {@render topLeftHeader?.()}
@@ -199,14 +199,14 @@
199
199
  <!-- entries view toggle -->
200
200
  <Button
201
201
  variant="outline"
202
- class="h-7 px-3 text-xs font-normal"
202
+ size="sm"
203
203
  Icon={icons.FolderPlus}
204
204
  onclick={handleCreateFolder}
205
205
  >
206
206
  New folder
207
207
  </Button>
208
208
  <Button
209
- class="h-7 px-3 text-xs font-normal"
209
+ size="sm"
210
210
  Icon={icons.FilePlus}
211
211
  onclick={() => { handleFileUpload() }}
212
212
  >
@@ -215,7 +215,7 @@
215
215
  </div>
216
216
  </div>
217
217
  <!-- file explorer body -->
218
- <div class="flex flex-1 overflow-auto h-[calc(100vh-7.5rem)]">
218
+ <div class="flex flex-1 overflow-auto h-[calc(100vh-8rem)]">
219
219
  <ContextMenu.Root bind:open={contextMenuOpen}>
220
220
  <ContextMenu.Trigger
221
221
  class="z-10 flex flex-1 flex-wrap content-start items-start justify-start gap-4 p-4"
@@ -28,7 +28,7 @@
28
28
  }
29
29
  </script>
30
30
 
31
- <button onclick={handleClick} class="flex flex-col items-center gap-2 w-full border p-4 rounded-md bg-muted-soft">
31
+ <button onclick={handleClick} class="flex flex-col items-center gap-2 w-full border p-4 rounded-md bg-muted">
32
32
  {#if value}
33
33
  <FileIcon entry={value} {utils} />
34
34
  {:else}
@@ -1,7 +1,7 @@
1
1
  import type { ExposedServiceOutput, ServiceOp } from "@lobb-js/core";
2
2
 
3
3
  export type StorageFsCreateOne = ServiceOp<
4
- { file: File; data?: { path?: string }; force?: boolean },
4
+ { file: File; data?: { path?: string; parent_id?: number | null }; force?: boolean },
5
5
  ExposedServiceOutput
6
6
  >;
7
7
 
@@ -1,6 +1,5 @@
1
1
  import type { Context } from "hono";
2
2
  import type { Lobb } from "@lobb-js/core";
3
- import { StorageAdapter } from "./adapters/index.ts";
4
3
  import { LobbError } from "@lobb-js/core";
5
4
 
6
5
  export async function getFileFromRequestBody(
@@ -18,18 +17,49 @@ export async function getFileFromRequestBody(
18
17
  return undefined;
19
18
  }
20
19
 
21
- export async function validatePathExists(path: string, lobb: Lobb) {
22
- const pathArray = path.split("/").filter(Boolean);
23
- if (pathArray.length > 0) {
24
- const directoryName = pathArray[pathArray.length - 1];
25
- pathArray.pop();
26
- const directoryPath = "/" + pathArray.join("/");
20
+ // ── parent_id / path helpers ──────────────────────────────────────────────
21
+ //
22
+ // The storage_fs table uses parent_id as the source of truth for the
23
+ // hierarchy. `path` is a derived cache, maintained by the service layer on
24
+ // every write so subtree queries and breadcrumbs stay O(1). These helpers
25
+ // keep both sides in sync — see services.ts for the actual write paths.
26
+ //
27
+ // `path` is the directory the entry lives in (e.g. "/imports/risks/" for a
28
+ // file named "out.csv" placed inside /imports/risks/). The entry's own name
29
+ // is NOT appended to its `path` — that matches the existing convention used
30
+ // by every test in the suite.
31
+
32
+ // Path convention: root is "/", every other directory is "/foo" or
33
+ // "/foo/bar" (no trailing slash). The `path` column stores the parent
34
+ // directory's full path — so an entry at the root has path "/", and an
35
+ // entry inside /foo has path "/foo".
36
+ function buildPath(segments: string[]): string {
37
+ return segments.length === 0 ? "/" : "/" + segments.join("/");
38
+ }
39
+
40
+ /**
41
+ * Resolve a directory path string to the id of the record representing it.
42
+ * Returns null for "/" (root). Throws BAD_REQUEST if any segment of the
43
+ * path doesn't exist.
44
+ */
45
+ export async function resolveParentIdFromPath(
46
+ path: string,
47
+ lobb: Lobb,
48
+ ): Promise<number | null> {
49
+ const segments = (path ?? "/").split("/").filter(Boolean);
50
+ if (segments.length === 0) return null;
51
+
52
+ let parentId: number | null = null;
53
+ const walked: string[] = [];
54
+ for (const segment of segments) {
55
+ const parentPath = buildPath(walked);
27
56
  const directory = (await lobb.collectionStore.findAll({
28
57
  collectionName: "storage_fs",
29
58
  params: {
30
59
  filter: {
31
- name: directoryName,
32
- path: directoryPath,
60
+ name: segment,
61
+ path: parentPath,
62
+ type: "directory",
33
63
  },
34
64
  },
35
65
  })).data[0];
@@ -40,48 +70,112 @@ export async function validatePathExists(path: string, lobb: Lobb) {
40
70
  message: `The specified path does not exist. Please create it first.`,
41
71
  });
42
72
  }
73
+
74
+ parentId = directory.id;
75
+ walked.push(segment);
43
76
  }
77
+
78
+ return parentId;
44
79
  }
45
80
 
46
81
  /**
47
- * Ensures a directory path exists, creating it if necessary
82
+ * Like resolveParentIdFromPath, but creates any missing directories along
83
+ * the way (used by force=true uploads).
48
84
  */
49
- export async function ensurePathExists(path: string, lobb: Lobb): Promise<void> {
50
- if (!path || path === "/") {
51
- return; // Root always exists
52
- }
85
+ export async function ensureParentIdForPath(
86
+ path: string,
87
+ lobb: Lobb,
88
+ ): Promise<number | null> {
89
+ const segments = (path ?? "/").split("/").filter(Boolean);
90
+ if (segments.length === 0) return null;
53
91
 
54
- // Parse the path into segments
55
- const pathArray = path.split("/").filter(Boolean);
56
-
57
- // Check and create each directory in the path
58
- let currentPath = "/";
59
- for (const dirName of pathArray) {
60
- // Check if this directory exists
92
+ let parentId: number | null = null;
93
+ const walked: string[] = [];
94
+ for (const segment of segments) {
95
+ const parentPath = buildPath(walked);
61
96
  const existing = (await lobb.collectionStore.findAll({
62
97
  collectionName: "storage_fs",
63
98
  params: {
64
99
  filter: {
65
- name: dirName,
66
- path: currentPath,
100
+ name: segment,
101
+ path: parentPath,
67
102
  type: "directory",
68
103
  },
69
104
  },
70
- })).data;
105
+ })).data[0];
71
106
 
72
- // Create the directory if it doesn't exist
73
- if (existing.length === 0) {
74
- await lobb.collectionStore.createOne({
107
+ if (existing) {
108
+ parentId = existing.id;
109
+ } else {
110
+ const created = await lobb.collectionStore.createOne({
75
111
  collectionName: "storage_fs",
76
112
  data: {
77
- name: dirName,
78
- path: currentPath,
113
+ name: segment,
114
+ path: parentPath,
115
+ parent_id: parentId,
79
116
  type: "directory",
80
117
  },
81
118
  });
119
+ parentId = created.data.id;
120
+ }
121
+ walked.push(segment);
122
+ }
123
+
124
+ return parentId;
125
+ }
126
+
127
+ /**
128
+ * Compute the path string for an entry whose parent is `parentId`. Walks up
129
+ * the parent chain to the root. Used on insert and whenever a record's
130
+ * parent_id changes.
131
+ */
132
+ export async function computePathForParent(
133
+ parentId: number | null,
134
+ lobb: Lobb,
135
+ ): Promise<string> {
136
+ if (parentId == null) return "/";
137
+
138
+ const segments: string[] = [];
139
+ let currentId: number | null = parentId;
140
+ // Defensive depth cap — guards against accidental cycles. Real hierarchies
141
+ // don't get this deep; if we somehow do, throw rather than infinite-loop.
142
+ for (let depth = 0; depth < 1024; depth++) {
143
+ if (currentId == null) break;
144
+ const row: any = (await lobb.collectionStore.findOne({
145
+ collectionName: "storage_fs",
146
+ id: String(currentId),
147
+ })).data;
148
+ if (!row) {
149
+ throw new LobbError({
150
+ code: "BAD_REQUEST",
151
+ message: `Parent directory ${currentId} not found.`,
152
+ });
82
153
  }
154
+ segments.unshift(row.name);
155
+ currentId = row.parent_id ?? null;
156
+ }
157
+ return buildPath(segments);
158
+ }
83
159
 
84
- // Update current path for next iteration
85
- currentPath = currentPath + dirName + "/";
160
+ /**
161
+ * Return every descendant of `parentId`, breadth-first. Used by cascading
162
+ * deletes and by rename/move to recompute paths for the whole subtree in
163
+ * one pass.
164
+ */
165
+ export async function findDescendants(
166
+ parentId: number,
167
+ lobb: Lobb,
168
+ ): Promise<any[]> {
169
+ const out: any[] = [];
170
+ let frontier: number[] = [parentId];
171
+ while (frontier.length > 0) {
172
+ const children = (await lobb.collectionStore.findAll({
173
+ collectionName: "storage_fs",
174
+ params: { filter: { parent_id: { $in: frontier } } },
175
+ })).data;
176
+ if (children.length === 0) break;
177
+ out.push(...children);
178
+ frontier = children.map((c: any) => c.id);
86
179
  }
180
+ return out;
87
181
  }