@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.
- package/CHANGELOG.md +20 -0
- package/dist/generated/index.d.ts.map +1 -1
- package/dist/generated/index.js +3 -1
- package/dist/generated/index.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/src/DbFileStorageDriver.d.ts +2 -1
- package/dist/src/DbFileStorageDriver.d.ts.map +1 -1
- package/dist/src/DbFileStorageDriver.js +23 -7
- package/dist/src/DbFileStorageDriver.js.map +1 -1
- package/dist/src/FileStorage.d.ts +24 -6
- package/dist/src/FileStorage.d.ts.map +1 -1
- package/dist/src/FileStorage.js +57 -22
- package/dist/src/FileStorage.js.map +1 -1
- package/dist/src/FileStorageDriver.d.ts +26 -1
- package/dist/src/FileStorageDriver.d.ts.map +1 -1
- package/dist/src/FileStorageTableWatcher.d.ts +18 -0
- package/dist/src/FileStorageTableWatcher.d.ts.map +1 -0
- package/dist/src/FileStorageTableWatcher.js +96 -0
- package/dist/src/FileStorageTableWatcher.js.map +1 -0
- package/dist/src/routes/getFile.d.ts +11 -0
- package/dist/src/routes/getFile.d.ts.map +1 -1
- package/dist/src/routes/getFile.js +31 -15
- package/dist/src/routes/getFile.js.map +1 -1
- package/dist/src/tables/FileTable.d.ts +12 -4
- package/dist/src/tables/FileTable.d.ts.map +1 -1
- package/dist/src/tables/FileTable.js +5 -7
- package/dist/src/tables/FileTable.js.map +1 -1
- package/dist/test/FileMediaMetadata.test.d.ts +2 -0
- package/dist/test/FileMediaMetadata.test.d.ts.map +1 -0
- package/dist/test/FileMediaMetadata.test.js +154 -0
- package/dist/test/FileMediaMetadata.test.js.map +1 -0
- package/dist/test/FileStorageDeleteFile.test.d.ts +2 -0
- package/dist/test/FileStorageDeleteFile.test.d.ts.map +1 -0
- package/dist/test/FileStorageDeleteFile.test.js +259 -0
- package/dist/test/FileStorageDeleteFile.test.js.map +1 -0
- package/dist/test/FileTestEnvironment.d.ts +33 -0
- package/dist/test/FileTestEnvironment.d.ts.map +1 -0
- package/dist/test/FileTestEnvironment.js +229 -0
- package/dist/test/FileTestEnvironment.js.map +1 -0
- package/dist/test/GetFileRoute.test.d.ts +2 -0
- package/dist/test/GetFileRoute.test.d.ts.map +1 -0
- package/dist/test/GetFileRoute.test.js +432 -0
- package/dist/test/GetFileRoute.test.js.map +1 -0
- package/generated/index.ts +3 -1
- package/index.ts +1 -0
- package/jest.config.js +4 -0
- package/package.json +5 -3
- package/src/DbFileStorageDriver.ts +18 -8
- package/src/FileStorage.ts +48 -23
- package/src/FileStorageDriver.ts +24 -1
- package/src/FileStorageTableWatcher.ts +43 -0
- package/src/routes/getFile.ts +26 -8
- package/src/tables/FileTable.ts +14 -7
- package/test/FileMediaMetadata.test.ts +74 -0
- package/test/FileStorageDeleteFile.test.ts +128 -0
- package/test/FileTestEnvironment.ts +118 -0
- package/test/GetFileRoute.test.ts +258 -0
- package/test/emulatorLock.globalSetup.js +11 -0
- package/test/emulatorLock.globalTeardown.js +8 -0
- package/test/emulatorLock.js +124 -0
- 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.
|
|
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.
|
|
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": "
|
|
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
|
}
|
package/src/FileStorage.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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.
|
|
50
|
+
FileStorage.driver = defaultDriverFactory.getDriver();
|
|
51
51
|
} else {
|
|
52
|
-
|
|
53
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
111
|
-
|
|
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
|
|
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
|
}
|
package/src/FileStorageDriver.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/routes/getFile.ts
CHANGED
|
@@ -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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
response.
|
|
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');
|
package/src/tables/FileTable.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
});
|