@vryzel/file-next 0.4.0 → 0.4.2

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/README.md CHANGED
@@ -1,12 +1,20 @@
1
1
  # @vryzel/file-next
2
2
 
3
- S3/R2 filesystem for Next.js: object adapter, metadata index, server actions, write-through.
3
+ S3/R2 filesystem for Next.js. Bytes in your bucket. The folder tree in **your** SQLite or Postgres. Tenant comes from the server, never from the client.
4
4
 
5
5
  ```bash
6
6
  pnpm add @vryzel/file-next
7
+ # optional, depending on the store:
8
+ pnpm add better-sqlite3 # SQLite
9
+ pnpm add pg # Postgres
7
10
  ```
8
11
 
12
+ Peer deps `better-sqlite3` and `pg` are optional. Install only the one you use.
13
+
14
+ ## Quick path
15
+
9
16
  ```ts
17
+ // app/lib/file-next.ts
10
18
  import {
11
19
  asTenantId,
12
20
  asUserId,
@@ -29,6 +37,7 @@ const fs = createFileSystem(
29
37
  },
30
38
  { store },
31
39
  );
40
+
32
41
  export const actions = createServerActions({
33
42
  store,
34
43
  fs,
@@ -40,6 +49,177 @@ export const actions = createServerActions({
40
49
  });
41
50
  ```
42
51
 
