@proteinjs/db-file 1.4.14 → 1.5.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.
Files changed (64) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/generated/index.d.ts.map +1 -1
  3. package/dist/generated/index.js +3 -1
  4. package/dist/generated/index.js.map +1 -1
  5. package/dist/index.d.ts +1 -0
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +1 -0
  8. package/dist/index.js.map +1 -1
  9. package/dist/src/DbFileStorageDriver.d.ts +2 -1
  10. package/dist/src/DbFileStorageDriver.d.ts.map +1 -1
  11. package/dist/src/DbFileStorageDriver.js +23 -7
  12. package/dist/src/DbFileStorageDriver.js.map +1 -1
  13. package/dist/src/FileStorage.d.ts +24 -6
  14. package/dist/src/FileStorage.d.ts.map +1 -1
  15. package/dist/src/FileStorage.js +57 -22
  16. package/dist/src/FileStorage.js.map +1 -1
  17. package/dist/src/FileStorageDriver.d.ts +26 -1
  18. package/dist/src/FileStorageDriver.d.ts.map +1 -1
  19. package/dist/src/FileStorageTableWatcher.d.ts +18 -0
  20. package/dist/src/FileStorageTableWatcher.d.ts.map +1 -0
  21. package/dist/src/FileStorageTableWatcher.js +96 -0
  22. package/dist/src/FileStorageTableWatcher.js.map +1 -0
  23. package/dist/src/routes/getFile.d.ts +11 -0
  24. package/dist/src/routes/getFile.d.ts.map +1 -1
  25. package/dist/src/routes/getFile.js +31 -15
  26. package/dist/src/routes/getFile.js.map +1 -1
  27. package/dist/src/tables/FileTable.d.ts +12 -4
  28. package/dist/src/tables/FileTable.d.ts.map +1 -1
  29. package/dist/src/tables/FileTable.js +5 -7
  30. package/dist/src/tables/FileTable.js.map +1 -1
  31. package/dist/test/FileMediaMetadata.test.d.ts +2 -0
  32. package/dist/test/FileMediaMetadata.test.d.ts.map +1 -0
  33. package/dist/test/FileMediaMetadata.test.js +154 -0
  34. package/dist/test/FileMediaMetadata.test.js.map +1 -0
  35. package/dist/test/FileStorageDeleteFile.test.d.ts +2 -0
  36. package/dist/test/FileStorageDeleteFile.test.d.ts.map +1 -0
  37. package/dist/test/FileStorageDeleteFile.test.js +259 -0
  38. package/dist/test/FileStorageDeleteFile.test.js.map +1 -0
  39. package/dist/test/FileTestEnvironment.d.ts +33 -0
  40. package/dist/test/FileTestEnvironment.d.ts.map +1 -0
  41. package/dist/test/FileTestEnvironment.js +229 -0
  42. package/dist/test/FileTestEnvironment.js.map +1 -0
  43. package/dist/test/GetFileRoute.test.d.ts +2 -0
  44. package/dist/test/GetFileRoute.test.d.ts.map +1 -0
  45. package/dist/test/GetFileRoute.test.js +432 -0
  46. package/dist/test/GetFileRoute.test.js.map +1 -0
  47. package/generated/index.ts +3 -1
  48. package/index.ts +1 -0
  49. package/jest.config.js +4 -0
  50. package/package.json +5 -3
  51. package/src/DbFileStorageDriver.ts +18 -8
  52. package/src/FileStorage.ts +48 -23
  53. package/src/FileStorageDriver.ts +24 -1
  54. package/src/FileStorageTableWatcher.ts +43 -0
  55. package/src/routes/getFile.ts +26 -8
  56. package/src/tables/FileTable.ts +14 -7
  57. package/test/FileMediaMetadata.test.ts +74 -0
  58. package/test/FileStorageDeleteFile.test.ts +128 -0
  59. package/test/FileTestEnvironment.ts +118 -0
  60. package/test/GetFileRoute.test.ts +258 -0
  61. package/test/emulatorLock.globalSetup.js +11 -0
  62. package/test/emulatorLock.globalTeardown.js +8 -0
  63. package/test/emulatorLock.js +124 -0
  64. package/test/setup.js +2 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proteinjs/db-file",
