@lobb-js/lobb-ext-storage 0.15.1 → 0.16.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
@@ -1,5 +1,5 @@
1
1
  import FileExplorerPage from "./lib/components/pages/fileExplorerPage.svelte";
2
- import ForeignKeyComponent from "./lib/components/foreignKeyComponent.svelte";
2
+ import FilePickerField from "./lib/components/FilePickerField.svelte";
3
3
  import ChildrenFileExplorer from "./lib/components/childrenFileExplorer.svelte";
4
4
  import ExplorerNotSupported from "./lib/components/explorerNotSupported.svelte";
5
5
  export default function extension(utils) {
@@ -7,7 +7,7 @@ export default function extension(utils) {
7
7
  name: "storage",
8
8
  components: {
9
9
  "pages.file_manager": FileExplorerPage,
10
- "detailView.fields.foreignKey.storage_fs": ForeignKeyComponent,
10
+ "detailView.fields.foreignKey.storage_fs": FilePickerField,
11
11
  "detailView.create.subRecords.storage_fs": ExplorerNotSupported,
12
12
  "detailView.update.subRecords.storage_fs": ChildrenFileExplorer,
13
13
  "listView.entry.children.storage_fs": ChildrenFileExplorer,
@@ -0,0 +1,48 @@
1
+ <script lang="ts">
2
+ import type { ExtensionProps } from "@lobb-js/studio";
3
+ import FileExplorer, { type Entry } from "./fileExplorer.svelte";
4
+ import FileIcon from "./fileIcon.svelte";
5
+
6
+ interface Props extends ExtensionProps {
7
+ value: Entry;
8
+ header?: any;
9
+ isDisabled?: boolean;
10
+ errorMessages?: any;
11
+ }
12
+
13
+ let { utils, value = $bindable(), header, isDisabled = false, errorMessages = [], ...props }: Props = $props();
14
+
15
+ const FilePlus2 = $derived(utils.components.Icons.FilePlus2);
16
+ const Drawer = $derived(utils.components.Drawer);
17
+ const FieldWrapper = $derived(utils.components.FieldWrapper);
18
+
19
+ let openDrawer = $state(false);
20
+
21
+ async function onHideHandle() {
22
+ openDrawer = false;
23
+ }
24
+
25
+ async function handleOnFileSelect(entry: Entry) {
26
+ value = entry;
27
+ openDrawer = false;
28
+ }
29
+ </script>
30
+
31
+ <FieldWrapper span={2} {isDisabled} {errorMessages} {header}>
32
+ {#snippet children()}
33
+ <button onclick={() => openDrawer = true} class="flex flex-col items-center gap-2 w-full border p-4 rounded-md bg-muted">
34
+ {#if value}
35
+ <FileIcon entry={value} {utils} />
36
+ {:else}
37
+ <FilePlus2 class="text-muted-foreground" />
38
+ <div class="text-sm text-muted-foreground">Click to add an asset</div>
39
+ {/if}
40
+ </button>
41
+
42
+ {#if openDrawer}
43
+ <Drawer onHide={onHideHandle}>
44
+ <FileExplorer onFileSelect={handleOnFileSelect} utils={utils} {...props} />
45
+ </Drawer>
46
+ {/if}
47
+ {/snippet}
48
+ </FieldWrapper>
@@ -0,0 +1,14 @@
1
+ import { SvelteComponentTyped } from "svelte";
2
+ declare const __propDef: {
3
+ props: Record<string, never>;
4
+ events: {
5
+ [evt: string]: CustomEvent<any>;
6
+ };
7
+ slots: {};
8
+ };
9
+ export type FilePickerFieldProps = typeof __propDef.props;
10
+ export type FilePickerFieldEvents = typeof __propDef.events;
11
+ export type FilePickerFieldSlots = typeof __propDef.slots;
12
+ export default class FilePickerField extends SvelteComponentTyped<FilePickerFieldProps, FilePickerFieldEvents, FilePickerFieldSlots> {
13
+ }
14
+ export {};
@@ -66,7 +66,7 @@
66
66
  contextMenuOpen = false;
67
67
  const newFolderName = prompt("Please enter a name for the new folder:");
68
68
  if (newFolderName) {
69
- const response = await utils.lobb.createOne("storage_fs", {
69
+ const response = await utils.lobb.runAction("storage_create", {
70
70
  data: { name: newFolderName, path: location, type: "directory", ...foreignKeyObject },
71
71
  });
72
72
  const result = await response.json();
@@ -89,8 +89,8 @@
89
89
 
90
90
  async function uploadFile(file: File) {
91
91
  if (file) {
92
- const response = await utils.lobb.createOne(
93
- "storage_fs",
92
+ const response = await utils.lobb.runAction(
93
+ "storage_create",
94
94
  { data: { path: location, ...foreignKeyObject } },
95
95
  file,
96
96
  );
@@ -133,10 +133,9 @@
133
133
  if (!entryId) {
134
134
  throw new Error("The id of the entry is (null) for some reason");
135
135
  }
136
- const response = await utils.lobb.deleteOne(
137
- "storage_fs",
138
- entryId,
139
- );
136
+ const response = await utils.lobb.runAction("storage_delete", {
137
+ id: String(entryId),
138
+ });
140
139
  const result = await response.json();
141
140
  if (result.status >= 400) {
142
141
  return;
@@ -16,7 +16,7 @@
16
16
  class="pointer-events-none flex flex-col items-center gap-2 min-w-28"
17
17
  >
18
18
  {#if entry.file_mime_type.startsWith("image/")}
19
- <div class="rounded-md w-24 h-24 bg-center bg-contain bg-no-repeat" style="background-image: url('{utils.lobb.lobbUrl}/api/collections/storage_fs/{entry.id}?action=view');"></div>
19
+ <div class="rounded-md w-24 h-24 bg-center bg-contain bg-no-repeat" style="background-image: url('{utils.lobb.lobbUrl}/api/actions/storage_read?id={entry.id}&action=view');"></div>
20
20
  {:else}
21
21
  <div
22
22
  class="pointer-events-none relative"
@@ -1,6 +1,5 @@
1
1
  import type { Extension } from "@lobb-js/core";
2
2
  import type { ExtensionConfig } from "./config/extensionConfigSchema.ts";
3
- export type { StorageFsCreateOne, StorageFsFindOne } from "./types.ts";
4
3
 
5
4
  import packageJson from "../../package.json" with { type: "json" };
6
5
  import { collections } from "./collections/index.ts";
@@ -8,6 +7,7 @@ import { meta } from "./meta.ts";
8
7
  import { migrations } from "./migrations.ts";
9
8
  import { openapi } from "./openapi.ts";
10
9
  import { getWorkflows } from "./workflows/index.ts";
10
+ import { getStorageActions } from "./workflows/actions.ts";
11
11
 
12
12
  export default function storage(extensionConfig: ExtensionConfig): Extension {
13
13
  return {
@@ -15,6 +15,7 @@ export default function storage(extensionConfig: ExtensionConfig): Extension {
15
15
  name: "storage",
16
16
  icon: "Folders",
17
17
  collections: (_lobb: any) => collections(extensionConfig),
18
+ actions: getStorageActions(extensionConfig),
18
19
  migrations: migrations,
19
20
  meta: meta,
20
21
  workflows: getWorkflows(extensionConfig),
@@ -1,59 +1,7 @@
1
- import type { Lobb, Migrations } from "@lobb-js/core";
2
-
3
- /**
4
- * Backfill `parent_id` for any row in storage_fs that still has it null.
5
- * Resolves the parent by walking the existing `path` string and matching
6
- * the (parent_path, name) of every directory record. Idempotent — running
7
- * it twice is safe; rows that already have parent_id are skipped.
8
- */
9
- export async function backfillStorageFsParentId(lobb: Lobb): Promise<void> {
10
- const rows = (await lobb.collectionService.findAll({
11
- collectionName: "storage_fs",
12
- params: { limit: 1000000 },
13
- })).data;
14
- if (rows.length === 0) return;
15
-
16
- // Index every directory by (path, name) so we can resolve any child's
17
- // parent in one lookup. This is the same key that resolveParentIdFromPath
18
- // walks at runtime, just precomputed.
19
- const dirByKey = new Map<string, number>();
20
- for (const r of rows as any[]) {
21
- if (r.type === "directory") {
22
- dirByKey.set(`${r.path}::${r.name}`, r.id);
23
- }
24
- }
25
-
26
- function parentIdFor(row: any): number | null {
27
- const path: string = row.path ?? "/";
28
- if (path === "/" || path === "") return null;
29
- const segments = path.split("/").filter(Boolean);
30
- if (segments.length === 0) return null;
31
- const lastName = segments[segments.length - 1];
32
- // No-trailing-slash convention: root is "/", deeper levels are
33
- // "/foo" or "/foo/bar". Older data may have either form; this
34
- // resolves only the normalized one. Loose-format rows get retried
35
- // on the next pass.
36
- const parentDirPath =
37
- segments.length > 1 ? "/" + segments.slice(0, -1).join("/") : "/";
38
- const key = `${parentDirPath}::${lastName}`;
39
- return dirByKey.get(key) ?? null;
40
- }
41
-
42
- for (const row of rows as any[]) {
43
- if (row.parent_id != null) continue;
44
- const pid = parentIdFor(row);
45
- if (pid != null) {
46
- await lobb.collectionStore.updateOne({
47
- collectionName: "storage_fs",
48
- id: row.id,
49
- data: { parent_id: pid },
50
- });
51
- }
52
- }
53
- }
1
+ import type { Migrations } from "@lobb-js/core";
54
2
 
55
3
  // No built-in migrations for the storage extension. The `parent_id` column
56
- // is set on every new write by the service; existing rows from environments
57
- // that predate parent_id can be reconciled by calling
58
- // `backfillStorageFsParentId` from a host-app migration when needed.
4
+ // is set on every new write by the service; no backfill ships with the
5
+ // package — host apps that need to reconcile legacy rows can write their
6
+ // own migration against `storage_fs`.
59
7
  export const migrations: Migrations = {};
@@ -1,7 +1,7 @@
1
1
  import type { Extension, ExtensionUtils } from "@lobb-js/studio";
2
2
 
3
3
  import FileExplorerPage from "./lib/components/pages/fileExplorerPage.svelte";
4
- import ForeignKeyComponent from "./lib/components/foreignKeyComponent.svelte";
4
+ import FilePickerField from "./lib/components/FilePickerField.svelte";
5
5
  import ChildrenFileExplorer from "./lib/components/childrenFileExplorer.svelte";
6
6
  import ExplorerNotSupported from "./lib/components/explorerNotSupported.svelte";
7
7
 
@@ -10,7 +10,7 @@ export default function extension(utils: ExtensionUtils): Extension {
10
10
  name: "storage",
11
11
  components: {
12
12
  "pages.file_manager": FileExplorerPage,
13
- "detailView.fields.foreignKey.storage_fs": ForeignKeyComponent,
13
+ "detailView.fields.foreignKey.storage_fs": FilePickerField,
14
14
  "detailView.create.subRecords.storage_fs": ExplorerNotSupported,
15
15
  "detailView.update.subRecords.storage_fs": ChildrenFileExplorer,
16
16
  "listView.entry.children.storage_fs": ChildrenFileExplorer,
@@ -0,0 +1,48 @@
1
+ <script lang="ts">
2
+ import type { ExtensionProps } from "@lobb-js/studio";
3
+ import FileExplorer, { type Entry } from "./fileExplorer.svelte";
4
+ import FileIcon from "./fileIcon.svelte";
5
+
6
+ interface Props extends ExtensionProps {
7
+ value: Entry;
8
+ header?: any;
9
+ isDisabled?: boolean;
10
+ errorMessages?: any;
11
+ }
12
+
13
+ let { utils, value = $bindable(), header, isDisabled = false, errorMessages = [], ...props }: Props = $props();
14
+
15
+ const FilePlus2 = $derived(utils.components.Icons.FilePlus2);
16
+ const Drawer = $derived(utils.components.Drawer);
17
+ const FieldWrapper = $derived(utils.components.FieldWrapper);
18
+
19
+ let openDrawer = $state(false);
20
+
21
+ async function onHideHandle() {
22
+ openDrawer = false;
23
+ }
24
+
25
+ async function handleOnFileSelect(entry: Entry) {
26
+ value = entry;
27
+ openDrawer = false;
28
+ }
29
+ </script>
30
+
31
+ <FieldWrapper span={2} {isDisabled} {errorMessages} {header}>
32
+ {#snippet children()}
33
+ <button onclick={() => openDrawer = true} class="flex flex-col items-center gap-2 w-full border p-4 rounded-md bg-muted">
34
+ {#if value}
35
+ <FileIcon entry={value} {utils} />
36
+ {:else}
37
+ <FilePlus2 class="text-muted-foreground" />
38
+ <div class="text-sm text-muted-foreground">Click to add an asset</div>
39
+ {/if}
40
+ </button>
41
+
42
+ {#if openDrawer}
43
+ <Drawer onHide={onHideHandle}>
44
+ <FileExplorer onFileSelect={handleOnFileSelect} utils={utils} {...props} />
45
+ </Drawer>
46
+ {/if}
47
+ {/snippet}
48
+ </FieldWrapper>
@@ -66,7 +66,7 @@
66
66
  contextMenuOpen = false;
67
67
  const newFolderName = prompt("Please enter a name for the new folder:");
68
68
  if (newFolderName) {
69
- const response = await utils.lobb.createOne("storage_fs", {
69
+ const response = await utils.lobb.runAction("storage_create", {
70
70
  data: { name: newFolderName, path: location, type: "directory", ...foreignKeyObject },
71
71
  });
72
72
  const result = await response.json();
@@ -89,8 +89,8 @@
89
89
 
90
90
  async function uploadFile(file: File) {
91
91
  if (file) {
92
- const response = await utils.lobb.createOne(
93
- "storage_fs",
92
+ const response = await utils.lobb.runAction(
93
+ "storage_create",
94
94
  { data: { path: location, ...foreignKeyObject } },
95
95
  file,
96
96
  );
@@ -133,10 +133,9 @@
133
133
  if (!entryId) {
134
134
  throw new Error("The id of the entry is (null) for some reason");
135
135
  }
136
- const response = await utils.lobb.deleteOne(
137
- "storage_fs",
138
- entryId,
139
- );
136
+ const response = await utils.lobb.runAction("storage_delete", {
137
+ id: String(entryId),
138
+ });
140
139
  const result = await response.json();
141
140
  if (result.status >= 400) {
142
141
  return;
@@ -16,7 +16,7 @@
16
16
  class="pointer-events-none flex flex-col items-center gap-2 min-w-28"
17
17
  >
18
18
  {#if entry.file_mime_type.startsWith("image/")}
19
- <div class="rounded-md w-24 h-24 bg-center bg-contain bg-no-repeat" style="background-image: url('{utils.lobb.lobbUrl}/api/collections/storage_fs/{entry.id}?action=view');"></div>
19
+ <div class="rounded-md w-24 h-24 bg-center bg-contain bg-no-repeat" style="background-image: url('{utils.lobb.lobbUrl}/api/actions/storage_read?id={entry.id}&action=view');"></div>
20
20
  {:else}
21
21
  <div
22
22
  class="pointer-events-none relative"
@@ -1,22 +1,6 @@
1
- import type { Context } from "hono";
2
1
  import type { Lobb } from "@lobb-js/core";
3
2
  import { LobbError } from "@lobb-js/core";
4
3
 
5
- export async function getFileFromRequestBody(
6
- c: Context,
7
- ): Promise<File | undefined> {
8
- const contentType = c.req.header("Content-Type");
9
- if (contentType?.startsWith("multipart/form-data")) {
10
- const formData = await c.req.formData();
11
- const entry = formData.get("file");
12
- if (entry instanceof File) {
13
- return entry;
14
- }
15
- }
16
- // return undefined if no file or wrong content type
17
- return undefined;
18
- }
19
-
20
4
  // ── parent_id / path helpers ──────────────────────────────────────────────
21
5
  //
22
6
  // The storage_fs table uses parent_id as the source of truth for the
@@ -0,0 +1,87 @@
1
+ import type { Workflow } from "@lobb-js/core";
2
+ import type { Context } from "hono";
3
+
4
+ // The two storage operations that need the HTTP layer (a multipart file, or a
5
+ // binary response) are served by adapting the *action* endpoints rather than
6
+ // touching the collection CRUD routes (which stay clean and predictable).
7
+ //
8
+ // POST /api/actions/storage_create (multipart) → upload a file
9
+ // GET /api/actions/storage_read?action=view|download → stream file bytes
10
+ //
11
+ // Everything else flows through the default JSON action handling.
12
+
13
+ export function getActionControllerWorkflows(): Workflow[] {
14
+ return [
15
+ {
16
+ name: "storageActionController",
17
+ eventName: "core.actions.controller.override",
18
+ handler: async (input, ctx) => {
19
+ const name = input.name as string;
20
+ const context = input.context as Context;
21
+
22
+ // Multipart upload → pull the File off the request and pass it to the action.
23
+ if (name === "storage_create") {
24
+ const contentType = context.req.header("Content-Type");
25
+ if (!contentType?.startsWith("multipart/form-data")) return; // JSON create → default flow
26
+
27
+ const force = context.req.query("force") === "true";
28
+ const body = await context.req.parseBody({ all: true });
29
+ const fileEntry = body["file"];
30
+
31
+ if (!fileEntry) {
32
+ throw new ctx.LobbError({ code: "BAD_REQUEST", message: "No files were provided." });
33
+ }
34
+ if (Array.isArray(fileEntry)) {
35
+ throw new ctx.LobbError({ code: "BAD_REQUEST", message: "Only one file can be uploaded at a time." });
36
+ }
37
+ if (!(fileEntry instanceof File)) {
38
+ throw new ctx.LobbError({ code: "BAD_REQUEST", message: "The file shouldnt be a string" });
39
+ }
40
+
41
+ const extraData: Record<string, string> = {};
42
+ for (const [key, val] of Object.entries(body)) {
43
+ if (key !== "file" && key !== "payload" && typeof val === "string") {
44
+ extraData[key] = val;
45
+ }
46
+ }
47
+
48
+ const result = await ctx.lobb.runAction("storage_create", {
49
+ file: fileEntry,
50
+ force,
51
+ data: extraData,
52
+ });
53
+
54
+ return context.json(result, 201);
55
+ }
56
+
57
+ // GET file view/download → stream raw bytes (browser-embeddable).
58
+ if (name === "storage_read") {
59
+ const action = context.req.query("action");
60
+ if (action !== "view" && action !== "download") return; // non-binary read → default flow
61
+
62
+ const id = context.req.query("id");
63
+ if (!id) {
64
+ throw new ctx.LobbError({ code: "BAD_REQUEST", message: "The (id) query param is required." });
65
+ }
66
+
67
+ const result = await ctx.lobb.runAction("storage_read", { id, readFile: true });
68
+
69
+ if (!result.data) {
70
+ throw new ctx.LobbError({ code: "NOT_FOUND", message: "The record is not found" });
71
+ }
72
+
73
+ const headers: Record<string, string> = {
74
+ "Content-Type": result.data.file_mime_type,
75
+ };
76
+ if (action === "download") {
77
+ headers["Content-Disposition"] = `attachment; filename="${result.data.name}"`;
78
+ }
79
+
80
+ return new Response(result.file.buffer as ArrayBuffer, { status: 200, headers });
81
+ }
82
+
83
+ return;
84
+ },
85
+ },
86
+ ];
87
+ }
@@ -1,4 +1,4 @@
1
- import type { Workflow } from "@lobb-js/core";
1
+ import type { ActionsConfig } from "@lobb-js/core";
2
2
  import { LobbError } from "@lobb-js/core";
3
3
  import type { ExtensionConfig } from "../config/extensionConfigSchema.ts";
4
4
  import { initStorageAdapter } from "../adapters/index.ts";
@@ -9,43 +9,20 @@ import {
9
9
  resolveParentIdFromPath,
10
10
  } from "../utils.ts";
11
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.
12
+ // The storage_fs hierarchy invariant `path === parent.path + parent.name` —
13
+ // is maintained by these actions on every write. `parent_id` is the source of
14
+ // truth; `path` is a derived cache for fast subtree queries and breadcrumbs.
15
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.
20
-
21
- export function getServicesWorkflows(extensionConfig: ExtensionConfig): Workflow[] {
22
- return [
23
- {
24
- name: "storageFindOneService",
25
- eventName: "core.service.findOne.override",
26
- handler: async (input, ctx) => {
27
- if (input.collectionName === "storage_fs") {
28
- const result = await ctx.lobb.collectionStore.findOne({
29
- collectionName: "storage_fs",
30
- id: input.id,
31
- });
32
-
33
- if (result.data?.type === "file" && input.readFile) {
34
- const adapter = initStorageAdapter(extensionConfig);
35
- const file = await adapter.readFile(result.data.id);
36
- return { ...result, file: new Uint8Array(file) };
37
- }
38
-
39
- return result;
40
- }
41
- },
42
- },
43
- {
44
- name: "storageCreateOneServiceOverride",
45
- eventName: "core.service.createOne.override",
46
- handler: async (input, ctx) => {
47
- if (input.collectionName !== "storage_fs") return;
16
+ // Complex/multi-step operations (upload, create, rename/move, cascade delete)
17
+ // live here as actions. Reads stay free on the collection routes; only file
18
+ // view/download and multipart upload are adapted from the collection route into
19
+ // `storage_read` / `storage_create` by the controller workflows.
48
20
 
21
+ export function getStorageActions(extensionConfig: ExtensionConfig): ActionsConfig {
22
+ return {
23
+ // Create a file (when `file` is present) or a directory / metadata record.
24
+ storage_create: {
25
+ handler: async (input: any, ctx) => {
49
26
  const raw = input.data ?? {};
50
27
  const explicitParentId = raw.parent_id ?? null;
51
28
  const pathInput: string = raw.path ?? "/";
@@ -89,8 +66,7 @@ export function getServicesWorkflows(extensionConfig: ExtensionConfig): Workflow
89
66
  return result;
90
67
  }
91
68
 
92
- // Non-file writes (directories or pure metadata records) — let the
93
- // generic store handle it after we stamp parent_id + path.
69
+ // Non-file writes (directories or pure metadata records).
94
70
  return await ctx.lobb.collectionStore.createOne({
95
71
  collectionName: "storage_fs",
96
72
  data: {
@@ -105,99 +81,28 @@ export function getServicesWorkflows(extensionConfig: ExtensionConfig): Workflow
105
81
  });
106
82
  },
107
83
  },
108
- {
109
- name: "storageDeleteOneService",
110
- eventName: "core.service.deleteOne.override",
111
- handler: async (input, ctx) => {
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({
120
- collectionName: "storage_fs",
121
- id: input.id,
122
- });
123
- }
124
-
125
- const adapter = initStorageAdapter(extensionConfig);
126
-
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);
138
- }
139
- await ctx.lobb.collectionStore.deleteOne({
140
- collectionName: "storage_fs",
141
- id: String(d.id),
142
- });
143
- }
144
- }
145
84
 
146
- return ctx.lobb.collectionStore.deleteOne({
85
+ // Read a record, optionally with the file bytes (for view/download).
86
+ storage_read: {
87
+ handler: async (input: any, ctx) => {
88
+ const result = await ctx.lobb.collectionStore.findOne({
147
89
  collectionName: "storage_fs",
148
90
  id: input.id,
149
91
  });
150
- },
151
- },
152
- {
153
- name: "storageDeleteManyService",
154
- eventName: "core.service.deleteMany.override",
155
- handler: async (input, ctx) => {
156
- if (input.collectionName !== "storage_fs") return;
157
-
158
- const matched = (await ctx.lobb.collectionStore.findAll({
159
- collectionName: "storage_fs",
160
- params: { filter: input.filter },
161
- })).data;
162
92
 
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);
178
- }
179
- }
180
- }
181
-
182
- for (const row of [...ordered].reverse()) {
183
- if (row.type === "file") await adapter.removeFile(row.id);
184
- await ctx.lobb.collectionStore.deleteOne({
185
- collectionName: "storage_fs",
186
- id: String(row.id),
187
- });
93
+ if (result.data?.type === "file" && input.readFile) {
94
+ const adapter = initStorageAdapter(extensionConfig);
95
+ const file = await adapter.readFile(result.data.id);
96
+ return { ...result, file: new Uint8Array(file) };
188
97
  }
189
98
 
190
- // affectedCount reflects what the caller asked to delete (the
191
- // matched rows). Cascaded descendants are an implementation detail.
192
- return { affectedCount: matched.length };
99
+ return result;
193
100
  },
194
101
  },
195
- {
196
- name: "storageUpdateOneService",
197
- eventName: "core.service.updateOne.override",
198
- handler: async (input, ctx) => {
199
- if (input.collectionName !== "storage_fs") return;
200
102
 
103
+ // Rename / move / update metadata, cascading `path` to all descendants.
104
+ storage_update: {
105
+ handler: async (input: any, ctx) => {
201
106
  const current = (await ctx.lobb.collectionStore.findOne({
202
107
  collectionName: "storage_fs",
203
108
  id: input.id,
@@ -212,17 +117,13 @@ export function getServicesWorkflows(extensionConfig: ExtensionConfig): Workflow
212
117
 
213
118
  const data: any = { ...input.data };
214
119
 
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.
120
+ // Translate any path-based reparenting into a parent_id update.
218
121
  if (data.path != null && data.parent_id == null) {
219
122
  data.parent_id = await resolveParentIdFromPath(data.path, ctx.lobb);
220
123
  }
221
124
  // The path field is derived — never let a caller overwrite it raw.
222
125
  delete data.path;
223
126
 
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
127
  const nameChanged = data.name != null && data.name !== current.name;
227
128
  const parentChanged =
228
129
  Object.prototype.hasOwnProperty.call(data, "parent_id") &&
@@ -232,8 +133,7 @@ export function getServicesWorkflows(extensionConfig: ExtensionConfig): Workflow
232
133
  const newParentId = parentChanged ? (data.parent_id ?? null) : current.parent_id;
233
134
  const newName = nameChanged ? data.name : current.name;
234
135
 
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.
136
+ // Cycle prevention.
237
137
  if (parentChanged && newParentId != null) {
238
138
  if (Number(newParentId) === Number(input.id)) {
239
139
  throw new LobbError({
@@ -266,15 +166,11 @@ export function getServicesWorkflows(extensionConfig: ExtensionConfig): Workflow
266
166
  data: { ...data, path: newSelfPath },
267
167
  });
268
168
 
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.
169
+ // Cascade descendant paths.
272
170
  if (current.type === "directory") {
273
171
  const descendants = await findDescendants(Number(input.id), ctx.lobb);
274
172
  const byId = new Map<number, any>();
275
173
  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
174
  const newDirFullPath = newSelfPath === "/" ? "/" + newName : newSelfPath + "/" + newName;
279
175
  const pathOf = (id: number | null): string => {
280
176
  if (id == null) return "/";
@@ -303,5 +199,79 @@ export function getServicesWorkflows(extensionConfig: ExtensionConfig): Workflow
303
199
  });
304
200
  },
305
201
  },
306
- ];
202
+
203
+ // Delete a file or directory, cascading leaf-first and removing files from disk.
204
+ storage_delete: {
205
+ handler: async (input: any, ctx) => {
206
+ const entry = (await ctx.lobb.collectionStore.findOne({
207
+ collectionName: "storage_fs",
208
+ id: input.id,
209
+ })).data;
210
+ if (!entry) {
211
+ return ctx.lobb.collectionStore.deleteOne({
212
+ collectionName: "storage_fs",
213
+ id: input.id,
214
+ });
215
+ }
216
+
217
+ const adapter = initStorageAdapter(extensionConfig);
218
+
219
+ if (entry.type === "file") {
220
+ await adapter.removeFile(entry.id);
221
+ } else if (entry.type === "directory") {
222
+ const descendants = await findDescendants(entry.id, ctx.lobb);
223
+ for (const d of [...descendants].reverse()) {
224
+ if (d.type === "file") {
225
+ await adapter.removeFile(d.id);
226
+ }
227
+ await ctx.lobb.collectionStore.deleteOne({
228
+ collectionName: "storage_fs",
229
+ id: String(d.id),
230
+ });
231
+ }
232
+ }
233
+
234
+ return ctx.lobb.collectionStore.deleteOne({
235
+ collectionName: "storage_fs",
236
+ id: input.id,
237
+ });
238
+ },
239
+ },
240
+
241
+ // Delete every row matching `filter`, cascading descendants, removing files.
242
+ storage_delete_many: {
243
+ handler: async (input: any, ctx) => {
244
+ const matched = (await ctx.lobb.collectionStore.findAll({
245
+ collectionName: "storage_fs",
246
+ params: { filter: input.filter },
247
+ })).data;
248
+
249
+ const adapter = initStorageAdapter(extensionConfig);
250
+ const ordered: any[] = [];
251
+ const seen = new Set<number>();
252
+ for (const entry of matched) {
253
+ if (seen.has(entry.id)) continue;
254
+ seen.add(entry.id);
255
+ ordered.push(entry);
256
+ if (entry.type === "directory") {
257
+ for (const d of await findDescendants(entry.id, ctx.lobb)) {
258
+ if (seen.has(d.id)) continue;
259
+ seen.add(d.id);
260
+ ordered.push(d);
261
+ }
262
+ }
263
+ }
264
+
265
+ for (const row of [...ordered].reverse()) {
266
+ if (row.type === "file") await adapter.removeFile(row.id);
267
+ await ctx.lobb.collectionStore.deleteOne({
268
+ collectionName: "storage_fs",
269
+ id: String(row.id),
270
+ });
271
+ }
272
+
273
+ return { affectedCount: matched.length };
274
+ },
275
+ },
276
+ };
307
277
  }
@@ -1,6 +1,5 @@
1
1
  import type { ExtensionConfig } from "../config/extensionConfigSchema.ts";
2
- import { getControllersWorkflows } from "./controllers.ts";
3
- import { getServicesWorkflows } from "./services.ts";
2
+ import { getActionControllerWorkflows } from "./actionController.ts";
4
3
  import { init } from "../init.ts";
5
4
 
6
5
  export function getWorkflows(extensionConfig: ExtensionConfig) {
@@ -12,7 +11,6 @@ export function getWorkflows(extensionConfig: ExtensionConfig) {
12
11
  await init(extensionConfig);
13
12
  },
14
13
  },
15
- ...getServicesWorkflows(extensionConfig),
16
- ...getControllersWorkflows(),
14
+ ...getActionControllerWorkflows(),
17
15
  ];
18
16
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lobb-js/lobb-ext-storage",
3
- "version": "0.15.1",
3
+ "version": "0.16.0",
4
4
  "license": "UNLICENSED",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -32,14 +32,14 @@
32
32
  "package": "svelte-package --input extensions/storage/studio"
33
33
  },
34
34
  "dependencies": {
35
- "@lobb-js/core": "^0.40.0",
35
+ "@lobb-js/core": "^0.41.0",
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.46.0",
42
+ "@lobb-js/studio": "^0.49.0",
43
43
  "@lucide/svelte": "^0.563.1",
44
44
  "@sveltejs/adapter-node": "^5.5.4",
45
45
  "@sveltejs/kit": "^2.60.1",
@@ -1,44 +0,0 @@
1
- <script lang="ts">
2
- import type { ExtensionProps } from "@lobb-js/studio";
3
- import FileExplorer, { type Entry } from "./fileExplorer.svelte";
4
- import FileIcon from "./fileIcon.svelte";
5
-
6
- interface Props extends ExtensionProps {
7
- value: Entry;
8
- }
9
-
10
- let { utils, value = $bindable(), ...props }: Props = $props();
11
-
12
- const { FilePlus2 } = utils.components.Icons;
13
- const { Drawer } = utils.components;
14
-
15
- let openDrawer = $state(false);
16
-
17
- function handleClick() {
18
- openDrawer = true;
19
- }
20
-
21
- async function onHideHandle() {
22
- openDrawer = false;
23
- }
24
-
25
- async function handleOnFileSelect(entry: Entry) {
26
- value = entry;
27
- openDrawer = false;
28
- }
29
- </script>
30
-
31
- <button onclick={handleClick} class="flex flex-col items-center gap-2 w-full border p-4 rounded-md bg-muted">
32
- {#if value}
33
- <FileIcon entry={value} {utils} />
34
- {:else}
35
- <FilePlus2 class="text-muted-foreground" />
36
- <div class="text-sm text-muted-foreground">Click to add an asset</div>
37
- {/if}
38
- </button>
39
-
40
- {#if openDrawer}
41
- <Drawer onHide={onHideHandle}>
42
- <FileExplorer onFileSelect={handleOnFileSelect} utils={utils} {...props} />
43
- </Drawer>
44
- {/if}
@@ -1,14 +0,0 @@
1
- import { SvelteComponentTyped } from "svelte";
2
- declare const __propDef: {
3
- props: Record<string, never>;
4
- events: {
5
- [evt: string]: CustomEvent<any>;
6
- };
7
- slots: {};
8
- };
9
- export type ForeignKeyComponentProps = typeof __propDef.props;
10
- export type ForeignKeyComponentEvents = typeof __propDef.events;
11
- export type ForeignKeyComponentSlots = typeof __propDef.slots;
12
- export default class ForeignKeyComponent extends SvelteComponentTyped<ForeignKeyComponentProps, ForeignKeyComponentEvents, ForeignKeyComponentSlots> {
13
- }
14
- export {};
@@ -1,44 +0,0 @@
1
- <script lang="ts">
2
- import type { ExtensionProps } from "@lobb-js/studio";
3
- import FileExplorer, { type Entry } from "./fileExplorer.svelte";
4
- import FileIcon from "./fileIcon.svelte";
5
-
6
- interface Props extends ExtensionProps {
7
- value: Entry;
8
- }
9
-
10
- let { utils, value = $bindable(), ...props }: Props = $props();
11
-
12
- const { FilePlus2 } = utils.components.Icons;
13
- const { Drawer } = utils.components;
14
-
15
- let openDrawer = $state(false);
16
-
17
- function handleClick() {
18
- openDrawer = true;
19
- }
20
-
21
- async function onHideHandle() {
22
- openDrawer = false;
23
- }
24
-
25
- async function handleOnFileSelect(entry: Entry) {
26
- value = entry;
27
- openDrawer = false;
28
- }
29
- </script>
30
-
31
- <button onclick={handleClick} class="flex flex-col items-center gap-2 w-full border p-4 rounded-md bg-muted">
32
- {#if value}
33
- <FileIcon entry={value} {utils} />
34
- {:else}
35
- <FilePlus2 class="text-muted-foreground" />
36
- <div class="text-sm text-muted-foreground">Click to add an asset</div>
37
- {/if}
38
- </button>
39
-
40
- {#if openDrawer}
41
- <Drawer onHide={onHideHandle}>
42
- <FileExplorer onFileSelect={handleOnFileSelect} utils={utils} {...props} />
43
- </Drawer>
44
- {/if}
@@ -1,11 +0,0 @@
1
- import type { ExposedServiceOutput, ServiceOp } from "@lobb-js/core";
2
-
3
- export type StorageFsCreateOne = ServiceOp<
4
- { file: File; data?: { path?: string; parent_id?: number | null }; force?: boolean },
5
- ExposedServiceOutput
6
- >;
7
-
8
- export type StorageFsFindOne = ServiceOp<
9
- { id: string; readFile: boolean },
10
- { data: { file_mime_type: string; name: string } | null; file: Uint8Array }
11
- >;
@@ -1,103 +0,0 @@
1
- import type { Lobb, Workflow } from "@lobb-js/core";
2
- import type { Context } from "hono";
3
- import type { StorageFsCreateOne, StorageFsFindOne } from "../types.ts";
4
-
5
- export function getControllersWorkflows(): Workflow[] {
6
- return [
7
- {
8
- name: "storagePreCreateController",
9
- eventName: "core.controllers.createOne.override",
10
- handler: async (input, ctx) => {
11
- if (input.collectionName === "storage_fs") {
12
- const context = input.context as Context;
13
- const lobb = context.get("lobb") as Lobb;
14
- const contentType = context.req.header("Content-Type");
15
-
16
- if (contentType?.startsWith("multipart/form-data")) {
17
- const body = await context.req.parseBody({ all: true });
18
- const fileEntry = body["file"];
19
-
20
- if (!fileEntry) {
21
- throw new ctx.LobbError({
22
- code: "BAD_REQUEST",
23
- message: "No files were provided.",
24
- });
25
- }
26
-
27
- if (Array.isArray(fileEntry)) {
28
- throw new ctx.LobbError({
29
- code: "BAD_REQUEST",
30
- message: "Only one file can be uploaded at a time.",
31
- });
32
- }
33
-
34
- if (!(fileEntry instanceof File)) {
35
- throw new ctx.LobbError({
36
- code: "BAD_REQUEST",
37
- message: "The file shouldnt be a string",
38
- });
39
- }
40
-
41
- const force = context.req.query("force") === "true";
42
-
43
- const extraData: Record<string, string> = {};
44
- for (const [key, val] of Object.entries(body)) {
45
- if (key !== "file" && key !== "payload" && typeof val === "string") {
46
- extraData[key] = val;
47
- }
48
- }
49
-
50
- const result = await ctx.lobb.collectionService.createOne<StorageFsCreateOne>({
51
- collectionName: "storage_fs",
52
- file: fileEntry,
53
- force,
54
- data: extraData,
55
- });
56
-
57
- return context.json(result, 201);
58
- }
59
- }
60
- },
61
- },
62
- {
63
- name: "storageFindOneController",
64
- eventName: "core.controllers.findOne.override",
65
- handler: async (input, ctx) => {
66
- if (input.collectionName === "storage_fs") {
67
- const context = input.context as Context;
68
- const action = context.req.query("action");
69
-
70
- if (action === "view" || action === "download") {
71
- const result = await ctx.lobb.collectionService.findOne<StorageFsFindOne>({
72
- collectionName: "storage_fs",
73
- id: input.id,
74
- readFile: true,
75
- });
76
-
77
- if (!result.data) {
78
- throw new ctx.LobbError({
79
- code: "NOT_FOUND",
80
- message: "The record is not found",
81
- });
82
- }
83
-
84
- const headers: Record<string, string> = {
85
- "Content-Type": result.data.file_mime_type,
86
- };
87
- if (action === "download") {
88
- headers["Content-Disposition"] =
89
- `attachment; filename="${result.data.name}"`;
90
- }
91
-
92
- return new Response(result.file.buffer as ArrayBuffer, { status: 200, headers });
93
- } else if (action) {
94
- throw new ctx.LobbError({
95
- code: "BAD_REQUEST",
96
- message: `The passed (${action}) action query param is not supported`,
97
- });
98
- }
99
- }
100
- },
101
- },
102
- ];
103
- }