43
- Tenant never comes from the client. Replace `getAuth` with your session.
52
+ ```ts
53
+ // app/lib/actions.ts
54
+ "use server";
55
+ import { actions } from "./file-next";
56
+
57
+ export const listFiles = (input: { parentId: string | null; cursor?: string; limit?: number }) =>
58
+ actions.listFiles(input);
59
+ ```
60
+
61
+ Replace `getAuth` with Clerk / Auth.js / your session. Import `@vryzel/file-next/server` only from server modules.
62
+
63
+ ## Use cases
64
+
65
+ ### 1. SQLite (one process)
66
+
67
+ Default for a single Next.js server.
68
+
69
+ ```ts
70
+ import { createSqliteStore } from "@vryzel/file-next";
71
+ const store = createSqliteStore({ path: ".data/metadata.db" });
72
+ ```
73
+
74
+ Schema is created on first call. Path is relative to `process.cwd()`.
75
+
76
+ ### 2. Postgres (multi-instance + RLS)
77
+
78
+ ```ts
79
+ import { createPostgresStore } from "@vryzel/file-next";
80
+ const store = createPostgresStore({
81
+ connectionString: process.env.DATABASE_URL!,
82
+ });
83
+ ```
84
+
85
+ Install `pg`. Tenant isolation uses `SET LOCAL app.current_tenant` + row-level security.
86
+
87
+ ### 3. In-memory (tests / Storybook)
88
+
89
+ ```ts
90
+ import { createMemoryStore, createMemoryFileSystem } from "@vryzel/file-next";
91
+
92
+ const store = createMemoryStore();
93
+ const fs = createMemoryFileSystem({ store });
94
+ ```
95
+
96
+ No AWS. Data dies with the process.
97
+
98
+ ### 4. Cloudflare R2
99
+
100
+ ```ts
101
+ const fs = createFileSystem(
102
+ {
103
+ provider: "r2",
104
+ bucket: process.env.FILE_NEXT_BUCKET!,
105
+ endpoint: process.env.FILE_NEXT_ENDPOINT!, // https://<accountid>.r2.cloudflarestorage.com
106
+ credentials: {
107
+ accessKeyId: process.env.FILE_NEXT_ACCESS_KEY_ID!,
108
+ secretAccessKey: process.env.FILE_NEXT_SECRET_ACCESS_KEY!,
109
+ },
110
+ },
111
+ { store },
112
+ );
113
+ ```
114
+
115
+ R2 is S3-compatible. Set bucket CORS for `PUT`/`GET` from your origin.
116
+
117
+ ### 5. Env singleton
118
+
119
+ ```ts
120
+ import { getFileSystem } from "@vryzel/file-next";
121
+ const fs = getFileSystem(); // memoized for the process
122
+ ```
123
+
124
+ Reads:
125
+
126
+ | Var | Required |
127
+ |---|---|
128
+ | `FILE_NEXT_PROVIDER` | `s3` or `r2` |
129
+ | `FILE_NEXT_BUCKET` | yes |
130
+ | `FILE_NEXT_REGION` | S3 |
131
+ | `FILE_NEXT_ENDPOINT` | R2 |
132
+ | `FILE_NEXT_ACCESS_KEY_ID` | yes |
133
+ | `FILE_NEXT_SECRET_ACCESS_KEY` | yes |
134
+ | `FILE_NEXT_FORCE_PATH_STYLE` | optional |
135
+
136
+ Missing config throws `FileSystemError` (not retryable).
137
+
138
+ ### 6. Multi-tenant keys
139
+
140
+ ```ts
141
+ const scoped = fs.forTenant("acme");
142
+ // object keys are prefixed t/acme/{nodeId} unless prefixTenantKeys: false
143
+ ```
144
+
145
+ Every store method also takes `tenantId`. Do not take tenant from the request body.
146
+
147
+ ### 7. Server actions
148
+
149
+ `createServerActions` returns:
150
+
151
+ `listFiles`, `searchFiles`, `listTrash`, `createFolder`, `deleteFile`, `moveFile`, `copyFile`, `setMetadata`, `prepareUpload`, `confirmUpload`, `restoreNode`, `createShare`, `resolveShare`, `revokeShare`.
152
+
153
+ All return `Result<T, FileSystemError>`: `{ ok: true, value }` or `{ ok: false, error }`.
154
+
155
+ ```ts
156
+ const listed = await actions.listFiles({ parentId: null, limit: 50 });
157
+ if (!listed.ok) throw listed.error;
158
+ ```
159
+
160
+ `listFiles` / `listTrash` accept `cursor` + `limit`.
161
+
162
+ ### 8. Route handlers (upload / download)
163
+
164
+ ```ts
165
+ // app/api/upload/route.ts
166
+ import { createUploadRouteHandler } from "@vryzel/file-next/server";
167
+ import { getFileSystem } from "@vryzel/file-next";
168
+
169
+ export const PUT = createUploadRouteHandler({
170
+ fs: getFileSystem(),
171
+ maxBytes: 25 * 1024 * 1024,
172
+ allowedContentTypes: ["image/*", "application/pdf"],
173
+ });
174
+ ```
175
+
176
+ Many apps skip this and PUT to a signed URL / a thin `writeThroughFile` route (see the test app). Use whichever matches your upload flow.
177
+
178
+ ### 9. Write-through (object first, then the tree)
179
+
180
+ ```ts
181
+ import { createWriteThrough } from "@vryzel/file-next/sync";
182
+
183
+ const writeThrough = createWriteThrough(fs, store);
184
+ await writeThrough.writeThroughFile({
185
+ tenantId,
186
+ parentId: null,
187
+ name: "photo.png",
188
+ body,
189
+ contentType: "image/png",
190
+ ownerId,
191
+ });
192
+ ```
193
+
194
+ If the metadata insert fails after the object is written, the key lands in `pending_orphans`. `reconcile()` drains them.
195
+
196
+ ### 10. Result + errors
197
+
198
+ ```ts
199
+ import { ok, err, unwrap, type Result } from "@vryzel/file-next";
200
+ import { FileSystemError } from "@vryzel/file-next/errors";
201
+
202
+ if (!result.ok) {
203
+ if (result.error.code === "Conflict") { /* duplicate name */ }
204
+ if (result.error.retryable) { /* backoff */ }
205
+ }
206
+ ```
207
+
208
+ `@vryzel/file-next/errors` is the client-safe entry (no Node builtins).
209
+
210
+ ## Imports
211
+
212
+ | Entry | Use from |
213
+ |---|---|
214
+ | `@vryzel/file-next` | server or shared types |
215
+ | `@vryzel/file-next/server` | Server Actions / Route Handlers only (`server-only`) |
216
+ | `@vryzel/file-next/sync` | server |
217
+ | `@vryzel/file-next/errors` | client or server |
218
+
219
+ ## Not this package
220
+
221
+ - React hooks → `@vryzel/file-next-headless`
222
+ - Ready-made explorer → `@vryzel/file-next-ui`
223
+ - CLI → `@vryzel/file-next-cli`
44
224
 