3
- "version": "1.4.14",
3
+ "version": "1.5.0",
4
4
  "description": "File storage in the db",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -18,10 +18,11 @@
18
18
  "scripts": {
19
19
  "clean": "rm -rf dist/ node_modules/ generated/",
20
20
  "build": "reflection-build && tsc",
21
+ "test": "jest --runInBand --passWithNoTests",
21
22
  "watch": "reflection-watch"
22
23
  },
23
24
  "dependencies": {
24
- "@proteinjs/db": "^1.32.3",
25
+ "@proteinjs/db": "^1.34.0",
25
26
  "@proteinjs/logger": "^1.0.21",
26
27
  "@proteinjs/reflection": "^1.1.12",
27
28
  "@proteinjs/server-api": "^3.0.11",
@@ -30,6 +31,7 @@
30
31
  "moment": "2.29.4"
31
32
  },
32
33
  "devDependencies": {
34
+ "@proteinjs/db-driver-spanner": "^1.22.0",
33
35
  "@proteinjs/reflection-build": "^1.4.10",
34
36
  "@types/jest": "29.5.5",
35
37
  "@types/node": "14.0.27",
@@ -44,5 +46,5 @@
44
46
  },
45
47
  "main": "./dist/generated/index.js",
46
48
  "types": "./dist/generated/index.d.ts",
47
- "gitHead": "3fa64176929e1c9183cf3d998d9d629dcbb669b0"
49
+ "gitHead": "7a301275151aee8040fd52e15e3fe950a6863783"
48
50
  }
@@ -29,14 +29,6 @@ export class DbFileStorageDriver implements FileStorageDriver {
29
29
  }
30
30
  }
31
31
 
32
- private splitIntoChunks(data: string): string[] {
33
- const chunks = [];
34
- for (let i = 0; i < data.length; i += this.chunkSize) {
35
- chunks.push(data.substring(i, i + this.chunkSize));
36
- }
37
- return chunks;
38
- }
39
-
40
32
  async getFileData(fileId: string): Promise<string> {
41
33
  // Byte reads are keyed by id and UNSCOPED — matching the GCS driver's semantics (a bucket
42
34
  // download by id). The driver is a byte store; access control lives with callers and the
@@ -70,4 +62,22 @@ export class DbFileStorageDriver implements FileStorageDriver {
70
62
  });
71
63
  }
72
64
  }
65
+
66
+ async deleteFile(fileId: string): Promise<void> {
67
+ // Byte deletes are keyed by id and UNSCOPED — the same semantics as getFileData above (the
68
+ // driver is a byte store; access control lives with callers and the scoped File metadata
69
+ // layer), and system sweeps delete rows outside the current session's scope. Deleting zero
70
+ // chunks succeeds, which gives this the idempotency the driver contract requires.
71
+ const db = getScopedDbAsSystem();
72
+ const deleteQuery = new QueryBuilderFactory().getQueryBuilder(tables.FileData, { file: fileId });
73
+ await db.delete(tables.FileData, deleteQuery);
74
+ }
75
+
76
+ private splitIntoChunks(data: string): string[] {
77
+ const chunks = [];
78
+ for (let i = 0; i < data.length; i += this.chunkSize) {
79
+ chunks.push(data.substring(i, i + this.chunkSize));
80
+ }
81
+ return chunks;
82
+ }
73
83
  }
