@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 +1 -0
- package/dist/lib/components/explorerNotSupported.svelte +1 -1
- package/dist/lib/components/fileExplorer.svelte +4 -4
- package/dist/lib/components/foreignKeyComponent.svelte +1 -1
- package/extensions/storage/collections/fileSystem.ts +14 -0
- package/extensions/storage/migrations.ts +66 -2
- package/extensions/storage/studio/index.ts +1 -0
- package/extensions/storage/studio/lib/components/explorerNotSupported.svelte +1 -1
- package/extensions/storage/studio/lib/components/fileExplorer.svelte +4 -4
- package/extensions/storage/studio/lib/components/foreignKeyComponent.svelte +1 -1
- package/extensions/storage/types.ts +1 -1
- package/extensions/storage/utils.ts +125 -31
- package/extensions/storage/workflows/services.ts +225 -100
- package/package.json +5 -5
- package/extensions/storage/tests/configs/simple.ts +0 -85
- package/extensions/storage/tests/directories.test.ts +0 -156
- package/extensions/storage/tests/extraFormData.test.ts +0 -47
- package/extensions/storage/tests/files/rose.jpeg +0 -0
- package/extensions/storage/tests/files.test.ts +0 -292
- package/extensions/storage/tests/forceUpload.test.ts +0 -72
- package/extensions/storage/tests/massRemove.test.ts +0 -189
- package/extensions/storage/tests/meta.test.ts +0 -26
- package/extensions/storage/tests/recursiveDeleteMany.test.ts +0 -208
- package/extensions/storage/tests/recursiveDeleteOne.test.ts +0 -206
- package/extensions/storage/tests/storage/127 +0 -0
package/dist/index.js
CHANGED
|
@@ -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
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
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
|
|
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 {
|
|
1
|
+
import type { Migrations } from "@lobb-js/core";
|
|
2
|
+
import { Lobb } from "@lobb-js/core";
|
|
2
3
|
|
|
3
|
-
|
|
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
|
+
};
|
|
@@ -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
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
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
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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:
|
|
32
|
-
path:
|
|
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
|
-
*
|
|
82
|
+
* Like resolveParentIdFromPath, but creates any missing directories along
|
|
83
|
+
* the way (used by force=true uploads).
|
|
48
84
|
*/
|
|
49
|
-
export async function
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
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:
|
|
66
|
-
path:
|
|
100
|
+
name: segment,
|
|
101
|
+
path: parentPath,
|
|
67
102
|
type: "directory",
|
|
68
103
|
},
|
|
69
104
|
},
|
|
70
|
-
})).data;
|
|
105
|
+
})).data[0];
|
|
71
106
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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:
|
|
78
|
-
path:
|
|
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
|
-
|
|
85
|
-
|
|
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
|
}
|