45
- Public on npmjs. See the repository README.
225
+ Bucket CORS and IAM: [provider-setup](https://github.com/Vryzel/file-next/blob/main/docs/provider-setup.md), [security](https://github.com/Vryzel/file-next/blob/main/docs/security.md).
@@ -724,6 +724,17 @@ interface MetadataStore {
724
724
  * tombstones the entire subtree in one call.
725
725
  */
726
726
  deleteNode(input: DeleteNodeInput): Promise<Result<void, FileSystemError>>;
727
+ /**
728
+ * Hard-delete a trashed node and its trashed descendants.
729
+ * Live nodes return NotFound. Returns S3 keys of files removed
730
+ * so the caller can delete objects.
731
+ */
732
+ purgeNode(input: {
733
+ tenantId: TenantId;
734
+ id: string;
735
+ }): Promise<Result<{
736
+ s3Keys: ReadonlyArray<string>;
737
+ }, FileSystemError>>;
727
738
  /** Update user metadata (merge or replace). */
728
739
  updateMetadata(input: UpdateMetadataInput): Promise<Result<FileNode, FileSystemError>>;
729
740
  /** Case-insensitive name search within an optional folder scope. */
@@ -724,6 +724,17 @@ interface MetadataStore {
724
724
  * tombstones the entire subtree in one call.
725
725
  */
726
726
  deleteNode(input: DeleteNodeInput): Promise<Result<void, FileSystemError>>;
727
+ /**
728
+ * Hard-delete a trashed node and its trashed descendants.
729
+ * Live nodes return NotFound. Returns S3 keys of files removed
730
+ * so the caller can delete objects.
731
+ */
732
+ purgeNode(input: {
733
+ tenantId: TenantId;
734
+ id: string;
735
+ }): Promise<Result<{
736
+ s3Keys: ReadonlyArray<string>;
737
+ }, FileSystemError>>;
727
738
  /** Update user metadata (merge or replace). */
728
739
  updateMetadata(input: UpdateMetadataInput): Promise<Result<FileNode, FileSystemError>>;
729
740
  /** Case-insensitive name search within an optional folder scope. */
package/dist/index.cjs CHANGED
@@ -3976,6 +3976,36 @@ var createMemoryStore = () => {
3976
3976
  }
3977
3977
  return ok(void 0);
3978
3978
  },
3979
+ async purgeNode(input) {
3980
+ const n = nodes.get(input.id);
3981
+ if (!n || n.tenantId !== input.tenantId || n.deletedAt === null) {
3982
+ return err(
3983
+ new FileSystemError({
3984
+ code: "NotFound",
3985
+ message: `Trashed node ${input.id} not found`,
3986
+ retryable: false
3987
+ })
3988
+ );
3989
+ }
3990
+ const prefix = `${n.path}/`;
3991
+ const s3Keys = [];
3992
+ const ids = [];
3993
+ for (const node of nodes.values()) {
3994
+ if (node.tenantId !== input.tenantId || node.deletedAt === null) continue;
3995
+ if (node.id !== n.id && !node.path.startsWith(prefix)) continue;
3996
+ ids.push(node.id);
3997
+ if (node.kind === "file" && node.s3Key) s3Keys.push(node.s3Key);
3998
+ }
3999
+ for (const id of ids) {
4000
+ const node = nodes.get(id);
4001
+ if (node) removeFromChildrenIndex(node);
4002
+ nodes.delete(id);
4003
+ }
4004
+ for (const [token, share] of shares) {
4005
+ if (ids.includes(share.nodeId)) shares.delete(token);
4006
+ }
4007
+ return ok({ s3Keys });
4008
+ },
3979
4009
  async updateMetadata(input) {
3980
4010
  const n = nodes.get(input.id);
3981
4011
  if (!n || n.tenantId !== input.tenantId || n.deletedAt !== null) {
@@ -4206,7 +4236,7 @@ var rowToFileNode = (row) => ({
4206
4236
  updatedAt: new Date(row.updated_at),
4207
4237
  deletedAt: row.deleted_at === null ? null : new Date(row.deleted_at)
4208
4238
  });
4209
- var SCHEMA_VERSION = 3;
4239
+ var SCHEMA_VERSION = 4;
4210
4240
  var MIGRATION_SQL = `
4211
4241
  CREATE TABLE IF NOT EXISTS schema_meta (
4212
4242
  key TEXT PRIMARY KEY,
@@ -4290,7 +4320,8 @@ WHEN new.deleted_at IS NULL BEGIN
4290
4320
  INSERT INTO nodes_fts(rowid, name) VALUES (new.rowid, new.name);
4291
4321
  END;
4292
4322
 
4293
- CREATE TRIGGER IF NOT EXISTS nodes_fts_ad AFTER DELETE ON nodes BEGIN
4323
+ CREATE TRIGGER IF NOT EXISTS nodes_fts_ad AFTER DELETE ON nodes
4324
+ WHEN old.deleted_at IS NULL BEGIN
4294
4325
  INSERT INTO nodes_fts(nodes_fts, rowid, name) VALUES('delete', old.rowid, old.name);
4295
4326
  END;
4296
4327
 
@@ -4300,8 +4331,39 @@ CREATE TRIGGER IF NOT EXISTS nodes_fts_au AFTER UPDATE ON nodes BEGIN
4300
4331
  SELECT new.rowid, new.name WHERE new.deleted_at IS NULL;
4301
4332
  END;
4302
4333
  `;
4334
+ var FTS_REBUILD_SQL = `
4335
+ DROP TRIGGER IF EXISTS nodes_fts_ai;
4336
+ DROP TRIGGER IF EXISTS nodes_fts_ad;
4337
+ DROP TRIGGER IF EXISTS nodes_fts_au;
4338
+ DROP TABLE IF EXISTS nodes_fts;
4339
+ CREATE VIRTUAL TABLE nodes_fts USING fts5(
4340
+ name,
4341
+ content='nodes',
4342
+ content_rowid='rowid',
4343
+ tokenize='unicode61 remove_diacritics 2'
4344
+ );
4345
+ CREATE TRIGGER nodes_fts_ai AFTER INSERT ON nodes
4346
+ WHEN new.deleted_at IS NULL BEGIN
4347
+ INSERT INTO nodes_fts(rowid, name) VALUES (new.rowid, new.name);
4348
+ END;
4349
+ CREATE TRIGGER nodes_fts_ad AFTER DELETE ON nodes
4350
+ WHEN old.deleted_at IS NULL BEGIN
4351
+ INSERT INTO nodes_fts(nodes_fts, rowid, name) VALUES('delete', old.rowid, old.name);
4352
+ END;
4353
+ CREATE TRIGGER nodes_fts_au AFTER UPDATE ON nodes BEGIN
4354
+ INSERT INTO nodes_fts(nodes_fts, rowid, name) VALUES('delete', old.rowid, old.name);
4355
+ INSERT INTO nodes_fts(rowid, name)
4356
+ SELECT new.rowid, new.name WHERE new.deleted_at IS NULL;
4357
+ END;
4358
+ INSERT INTO nodes_fts(rowid, name) SELECT rowid, name FROM nodes WHERE deleted_at IS NULL;
4359
+ `;
4303
4360
  var runMigrations = (db) => {
4304
4361
  db.exec(MIGRATION_SQL);
4362
+ const row = db.prepare("SELECT value FROM schema_meta WHERE key = 'version'").get();
4363
+ const version = row ? Number(row.value) : 0;
4364
+ if (!Number.isFinite(version) || version < 4) {
4365
+ db.exec(FTS_REBUILD_SQL);
4366
+ }
4305
4367
  db.prepare(
4306
4368
  "INSERT OR REPLACE INTO schema_meta (key, value) VALUES ('version', ?)"
4307
4369
  ).run(String(SCHEMA_VERSION));
@@ -4346,6 +4408,7 @@ var createSqliteStore = (options) => {
4346
4408
  listChildren: wrap((s, i) => s.listChildren(i)),
4347
4409
  moveNode: wrap((s, i) => s.moveNode(i)),
4348
4410
  deleteNode: wrap((s, i) => s.deleteNode(i)),
4411
+ purgeNode: wrap((s, i) => s.purgeNode(i)),
4349
4412
  updateMetadata: wrap((s, i) => s.updateMetadata(i)),
4350
4413
  search: wrap((s, i) => s.search(i)),
4351
4414
  getPath: wrap((s, i) => s.getPath(i)),
@@ -4685,6 +4748,35 @@ var buildInner = (Database, options) => {
4685
4748
  }
4686
4749
  return ok(void 0);
4687
4750
  },
4751
+ async purgeNode(input) {
4752
+ const node = db.prepare(
4753
+ `SELECT id, path FROM nodes WHERE id = ? AND tenant_id = ? AND deleted_at IS NOT NULL`
4754
+ ).get(input.id, input.tenantId);
4755
+ if (!node) {
4756
+ return err(
4757
+ new FileSystemError({
4758
+ code: "NotFound",
4759
+ message: `Trashed node ${input.id} not found`,
4760
+ retryable: false
4761
+ })
4762
+ );
4763
+ }
4764
+ const like = `${node.path}/%`;
4765
+ const files = db.prepare(
4766
+ `SELECT s3_key FROM nodes
4767
+ WHERE tenant_id = ? AND deleted_at IS NOT NULL AND kind = 'file' AND s3_key != ''
4768
+ AND (id = ? OR path LIKE ?)`
4769
+ ).all(input.tenantId, input.id, like);
4770
+ db.prepare(
4771
+ `DELETE FROM shares WHERE tenant_id = ? AND node_id IN (
4772
+ SELECT id FROM nodes WHERE tenant_id = ? AND deleted_at IS NOT NULL AND (id = ? OR path LIKE ?)
4773
+ )`
4774
+ ).run(input.tenantId, input.tenantId, input.id, like);
4775
+ db.prepare(
4776
+ `DELETE FROM nodes WHERE tenant_id = ? AND deleted_at IS NOT NULL AND (id = ? OR path LIKE ?)`
4777
+ ).run(input.tenantId, input.id, like);
4778
+ return ok({ s3Keys: files.map((row) => row.s3_key) });
4779
+ },
4688
4780
  async updateMetadata(input) {
4689
4781
  const node = stmtGetById.get(input.id, input.tenantId);
4690
4782
  if (!node) return notFound(input.id);
@@ -5065,6 +5157,7 @@ var createPostgresStore = (options) => {
5065
5157
  listChildren: wrap((s, i) => s.listChildren(i)),
5066
5158
  moveNode: wrap((s, i) => s.moveNode(i)),
5067
5159
  deleteNode: wrap((s, i) => s.deleteNode(i)),
5160
+ purgeNode: wrap((s, i) => s.purgeNode(i)),
5068
5161
  updateMetadata: wrap((s, i) => s.updateMetadata(i)),
5069
5162
  search: wrap((s, i) => s.search(i)),
5070
5163
  getPath: wrap((s, i) => s.getPath(i)),
@@ -5405,6 +5498,48 @@ var buildInner2 = (pg, options) => {
5405
5498
  }
5406
5499
  return ok(void 0);
5407
5500
  },
5501
+ async purgeNode(input) {
5502
+ await ensureInitialized();
5503
+ const nodeRes = await q(
5504
+ input.tenantId,
5505
+ `SELECT id, path FROM "${schema}".nodes
5506
+ WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NOT NULL`,
5507
+ [input.id, input.tenantId]
5508
+ );
5509
+ const node = nodeRes.rows[0];
5510
+ if (!node) {
5511
+ return err(
5512
+ new FileSystemError({
5513
+ code: "NotFound",
5514
+ message: `Trashed node ${input.id} not found`,
5515
+ retryable: false
5516
+ })
5517
+ );
5518
+ }
5519
+ const like = `${node.path}/%`;
5520
+ const files = await q(
5521
+ input.tenantId,
5522
+ `SELECT s3_key FROM "${schema}".nodes
5523
+ WHERE tenant_id = $1 AND deleted_at IS NOT NULL AND kind = 'file' AND s3_key <> ''
5524
+ AND (id = $2 OR path LIKE $3)`,
5525
+ [input.tenantId, input.id, like]
5526
+ );
5527
+ await q(
5528
+ input.tenantId,
5529
+ `DELETE FROM "${schema}".shares WHERE tenant_id = $1 AND node_id IN (
5530
+ SELECT id FROM "${schema}".nodes
5531
+ WHERE tenant_id = $1 AND deleted_at IS NOT NULL AND (id = $2 OR path LIKE $3)
5532
+ )`,
5533
+ [input.tenantId, input.id, like]
5534
+ );
5535
+ await q(
5536
+ input.tenantId,
5537
+ `DELETE FROM "${schema}".nodes
5538
+ WHERE tenant_id = $1 AND deleted_at IS NOT NULL AND (id = $2 OR path LIKE $3)`,
5539
+ [input.tenantId, input.id, like]
5540
+ );
5541
+ return ok({ s3Keys: files.rows.map((row) => row.s3_key) });
5542
+ },
5408
5543
  async updateMetadata(input) {
5409
5544
  await ensureInitialized();
5410
5545
  const exists = await q(