@@ -27,9 +27,7 @@ export interface DefaultFileStorageDriverFactory extends Loadable {
27
27
  * File data is stored by the `FileStorageDriver`.
28
28
  */
29
29
  export class FileStorage implements FileStorageService {
30
- private static defaultDriver: FileStorageDriver;
31
- private driver: FileStorageDriver;
32
- private logger: Logger = new Logger({ name: this.constructor.name });
30
+ private static driver: FileStorageDriver;
33
31
 
34
32
  public serviceMetadata = {
35
33
  auth: {
@@ -37,24 +35,28 @@ export class FileStorage implements FileStorageService {
37
35
  },
38
36
  };
39
37
 
40
- constructor(driver?: FileStorageDriver) {
41
- this.driver = driver ? driver : this.getDefaultDriver();
42
- }
43
-
44
- private getDefaultDriver(): FileStorageDriver {
45
- if (!FileStorage.defaultDriver) {
38
+ /**
39
+ * The `FileStorageDriver` for this process provided by the `DefaultFileStorageDriverFactory`
40
+ * implementation, defaulting to the `DbFileStorageDriver`. One resolution for every byte
41
+ * operation (`FileStorage` and `FileStorageTableWatcher`), so bytes always live and die in the
42
+ * same store.
43
+ */
44
+ static getDriver(): FileStorageDriver {
45
+ if (!FileStorage.driver) {
46
46
  const defaultDriverFactory = SourceRepository.get().object<DefaultFileStorageDriverFactory>(
47
47
  '@proteinjs/db-file/DefaultFileStorageDriverFactory'
48
48
  );
49
49
  if (defaultDriverFactory) {
50
- FileStorage.defaultDriver = defaultDriverFactory.getDriver();
50
+ FileStorage.driver = defaultDriverFactory.getDriver();
51
51
  } else {
52
- this.logger.info({ message: `Defaulting to DbFileStorageDriver since no FileStorageDriver was provided` });
53
- FileStorage.defaultDriver = new DbFileStorageDriver();
52
+ new Logger({ name: 'FileStorage' }).info({
53
+ message: `Defaulting to DbFileStorageDriver since no FileStorageDriver was provided`,
54
+ });
55
+ FileStorage.driver = new DbFileStorageDriver();
54
56
  }
55
57
  }
56
58
 
57
- return FileStorage.defaultDriver;
59
+ return FileStorage.driver;
58
60
  }
59
61
 
60
62
  /**
@@ -66,7 +68,7 @@ export class FileStorage implements FileStorageService {
66
68
  async createFile(fileMetaData: Omit<File, keyof ScopedRecord>, fileData: string): Promise<File> {
67
69
  const db = getScopedDb();
68
70
  const file = await db.insert(tables.File, fileMetaData);
69
- await this.driver.createFile(file, fileData);
71
+ await FileStorage.getDriver().createFile(file, fileData);
70
72
  return file;
71
73
  }
72
74
 
@@ -87,7 +89,31 @@ export class FileStorage implements FileStorageService {
87
89
  * @returns The file data as a single string.
88
90
  */
89
91
  async getFileData(fileId: string): Promise<string> {
90
- return await this.driver.getFileData(fileId);
92
+ return await FileStorage.getDriver().getFileData(fileId);
93
+ }
94
+
95
+ /**
96
+ * Mint a short-lived, read-only URL for the file's bytes (server-only; not part of the
97
+ * browser-facing `FileStorageService`). A signed URL is a bearer capability, so minting
98
+ * verifies the caller can read the file row (scoped read) before signing.
99
+ * @param fileId - The `id` of the file.
100
+ * @param options.ttlMs - How long the URL stays valid; the driver applies its default when omitted.
101
+ * @returns The signed URL, or `undefined` when the driver has no external URL space
102
+ * (`DbFileStorageDriver`) — the caller then serves bytes through the proxy route.
103
+ * @throws When the file row does not exist or is not readable by the caller.
104
+ */
105
+ async getSignedUrl(fileId: string, options?: { ttlMs?: number }): Promise<string | undefined> {
106
+ const file = await this.getFile(fileId);
107
+ if (!file) {
108
+ throw new Error(`File not found: ${fileId}`);
109
+ }
110
+
111
+ const driver = FileStorage.getDriver();
112
+ if (!driver.getSignedUrl) {
113
+ return undefined;
114
+ }
115
+
116
+ return await driver.getSignedUrl(fileId, options);
91
117
  }
92
118
 
93
119
  /**
@@ -96,7 +122,7 @@ export class FileStorage implements FileStorageService {
96
122
  * @param data - The new data string to replace the existing data.
97
123
  */
98
124
  async updateFileData(fileId: string, data: string): Promise<void> {
99
- await this.driver.updateFileData(fileId, data);
125
+ await FileStorage.getDriver().updateFileData(fileId, data);
100
126
  }
101
127
 
102
128
  /**
@@ -107,22 +133,21 @@ export class FileStorage implements FileStorageService {
107
133
  const db = getScopedDb();
108
134
  await db.update(tables.File, file);
109
135
 
110
- if (this.driver.updateFile) {
111
- await this.driver.updateFile(file as File);
136
+ const driver = FileStorage.getDriver();
137
+ if (driver.updateFile) {
138
+ await driver.updateFile(file as File);
112
139
  }
113
140
  }
114
141
 
115
142
  /**
116
143
  * Deletes a file and its data.
117
- * The file data is deleted by a cascade delete rule defined on the `FileTable`
144
+ * The file data is deleted by the `FileStorageTableWatcher` when the row delete runs the
145
+ * watcher fires for every file-row delete path (this one, reference cascades, system sweeps),
146
+ * so the bytes die with the row no matter where the delete originates.
118
147
  * @param fileId - The `id` of the file to delete.
119
148
  */
120
149
  async deleteFile(fileId: string): Promise<void> {
121
150
  const db = getScopedDb();
122
- if (this.driver.deleteFile) {
123
- await this.driver.deleteFile(fileId);
124
- }
125
-
126
151
  await db.delete(tables.File, { id: fileId });
127
152
  }
128
153
  }
@@ -1,9 +1,32 @@
1
1
  import { File } from './tables/FileTable';
2
2
 
3
+ /**
4
+ * Byte store behind `FileStorage`.
5
+ *
6
+ * Encoding contract: every `fileData`/`data` string crossing this interface is BASE64 of the
7
+ * file's true bytes — for text and binary files alike. Drivers own their at-rest representation,
8
+ * with one hard requirement: a driver that serves blobs directly to clients (`getSignedUrl`)
9
+ * MUST store the true bytes at rest, because clients read its objects without any decode step.
10
+ */
3
11
  export interface FileStorageDriver {
4
12
  createFile(file: File, fileData: string): Promise<void>;
5
13
  getFileData(fileId: string): Promise<string>;
6
14
  updateFileData(fileId: string, data: string): Promise<void>;
7
15
  updateFile?(file: File): Promise<void>;
8
- deleteFile?(fileId: string): Promise<void>;
16
+ /**
17
+ * Mint a short-lived, read-only URL for the file's bytes, when the driver's store has an
18
+ * external URL space (e.g. GCS signed URLs). Serving rides this: `GET /file/:id` 302-redirects
19
+ * to the minted URL, so clients get native Range/seek and caching and bytes stop transiting
20
+ * the app server. Absent on drivers with no external URL space (`DbFileStorageDriver`) — their
21
+ * bytes serve through the proxy route.
22
+ */
23
+ getSignedUrl?(fileId: string, options?: { ttlMs?: number }): Promise<string>;
24
+ /**
25
+ * Delete the stored bytes for `fileId`.
26
+ *
27
+ * Contract: idempotent on the goal state — bytes already gone is success, since file-row
28
+ * deletes are retryable (a re-run after a half-completed prior attempt must not wedge on the
29
+ * missing bytes). Every other failure throws loudly; the file row survives to drive a retry.
30
+ */
31
+ deleteFile(fileId: string): Promise<void>;
9
32
  }
@@ -0,0 +1,43 @@
1
+ import { QueryBuilder, Table, TableWatcher } from '@proteinjs/db';
2
+ import { Logger } from '@proteinjs/logger';
3
+ import { File, FileTable } from './tables/FileTable';
4
+ import { FileStorage } from './FileStorage';
5
+
6
+ /**
7
+ * Deletes stored file bytes whenever file rows are deleted. Lives at the Db layer so every
8
+ * delete path reaches the byte store — `FileStorage.deleteFile`, reference cascades (e.g.
9
+ * `file.preview`), and system sweeps that delete file rows directly.
10
+ *
11
+ * Bytes go first (`beforeDelete`): if the byte delete fails, the row survives to drive a retry;
12
+ * the reverse order would strand bytes with no row left to retry from. A retried delete is safe
13
+ * because `FileStorageDriver.deleteFile` is idempotent on the goal state.
14
+ */
15
+ export class FileStorageTableWatcher implements TableWatcher<File> {
16
+ private logger = new Logger({ name: this.constructor.name });
17
+
18
+ name(): string {
19
+ return this.constructor.name;
20
+ }
21
+
22
+ table(): Table<File> {
23
+ return new FileTable();
24
+ }
25
+
26
+ async beforeDelete<T extends File>(
27
+ recordsToDelete: T[],
28
+ initialQb: QueryBuilder<T>,
29
+ deleteQb: QueryBuilder<T>
30
+ ): Promise<void> {
31
+ const driver = FileStorage.getDriver();
32
+ const deletedFileIds: string[] = [];
33
+ for (const record of recordsToDelete) {
34
+ await driver.deleteFile(record.id);
35
+ deletedFileIds.push(record.id);
36
+ }
37
+
38
+ this.logger.info({
39
+ message: `Deleted stored bytes for file records being deleted`,
40
+ obj: { deletedFileIds },
41
+ });
42
+ }
43
+ }
@@ -2,6 +2,17 @@ import { Route } from '@proteinjs/server-api';
2
2
  import { getFileStorage } from '../FileStorage';
3
3
  import { UserAuth } from '@proteinjs/user';
4
4
 
5
+ /**
6
+ * Serves a file's bytes. Auth first (logged-in + scoped row read), then one of two paths:
7
+ *
8
+ * - **302 redirect** to a short-lived signed URL when the driver's store has an external URL
9
+ * space (GCS). The app URL stays the one stable reference every `<img>`/`<video>`/chip uses;
10
+ * the client follows the redirect and reads the store directly — native Range/206 for video
11
+ * seeking, real caching, bytes never transit the app server. The redirect itself is cached
12
+ * briefly (client-private, well under the signed TTL) so repeated loads reuse one URL.
13
+ * - **Proxy** for drivers with no external URL space (`DbFileStorageDriver`): the interface's
14
+ * base64 is decoded so every mime — binary or text — serves its true bytes.
15
+ */
5
16
  export const getFile: Route = {
6
17
  path: '/file/:id',
7
18
  method: 'get',
@@ -14,18 +25,25 @@ export const getFile: Route = {
14
25
  const fileId = request.params.id;
15
26
  const fileStorage = getFileStorage();
16
27
  try {
28
+ // The file row decides existence (a scoped read, so it is also the access check).
17
29
  const file = await fileStorage.getFile(fileId);
18
- const fileDataBase64 = await fileStorage.getFileData(fileId);
19
- // The file row decides existence; empty data is a legitimate zero-byte file, not a 404.
20
30
  if (!file) {
21
31
  response.status(404).send('File not found');
22
- } else {
23
- const safeFilename = encodeURIComponent(file.name);
24
- const fileData = file.type.startsWith('image/') ? Buffer.from(fileDataBase64, 'base64') : fileDataBase64;
25
- response.setHeader('Content-Disposition', `inline; filename="${safeFilename}"`);
26
- response.setHeader('Content-Type', file.type);
27
- response.send(fileData);
32
+ return;
33
+ }
34
+
35
+ const signedUrl = await fileStorage.getSignedUrl(fileId);
36
+ if (signedUrl) {
37
+ response.setHeader('Cache-Control', 'private, max-age=300');
38
+ response.redirect(302, signedUrl);
39
+ return;
28
40
  }
41
+
42
+ const fileDataBase64 = await fileStorage.getFileData(fileId);
43
+ const safeFilename = encodeURIComponent(file.name);
44
+ response.setHeader('Content-Disposition', `inline; filename="${safeFilename}"`);
45
+ response.setHeader('Content-Type', file.type);
46
+ response.send(Buffer.from(fileDataBase64, 'base64'));
29
47
  } catch (error) {
30
48
  console.error(`Error fetching file (${fileId}):`, error);
31
49
  response.status(500).send('Internal Server Error');
@@ -1,6 +1,5 @@
1
1
  import { Table, StringColumn, IntegerColumn, ReferenceColumn, Reference } from '@proteinjs/db';
2
2
  import { ScopedRecord, withScopedRecordColumns } from '@proteinjs/user';
3
- import { FileDataTable } from './FileDataTable';
4
3
 
5
4
  const FILE_TABLE_NAME = 'file';
6
5
 
@@ -14,6 +13,15 @@ export interface File extends ScopedRecord {
14
13
  * the bytes stay out of the DB row (consistent with the storage model); deleted with this file.
15
14
  */
16
15
  preview?: Reference<File>;
16
+ /**
17
+ * Media metadata — generic file facts (an image/video's pixel dimensions, a video/audio
18
+ * duration) every consumer needs to render without loading bytes, e.g. reserving a media box's
19
+ * aspect ratio before any bytes arrive. Set at ingest for media files; absent for everything
20
+ * else.
21
+ */
22
+ width?: number;
23
+ height?: number;
24
+ durationMs?: number;
17
25
  }
18
26
 
19
27
  export class FileTable extends Table<File> {
@@ -32,11 +40,10 @@ export class FileTable extends Table<File> {
32
40
  size: new IntegerColumn('size'),
33
41
  // Self-reference (the preview is itself a File). cascadeDelete: removing a file removes its preview.
34
42
  preview: new ReferenceColumn<File>('preview', FILE_TABLE_NAME, true),
43
+ width: new IntegerColumn('width'),
44
+ height: new IntegerColumn('height'),
45
+ durationMs: new IntegerColumn('duration_ms'),
35
46
  });
36
- public cascadeDeleteReferences = () => [
37
- {
38
- table: new FileDataTable().name,
39
- referenceColumn: new FileDataTable().columns.file.name,
40
- },
41
- ];
47
+ // No cascadeDeleteReferences for FileData: byte cleanup (FileData rows included) is owned by
48
+ // FileStorageDriver.deleteFile, invoked for every file-row delete by FileStorageTableWatcher.
42
49
  }
@@ -0,0 +1,74 @@
1
+ import { getDbAsSystem } from '@proteinjs/db';
2
+ import { File } from '../src/tables/FileTable';
3
+ import { tables } from '../src/tables/tables';
4
+ import { FileStorage } from '../src/FileStorage';
5
+ import { FileStorageDriver } from '../src/FileStorageDriver';
6
+ import { FileTestEnvironment } from './FileTestEnvironment';
7
+
8
+ class TestMemoryFileStorageDriver implements FileStorageDriver {
9
+ readonly store = new Map<string, string>();
10
+
11
+ async createFile(file: File, fileData: string): Promise<void> {
12
+ this.store.set(file.id, fileData);
13
+ }
14
+
15
+ async getFileData(fileId: string): Promise<string> {
16
+ const data = this.store.get(fileId);
17
+ if (data === undefined) {
18
+ throw new Error(`No such object: ${fileId}`);
19
+ }
20
+ return data;
21
+ }
22
+
23
+ async updateFileData(fileId: string, data: string): Promise<void> {
24
+ this.store.set(fileId, data);
25
+ }
26
+
27
+ async deleteFile(fileId: string): Promise<void> {
28
+ this.store.delete(fileId);
29
+ }
30
+ }
31
+
32
+ const testEnv = new FileTestEnvironment();
33
+
34
+ beforeAll(async () => {
35
+ await testEnv.beforeAll();
36
+ const user = await testEnv.createUser({ name: 'File owner', email: 'media-metadata@test.local' });
37
+ testEnv.actAs(user);
38
+ testEnv.setDriver(new TestMemoryFileStorageDriver());
39
+ });
40
+
41
+ afterAll(async () => {
42
+ await testEnv.afterAll();
43
+ });
44
+
45
+ /**
46
+ * Media metadata lives on the File row (`width`/`height`/`durationMs`) — generic file facts every
47
+ * consumer needs to render without loading bytes (aspect-ratio-reserved layout). This proves the
48
+ * columns persist and round-trip through the real schema on the emulator.
49
+ */
50
+ describe('File media metadata columns', () => {
51
+ it('round-trips width/height/durationMs on a media file', async () => {
52
+ const file = await new FileStorage().createFile(
53
+ { name: 'clip.mp4', type: 'video/mp4', size: 4, width: 1280, height: 720, durationMs: 24_500 } as File,
54
+ Buffer.from('clip').toString('base64')
55
+ );
56
+
57
+ const row = await getDbAsSystem().get(tables.File, { id: file.id });
58
+ expect(row.width).toEqual(1280);
59
+ expect(row.height).toEqual(720);
60
+ expect(row.durationMs).toEqual(24_500);
61
+ });
62
+
63
+ it('leaves the fields absent for non-media files', async () => {
64
+ const file = await new FileStorage().createFile(
65
+ { name: 'a.txt', type: 'text/plain', size: 5 } as File,
66
+ Buffer.from('hello').toString('base64')
67
+ );
68
+
69
+ const row = await getDbAsSystem().get(tables.File, { id: file.id });
70
+ expect(row.width ?? undefined).toBeUndefined();
71
+ expect(row.height ?? undefined).toBeUndefined();
72
+ expect(row.durationMs ?? undefined).toBeUndefined();
73
+ });
74
+ });
@@ -0,0 +1,128 @@
1
+ import { Reference, getDbAsSystem } from '@proteinjs/db';
2
+ import { getScopedDbAsSystem, ScopedRecord } from '@proteinjs/user';
3
+ import { File } from '../src/tables/FileTable';
4
+ import { tables } from '../src/tables/tables';
5
+ import { FileStorage } from '../src/FileStorage';
6
+ import { FileStorageDriver } from '../src/FileStorageDriver';
7
+ import { DbFileStorageDriver } from '../src/DbFileStorageDriver';
8
+ import { FileTestEnvironment } from './FileTestEnvironment';
9
+
10
+ /**
11
+ * In-memory byte store shaped like the GoogleCloudStorageDriver: bytes keyed by file id,
12
+ * reads of a missing object fail loudly (GCS 404 semantics).
13
+ */
14
+ class TestMemoryFileStorageDriver implements FileStorageDriver {
15
+ readonly store = new Map<string, string>();
16
+
17
+ async createFile(file: File, fileData: string): Promise<void> {
18
+ this.store.set(file.id, fileData);
19
+ }
20
+
21
+ async getFileData(fileId: string): Promise<string> {
22
+ const data = this.store.get(fileId);
23
+ if (data === undefined) {
24
+ throw new Error(`No such object: ${fileId}`);
25
+ }
26
+ return data;
27
+ }
28
+
29
+ async updateFileData(fileId: string, data: string): Promise<void> {
30
+ this.store.set(fileId, data);
31
+ }
32
+
33
+ async updateFile(_file: File): Promise<void> {}
34
+
35
+ async deleteFile(fileId: string): Promise<void> {
36
+ this.store.delete(fileId);
37
+ }
38
+ }
39
+
40
+ const testEnv = new FileTestEnvironment();
41
+
42
+ beforeAll(async () => {
43
+ await testEnv.beforeAll();
44
+ const user = await testEnv.createUser({ name: 'File owner', email: 'file-owner@test.local' });
45
+ testEnv.actAs(user);
46
+ });
47
+
48
+ afterAll(async () => {
49
+ await testEnv.afterAll();
50
+ });
51
+
52
+ describe('FileStorage.deleteFile', () => {
53
+ const driver = new TestMemoryFileStorageDriver();
54
+
55
+ beforeAll(() => {
56
+ testEnv.setDriver(driver);
57
+ });
58
+
59
+ it('deletes the stored bytes along with the file row', async () => {
60
+ const fileStorage = new FileStorage();
61
+ const file = await fileStorage.createFile({ name: 'a.txt', type: 'text/plain', size: 5 } as File, 'hello');
62
+ expect(driver.store.has(file.id)).toBe(true);
63
+
64
+ await fileStorage.deleteFile(file.id);
65
+
66
+ expect(await getDbAsSystem().get(tables.File, { id: file.id })).toBeUndefined();
67
+ expect(driver.store.has(file.id)).toBe(false);
68
+ });
69
+
70
+ it('deletes the stored bytes when file rows are deleted directly through Db.delete (system sweep path)', async () => {
71
+ const fileStorage = new FileStorage();
72
+ const file = await fileStorage.createFile({ name: 'b.txt', type: 'text/plain', size: 5 } as File, 'swept');
73
+ expect(driver.store.has(file.id)).toBe(true);
74
+
75
+ await getScopedDbAsSystem<File>().delete(tables.File, { id: file.id });
76
+
77
+ expect(await getDbAsSystem().get(tables.File, { id: file.id })).toBeUndefined();
78
+ expect(driver.store.has(file.id)).toBe(false);
79
+ });
80
+
81
+ it('deletes the preview file bytes through the reference cascade', async () => {
82
+ const fileStorage = new FileStorage();
83
+ const preview = await fileStorage.createFile({ name: 'c.gif.preview', type: 'image/png', size: 4 } as File, 'PREV');
84
+ const file = await fileStorage.createFile(
85
+ {
86
+ name: 'c.gif',
87
+ type: 'image/gif',
88
+ size: 4,
89
+ preview: new Reference<File>(tables.File.name, preview.id),
90
+ } as Omit<File, keyof ScopedRecord>,
91
+ 'MAIN'
92
+ );
93
+
94
+ await fileStorage.deleteFile(file.id);
95
+
96
+ expect(await getDbAsSystem().get(tables.File, { id: file.id })).toBeUndefined();
97
+ expect(await getDbAsSystem().get(tables.File, { id: preview.id })).toBeUndefined();
98
+ expect(driver.store.has(file.id)).toBe(false);
99
+ expect(driver.store.has(preview.id)).toBe(false);
100
+ });
101
+ });
102
+
103
+ describe('FileStorage.deleteFile with DbFileStorageDriver', () => {
104
+ beforeAll(() => {
105
+ // 4-byte chunks so a small payload exercises multi-chunk cleanup.
106
+ testEnv.setDriver(new DbFileStorageDriver(4));
107
+ });
108
+
109
+ it('deletes all file_data chunk rows along with the file row', async () => {
110
+ const fileStorage = new FileStorage();
111
+ const file = await fileStorage.createFile({ name: 'd.txt', type: 'text/plain', size: 11 } as File, 'hello world');
112
+ const chunksBefore = await getDbAsSystem().query(tables.FileData, { file: file.id });
113
+ expect(chunksBefore.length).toBeGreaterThan(1);
114
+
115
+ await fileStorage.deleteFile(file.id);
116
+
117
+ expect(await getDbAsSystem().get(tables.File, { id: file.id })).toBeUndefined();
118
+ expect(await getDbAsSystem().query(tables.FileData, { file: file.id })).toHaveLength(0);
119
+ });
120
+
121
+ it('deleteFile is idempotent: deleting bytes that are already gone succeeds', async () => {
122
+ const fileStorage = new FileStorage();
123
+ const file = await fileStorage.createFile({ name: 'e.txt', type: 'text/plain', size: 5 } as File, 'again');
124
+
125
+ await fileStorage.deleteFile(file.id);
126
+ await expect(FileStorage.getDriver().deleteFile(file.id)).resolves.toBeUndefined();
127
+ });
128
+ });