@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.
@@ -1,7 +1,22 @@
1
1
  import type { Workflow } from "@lobb-js/core";
2
+ import { LobbError } from "@lobb-js/core";
2
3
  import type { ExtensionConfig } from "../config/extensionConfigSchema.ts";
3
4
  import { initStorageAdapter } from "../adapters/index.ts";
4
- import { validatePathExists, ensurePathExists } from "../utils.ts";
5
+ import {
6
+ computePathForParent,
7
+ ensureParentIdForPath,
8
+ findDescendants,
9
+ resolveParentIdFromPath,
10
+ } from "../utils.ts";
11
+
12
+ // All writes funnel through these service overrides so the invariant
13
+ // `path === parent.path + parent.name + "/"` is maintained on every change.
14
+ // Reads stay free — both `parent_id` and `path` are on the row.
15
+ //
16
+ // `parent_id` is the source of truth for the hierarchy; `path` is a cached
17
+ // view used for fast subtree queries and breadcrumbs. Callers may pass
18
+ // either (or both) to create/update — we resolve to parent_id and recompute
19
+ // path from the tree.
5
20
 
6
21
  export function getServicesWorkflows(extensionConfig: ExtensionConfig): Workflow[] {
7
22
  return [
@@ -29,153 +44,263 @@ export function getServicesWorkflows(extensionConfig: ExtensionConfig): Workflow
29
44
  name: "storageCreateOneServiceOverride",
30
45
  eventName: "core.service.createOne.override",
31
46
  handler: async (input, ctx) => {
32
- if (input.collectionName === "storage_fs") {
33
- if (input.file) {
34
- const filePath = input.data?.path ?? "/";
47
+ if (input.collectionName !== "storage_fs") return;
35
48
 
36
- if (input.force) {
37
- await ensurePathExists(filePath, ctx.lobb);
38
- } else if (filePath !== "/") {
39
- await validatePathExists(filePath, ctx.lobb);
40
- }
49
+ const raw = input.data ?? {};
50
+ const explicitParentId = raw.parent_id ?? null;
51
+ const pathInput: string = raw.path ?? "/";
41
52
 
42
- const { path: _path, name: _name, type: _type, file_mime_type: _mime, file_size: _size, ...extraData } = input.data ?? {};
53
+ // Resolve parent_id: explicit wins, else derive from the path.
54
+ // `force` (uploads) creates missing directories along the way.
55
+ const parentId = explicitParentId != null
56
+ ? Number(explicitParentId)
57
+ : input.force
58
+ ? await ensureParentIdForPath(pathInput, ctx.lobb)
59
+ : await resolveParentIdFromPath(pathInput, ctx.lobb);
43
60
 
44
- const result = await ctx.lobb.collectionStore.createOne({
45
- collectionName: "storage_fs",
46
- data: {
47
- ...extraData,
48
- name: input.file.name,
49
- path: filePath,
50
- type: "file",
51
- file_mime_type: input.file.type,
52
- file_size: input.file.size,
53
- },
54
- });
61
+ const cachedPath = await computePathForParent(parentId, ctx.lobb);
55
62
 
56
- const adapter = initStorageAdapter(extensionConfig);
57
- await adapter.createFile(result.data.id, input.file);
63
+ const {
64
+ path: _path,
65
+ parent_id: _pid,
66
+ name: _name,
67
+ type: _type,
68
+ file_mime_type: _mime,
69
+ file_size: _size,
70
+ ...extraData
71
+ } = raw;
58
72
 
59
- return result;
60
- } else {
61
- if (input.data?.path) {
62
- await validatePathExists(input.data.path, ctx.lobb);
63
- }
64
- }
73
+ if (input.file) {
74
+ const result = await ctx.lobb.collectionStore.createOne({
75
+ collectionName: "storage_fs",
76
+ data: {
77
+ ...extraData,
78
+ name: input.file.name,
79
+ parent_id: parentId,
80
+ path: cachedPath,
81
+ type: "file",
82
+ file_mime_type: input.file.type,
83
+ file_size: input.file.size,
84
+ },
85
+ });
86
+
87
+ const adapter = initStorageAdapter(extensionConfig);
88
+ await adapter.createFile(result.data.id, input.file);
89
+ return result;
65
90
  }
91
+
92
+ // Non-file writes (directories or pure metadata records) — let the
93
+ // generic store handle it after we stamp parent_id + path.
94
+ return await ctx.lobb.collectionStore.createOne({
95
+ collectionName: "storage_fs",
96
+ data: {
97
+ ...extraData,
98
+ name: raw.name,
99
+ parent_id: parentId,
100
+ path: cachedPath,
101
+ type: raw.type ?? "directory",
102
+ ...(raw.icon !== undefined ? { icon: raw.icon } : {}),
103
+ ...(raw.is_pinned_sidebar !== undefined ? { is_pinned_sidebar: raw.is_pinned_sidebar } : {}),
104
+ },
105
+ });
66
106
  },
67
107
  },
68
108
  {
69
109
  name: "storageDeleteOneService",
70
110
  eventName: "core.service.deleteOne.override",
71
111
  handler: async (input, ctx) => {
72
- if (input.collectionName === "storage_fs") {
73
- const entry = (await ctx.lobb.collectionStore.findOne({
112
+ if (input.collectionName !== "storage_fs") return;
113
+
114
+ const entry = (await ctx.lobb.collectionStore.findOne({
115
+ collectionName: "storage_fs",
116
+ id: input.id,
117
+ })).data;
118
+ if (!entry) {
119
+ return ctx.lobb.collectionStore.deleteOne({
74
120
  collectionName: "storage_fs",
75
121
  id: input.id,
76
- })).data;
122
+ });
123
+ }
77
124
 
78
- const adapter = initStorageAdapter(extensionConfig);
79
- if (entry.type === "file") {
80
- await adapter.removeFile(entry.id);
81
- } else if (entry.type === "directory") {
82
- const directoryPath = entry.path + entry.name;
125
+ const adapter = initStorageAdapter(extensionConfig);
83
126
 
84
- const entries = (await ctx.lobb.collectionService.findAll({
85
- collectionName: "storage_fs",
86
- params: {
87
- filter: {
88
- path: {
89
- $starts_with: directoryPath,
90
- },
91
- },
92
- },
93
- })).data;
94
-
95
- for (let index = 0; index < entries.length; index++) {
96
- const entry = entries[index];
97
- if (entry.type === "file") {
98
- await adapter.removeFile(entry.id);
99
- }
127
+ if (entry.type === "file") {
128
+ await adapter.removeFile(entry.id);
129
+ } else if (entry.type === "directory") {
130
+ // Cascade leaf-first so each deleted row has no remaining children
131
+ // referencing it — Lobb's core FK-children check would otherwise
132
+ // block the delete. findDescendants returns BFS (shallow → deep);
133
+ // reverse to walk deep → shallow.
134
+ const descendants = await findDescendants(entry.id, ctx.lobb);
135
+ for (const d of [...descendants].reverse()) {
136
+ if (d.type === "file") {
137
+ await adapter.removeFile(d.id);
100
138
  }
101
-
102
- await ctx.lobb.collectionStore.deleteMany({
139
+ await ctx.lobb.collectionStore.deleteOne({
103
140
  collectionName: "storage_fs",
104
- filter: {
105
- path: {
106
- $starts_with: directoryPath,
107
- },
108
- },
141
+ id: String(d.id),
109
142
  });
110
143
  }
111
-
112
- return ctx.lobb.collectionStore.deleteOne({
113
- collectionName: "storage_fs",
114
- id: input.id,
115
- });
116
144
  }
145
+
146
+ return ctx.lobb.collectionStore.deleteOne({
147
+ collectionName: "storage_fs",
148
+ id: input.id,
149
+ });
117
150
  },
118
151
  },
119
152
  {
120
153
  name: "storageDeleteManyService",
121
154
  eventName: "core.service.deleteMany.override",
122
155
  handler: async (input, ctx) => {
123
- if (input.collectionName === "storage_fs") {
124
- const matchedEntries = (await ctx.lobb.collectionStore.findAll({
125
- collectionName: "storage_fs",
126
- params: { filter: input.filter },
127
- })).data;
156
+ if (input.collectionName !== "storage_fs") return;
128
157
 
129
- const adapter = initStorageAdapter(extensionConfig);
130
-
131
- for (const entry of matchedEntries) {
132
- if (entry.type === "directory") {
133
- const directoryPath = entry.path + entry.name;
134
-
135
- const nestedEntries = (await ctx.lobb.collectionStore.findAll({
136
- collectionName: "storage_fs",
137
- params: {
138
- filter: { path: { $starts_with: directoryPath } },
139
- },
140
- })).data;
158
+ const matched = (await ctx.lobb.collectionStore.findAll({
159
+ collectionName: "storage_fs",
160
+ params: { filter: input.filter },
161
+ })).data;
141
162
 
142
- for (const nested of nestedEntries) {
143
- if (nested.type === "file") {
144
- await adapter.removeFile(nested.id);
145
- }
146
- }
147
-
148
- await ctx.lobb.collectionStore.deleteMany({
149
- collectionName: "storage_fs",
150
- filter: { path: { $starts_with: directoryPath } },
151
- });
152
- } else if (entry.type === "file") {
153
- await adapter.removeFile(entry.id);
163
+ const adapter = initStorageAdapter(extensionConfig);
164
+ // Collect every row to remove, BFS shallow→deep. We delete in
165
+ // reverse order so leaves are dropped before their parents — that
166
+ // keeps Lobb's children FK check happy.
167
+ const ordered: any[] = [];
168
+ const seen = new Set<number>();
169
+ for (const entry of matched) {
170
+ if (seen.has(entry.id)) continue;
171
+ seen.add(entry.id);
172
+ ordered.push(entry);
173
+ if (entry.type === "directory") {
174
+ for (const d of await findDescendants(entry.id, ctx.lobb)) {
175
+ if (seen.has(d.id)) continue;
176
+ seen.add(d.id);
177
+ ordered.push(d);
154
178
  }
155
179
  }
180
+ }
156
181
 
157
- return ctx.lobb.collectionStore.deleteMany({
182
+ for (const row of [...ordered].reverse()) {
183
+ if (row.type === "file") await adapter.removeFile(row.id);
184
+ await ctx.lobb.collectionStore.deleteOne({
158
185
  collectionName: "storage_fs",
159
- filter: input.filter,
186
+ id: String(row.id),
160
187
  });
161
188
  }
189
+
190
+ // affectedCount reflects what the caller asked to delete (the
191
+ // matched rows). Cascaded descendants are an implementation detail.
192
+ return { affectedCount: matched.length };
162
193
  },
163
194
  },
164
195
  {
165
196
  name: "storageUpdateOneService",
166
197
  eventName: "core.service.updateOne.override",
167
198
  handler: async (input, ctx) => {
168
- if (input.collectionName === "storage_fs") {
169
- if (input.data.path) {
170
- await validatePathExists(input.data.path, ctx.lobb);
171
- }
199
+ if (input.collectionName !== "storage_fs") return;
172
200
 
201
+ const current = (await ctx.lobb.collectionStore.findOne({
202
+ collectionName: "storage_fs",
203
+ id: input.id,
204
+ })).data;
205
+ if (!current) {
173
206
  return ctx.lobb.collectionStore.updateOne({
174
207
  collectionName: "storage_fs",
175
208
  id: input.id,
176
209
  data: input.data,
177
210
  });
178
211
  }
212
+
213
+ const data: any = { ...input.data };
214
+
215
+ // Translate any path-based reparenting into a parent_id update so
216
+ // the rest of this handler has a single canonical signal to work
217
+ // with. Explicit parent_id wins if both are present.
218
+ if (data.path != null && data.parent_id == null) {
219
+ data.parent_id = await resolveParentIdFromPath(data.path, ctx.lobb);
220
+ }
221
+ // The path field is derived — never let a caller overwrite it raw.
222
+ delete data.path;
223
+
224
+ // If the row's location in the tree is changing (name or parent),
225
+ // recompute its own path and cascade the change to every descendant.
226
+ const nameChanged = data.name != null && data.name !== current.name;
227
+ const parentChanged =
228
+ Object.prototype.hasOwnProperty.call(data, "parent_id") &&
229
+ (data.parent_id ?? null) !== (current.parent_id ?? null);
230
+
231
+ if (nameChanged || parentChanged) {
232
+ const newParentId = parentChanged ? (data.parent_id ?? null) : current.parent_id;
233
+ const newName = nameChanged ? data.name : current.name;
234
+
235
+ // Cycle prevention: the new parent must not be the row itself,
236
+ // and walking up from it must never hit the row's own id.
237
+ if (parentChanged && newParentId != null) {
238
+ if (Number(newParentId) === Number(input.id)) {
239
+ throw new LobbError({
240
+ code: "BAD_REQUEST",
241
+ message: "A directory cannot be its own parent.",
242
+ });
243
+ }
244
+ let cur: number | null = Number(newParentId);
245
+ while (cur != null) {
246
+ if (cur === Number(input.id)) {
247
+ throw new LobbError({
248
+ code: "BAD_REQUEST",
249
+ message: "Cannot move a directory into one of its own descendants.",
250
+ });
251
+ }
252
+ const row: any = (await ctx.lobb.collectionStore.findOne({
253
+ collectionName: "storage_fs",
254
+ id: String(cur),
255
+ })).data;
256
+ if (!row) break;
257
+ cur = row.parent_id ?? null;
258
+ }
259
+ }
260
+
261
+ const newSelfPath = await computePathForParent(newParentId, ctx.lobb);
262
+
263
+ const result = await ctx.lobb.collectionStore.updateOne({
264
+ collectionName: "storage_fs",
265
+ id: input.id,
266
+ data: { ...data, path: newSelfPath },
267
+ });
268
+
269
+ // Cascade: for a directory, every descendant's `path` is anchored
270
+ // to this row's name + its own path. Recompute by walking the
271
+ // tree once in memory — no DB round trip per descendant.
272
+ if (current.type === "directory") {
273
+ const descendants = await findDescendants(Number(input.id), ctx.lobb);
274
+ const byId = new Map<number, any>();
275
+ for (const d of descendants) byId.set(d.id, d);
276
+ // The directory's OWN full path (= its parent's path + its name)
277
+ // is what every direct child stores in `path`.
278
+ const newDirFullPath = newSelfPath === "/" ? "/" + newName : newSelfPath + "/" + newName;
279
+ const pathOf = (id: number | null): string => {
280
+ if (id == null) return "/";
281
+ if (id === Number(input.id)) return newDirFullPath;
282
+ const r = byId.get(id);
283
+ if (!r) return "/";
284
+ const parentFull = pathOf(r.parent_id ?? null);
285
+ return parentFull === "/" ? "/" + r.name : parentFull + "/" + r.name;
286
+ };
287
+ for (const d of descendants) {
288
+ await ctx.lobb.collectionStore.updateOne({
289
+ collectionName: "storage_fs",
290
+ id: String(d.id),
291
+ data: { path: pathOf(d.parent_id ?? null) },
292
+ });
293
+ }
294
+ }
295
+
296
+ return result;
297
+ }
298
+
299
+ return ctx.lobb.collectionStore.updateOne({
300
+ collectionName: "storage_fs",
301
+ id: input.id,
302
+ data,
303
+ });
179
304
  },
180
305
  },
181
306
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lobb-js/lobb-ext-storage",
3
- "version": "0.13.1",
3
+ "version": "0.15.0",
4
4
  "license": "UNLICENSED",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -18,8 +18,8 @@
18
18
  }
19
19
  },
20
20
  "scripts": {
21
- "test": "bun test extensions/storage/tests",
22
- "test:lobb": "bun test extensions/storage/tests",
21
+ "test": "bun test tests",
22
+ "test:lobb": "bun test tests",
23
23
  "dev": "bun run --watch lobb.ts",
24
24
  "dev:studio": "vite dev",
25
25
  "build": "vite build",
@@ -32,14 +32,14 @@
32
32
  "package": "svelte-package --input extensions/storage/studio"
33
33
  },
34
34
  "dependencies": {
35
- "@lobb-js/core": "^0.32.1",
35
+ "@lobb-js/core": "^0.38.1",
36
36
  "browser-fs-access": "^0.35.0",
37
37
  "hono": "^4.7.0",
38
38
  "openapi-types": "^12.1.3",
39
39
  "path-browserify": "^1.0.1"
40
40
  },
41
41
  "devDependencies": {
42
- "@lobb-js/studio": "^0.29.1",
42
+ "@lobb-js/studio": "^0.44.1",
43
43
  "@lucide/svelte": "^0.563.1",
44
44
  "@sveltejs/adapter-node": "^5.5.4",
45
45
  "@sveltejs/kit": "^2.60.1",
@@ -1,85 +0,0 @@
1
- import type { Config } from "@lobb-js/core";
2
- import storage from "../../index.ts";
3
- import { join } from "node:path";
4
- import { tmpdir } from "node:os";
5
-
6
- export function createSimpleConfig(uploadsPath?: string): Config & { uploadsPath: string } {
7
- const resolvedUploadsPath = uploadsPath ?? join(tmpdir(), "lobb_storage_" + crypto.randomUUID());
8
- return {
9
- uploadsPath: resolvedUploadsPath,
10
- project: {
11
- name: "Lobb",
12
- force_sync: true,
13
- },
14
- database: {
15
- host: "localhost",
16
- port: 5432,
17
- username: "test",
18
- password: "test",
19
- database: "*",
20
- },
21
- web_server: {
22
- host: "0.0.0.0",
23
- port: 0,
24
- cors: {
25
- origin: "*",
26
- },
27
- },
28
- extensions: [
29
- storage({
30
- adapter: "local",
31
- uploadsPath: resolvedUploadsPath,
32
- }),
33
- ],
34
- collections: {
35
- articles: {
36
- indexes: {},
37
- fields: {
38
- id: {
39
- type: "integer",
40
- },
41
- image: {
42
- type: "string",
43
- length: 255,
44
- },
45
- title: {
46
- type: "string",
47
- length: 255,
48
- required: true,
49
- },
50
- description: {
51
- type: "string",
52
- length: 255,
53
- },
54
- body: {
55
- type: "string",
56
- length: 255,
57
- required: true,
58
- },
59
- status: {
60
- type: "string",
61
- length: 255,
62
- enum: ["public", "private"],
63
- },
64
- },
65
- },
66
- comments: {
67
- indexes: {},
68
- fields: {
69
- id: {
70
- type: "integer",
71
- },
72
- body: {
73
- type: "string",
74
- length: 255,
75
- required: true,
76
- },
77
- article_id: {
78
- type: "integer",
79
- required: true,
80
- },
81
- },
82
- },
83
- },
84
- };
85
- }
@@ -1,156 +0,0 @@
1
- import { Lobb } from "@lobb-js/core";
2
- import { afterAll, beforeAll, describe, expect, it } from "bun:test";
3
- import { createSimpleConfig } from "./configs/simple.ts";
4
-
5
- describe("Directories Operations", () => {
6
- let lobb: Lobb;
7
- let baseUrl: string;
8
- let createdRecordId: number;
9
-
10
- beforeAll(async () => {
11
- const config = createSimpleConfig();
12
- lobb = await Lobb.init(config);
13
- baseUrl = `http://127.0.0.1:${lobb.webServer.port}`;
14
- });
15
-
16
- afterAll(async () => {
17
- await lobb.close();
18
- });
19
-
20
- it("should create a directory successfully", async () => {
21
- const response = await fetch(
22
- `${baseUrl}/api/collections/storage_fs`,
23
- {
24
- method: "POST",
25
- body: JSON.stringify({
26
- data: {
27
- name: "test_directory",
28
- path: "/",
29
- },
30
- }),
31
- },
32
- );
33
- const data = await response.json();
34
-
35
- expect(data).toMatchObject({
36
- data: {
37
- name: "test_directory",
38
- path: "/",
39
- type: "directory",
40
- },
41
- });
42
-
43
- createdRecordId = data.data.id;
44
- });
45
-
46
- it("should prevent creating a directory in a non existing path", async () => {
47
- const response = await fetch(
48
- `${baseUrl}/api/collections/storage_fs`,
49
- {
50
- method: "POST",
51
- body: JSON.stringify({
52
- data: {
53
- name: "another_dir",
54
- path: "/none_existing_directory",
55
- },
56
- }),
57
- },
58
- );
59
- const data = await response.json();
60
-
61
- expect(data).toEqual({
62
- code: "BAD_REQUEST",
63
- status: 400,
64
- message: "The specified path does not exist. Please create it first.",
65
- });
66
- });
67
-
68
- it("should respond with the record of the requested directory entry", async () => {
69
- const response = await fetch(
70
- `${baseUrl}/api/collections/storage_fs/${createdRecordId}`,
71
- {
72
- method: "GET",
73
- },
74
- );
75
- const data = await response.json();
76
-
77
- expect(data).toMatchObject({
78
- data: {
79
- name: "test_directory",
80
- path: "/",
81
- type: "directory",
82
- },
83
- });
84
- });
85
-
86
- it("should list all file system entries", async () => {
87
- const response = await fetch(
88
- `${baseUrl}/api/collections/storage_fs`,
89
- {
90
- method: "GET",
91
- },
92
- );
93
- const data = await response.json();
94
-
95
- expect(data).toMatchObject({
96
- data: [
97
- {
98
- name: "test_directory",
99
- path: "/",
100
- type: "directory",
101
- },
102
- ],
103
- meta: {
104
- totalCount: 1,
105
- },
106
- });
107
- });
108
-
109
- it("should list all file system entries using a filter", async () => {
110
- const response = await fetch(
111
- `${baseUrl}/api/collections/storage_fs/search`,
112
- {
113
- method: "POST",
114
- body: JSON.stringify({
115
- filter: {
116
- path: {
117
- $starts_with: "/",
118
- },
119
- },
120
- }),
121
- },
122
- );
123
- const data = await response.json();
124
-
125
- expect(data).toMatchObject({
126
- data: [
127
- {
128
- name: "test_directory",
129
- path: "/",
130
- type: "directory",
131
- },
132
- ],
133
- meta: {
134
- totalCount: 1,
135
- },
136
- });
137
- });
138
-
139
- it("should delete the directory successully", async () => {
140
- const response = await fetch(
141
- `${baseUrl}/api/collections/storage_fs/${createdRecordId}`,
142
- {
143
- method: "DELETE",
144
- },
145
- );
146
- const data = await response.json();
147
-
148
- expect(data).toMatchObject({
149
- data: {
150
- name: "test_directory",
151
- path: "/",
152
- type: "directory",
153
- },
154
- });
155
- });
156
- });