@lobb-js/lobb-ext-storage 0.13.1 → 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>
@@ -67,10 +67,7 @@
67
67
  const newFolderName = prompt("Please enter a name for the new folder:");
68
68
  if (newFolderName) {
69
69
  const response = await utils.lobb.createOne("storage_fs", {
70
- name: newFolderName,
71
- path: location,
72
- type: "directory",
73
- ...foreignKeyObject,
70
+ data: { name: newFolderName, path: location, type: "directory", ...foreignKeyObject },
74
71
  });
75
72
  const result = await response.json();
76
73
  if (result.error) {
@@ -94,10 +91,7 @@
94
91
  if (file) {
95
92
  const response = await utils.lobb.createOne(
96
93
  "storage_fs",
97
- {
98
- path: location,
99
- ...foreignKeyObject,
100
- },
94
+ { data: { path: location, ...foreignKeyObject } },
101
95
  file,
102
96
  );
103
97
  const result = await response.json();
@@ -195,7 +189,7 @@
195
189
  <div class="flex h-full w-full flex-col bg-background {className}">
196
190
  <!-- file explorer header -->
197
191
  <div
198
- 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"
199
193
  >
200
194
  <div class="flex items-center justify-between gap-2">
201
195
  {@render topLeftHeader?.()}
@@ -205,14 +199,14 @@
205
199
  <!-- entries view toggle -->
206
200
  <Button
207
201
  variant="outline"
208
- class="h-7 px-3 text-xs font-normal"
202
+ size="sm"
209
203
  Icon={icons.FolderPlus}
210
204
  onclick={handleCreateFolder}
211
205
  >
212
206
  New folder
213
207
  </Button>
214
208
  <Button
215
- class="h-7 px-3 text-xs font-normal"
209
+ size="sm"
216
210
  Icon={icons.FilePlus}
217
211
  onclick={() => { handleFileUpload() }}
218
212
  >
@@ -221,7 +215,7 @@
221
215
  </div>
222
216
  </div>
223
217
  <!-- file explorer body -->
224
- <div class="flex flex-1 overflow-auto h-[calc(100vh-7.5rem)]">
218
+ <div class="flex flex-1 overflow-auto h-[calc(100vh-8rem)]">
225
219
  <ContextMenu.Root bind:open={contextMenuOpen}>
226
220
  <ContextMenu.Trigger
227
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>
@@ -67,10 +67,7 @@
67
67
  const newFolderName = prompt("Please enter a name for the new folder:");
68
68
  if (newFolderName) {
69
69
  const response = await utils.lobb.createOne("storage_fs", {
70
- name: newFolderName,
71
- path: location,
72
- type: "directory",
73
- ...foreignKeyObject,
70
+ data: { name: newFolderName, path: location, type: "directory", ...foreignKeyObject },
74
71
  });
75
72
  const result = await response.json();
76
73
  if (result.error) {
@@ -94,10 +91,7 @@
94
91
  if (file) {
95
92
  const response = await utils.lobb.createOne(
96
93
  "storage_fs",
97
- {
98
- path: location,
99
- ...foreignKeyObject,
100
- },
94
+ { data: { path: location, ...foreignKeyObject } },
101
95
  file,
102
96
  );
103
97
  const result = await response.json();
@@ -195,7 +189,7 @@
195
189
  <div class="flex h-full w-full flex-col bg-background {className}">
196
190
  <!-- file explorer header -->
197
191
  <div
198
- 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"
199
193
  >
200
194
  <div class="flex items-center justify-between gap-2">
201
195
  {@render topLeftHeader?.()}
@@ -205,14 +199,14 @@
205
199
  <!-- entries view toggle -->
206
200
  <Button
207
201
  variant="outline"
208
- class="h-7 px-3 text-xs font-normal"
202
+ size="sm"
209
203
  Icon={icons.FolderPlus}
210
204
  onclick={handleCreateFolder}
211
205
  >
212
206
  New folder
213
207
  </Button>
214
208
  <Button
215
- class="h-7 px-3 text-xs font-normal"
209
+ size="sm"
216
210
  Icon={icons.FilePlus}
217
211
  onclick={() => { handleFileUpload() }}
218
212
  >
@@ -221,7 +215,7 @@
221
215
  </div>
222
216
  </div>
223
217
  <!-- file explorer body -->
224
- <div class="flex flex-1 overflow-auto h-[calc(100vh-7.5rem)]">
218
+ <div class="flex flex-1 overflow-auto h-[calc(100vh-8rem)]">
225
219
  <ContextMenu.Root bind:open={contextMenuOpen}>
226
220
  <ContextMenu.Trigger
227
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
  }