@proteinjs/db-file 1.8.3 → 1.9.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 (97) hide show
  1. package/CHANGELOG.md +38 -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 +4 -0
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +4 -0
  8. package/dist/index.js.map +1 -1
  9. package/dist/src/DbFileStorageDriver.d.ts +7 -0
  10. package/dist/src/DbFileStorageDriver.d.ts.map +1 -1
  11. package/dist/src/DbFileStorageDriver.js +69 -41
  12. package/dist/src/DbFileStorageDriver.js.map +1 -1
  13. package/dist/src/FileCopyForOthers.d.ts +52 -0
  14. package/dist/src/FileCopyForOthers.d.ts.map +1 -0
  15. package/dist/src/FileCopyForOthers.js +62 -0
  16. package/dist/src/FileCopyForOthers.js.map +1 -0
  17. package/dist/src/FileStorage.d.ts +145 -11
  18. package/dist/src/FileStorage.d.ts.map +1 -1
  19. package/dist/src/FileStorage.js +537 -41
  20. package/dist/src/FileStorage.js.map +1 -1
  21. package/dist/src/FileStorageDriver.d.ts +11 -0
  22. package/dist/src/FileStorageDriver.d.ts.map +1 -1
  23. package/dist/src/FileStorageError.d.ts +62 -0
  24. package/dist/src/FileStorageError.d.ts.map +1 -0
  25. package/dist/src/FileStorageError.js +118 -0
  26. package/dist/src/FileStorageError.js.map +1 -0
  27. package/dist/src/FileVariantMaker.d.ts +61 -0
  28. package/dist/src/FileVariantMaker.d.ts.map +1 -0
  29. package/dist/src/FileVariantMaker.js +62 -0
  30. package/dist/src/FileVariantMaker.js.map +1 -0
  31. package/dist/src/routes/FileResponder.d.ts +41 -0
  32. package/dist/src/routes/FileResponder.d.ts.map +1 -0
  33. package/dist/src/routes/FileResponder.js +135 -0
  34. package/dist/src/routes/FileResponder.js.map +1 -0
  35. package/dist/src/routes/getFile.d.ts +5 -13
  36. package/dist/src/routes/getFile.d.ts.map +1 -1
  37. package/dist/src/routes/getFile.js +20 -56
  38. package/dist/src/routes/getFile.js.map +1 -1
  39. package/dist/src/routes/getFileVariant.d.ts +20 -0
  40. package/dist/src/routes/getFileVariant.d.ts.map +1 -0
  41. package/dist/src/routes/getFileVariant.js +112 -0
  42. package/dist/src/routes/getFileVariant.js.map +1 -0
  43. package/dist/src/routes/getFileVariantRoute.d.ts +7 -0
  44. package/dist/src/routes/getFileVariantRoute.d.ts.map +1 -0
  45. package/dist/src/routes/getFileVariantRoute.js +9 -0
  46. package/dist/src/routes/getFileVariantRoute.js.map +1 -0
  47. package/dist/src/tables/FileTable.d.ts +55 -2
  48. package/dist/src/tables/FileTable.d.ts.map +1 -1
  49. package/dist/src/tables/FileTable.js +8 -0
  50. package/dist/src/tables/FileTable.js.map +1 -1
  51. package/dist/test/DbFileStorageDriverMissingBytes.test.d.ts +2 -0
  52. package/dist/test/DbFileStorageDriverMissingBytes.test.d.ts.map +1 -0
  53. package/dist/test/DbFileStorageDriverMissingBytes.test.js +184 -0
  54. package/dist/test/DbFileStorageDriverMissingBytes.test.js.map +1 -0
  55. package/dist/test/FileByteAccess.test.js +6 -3
  56. package/dist/test/FileByteAccess.test.js.map +1 -1
  57. package/dist/test/FileCopyForOthers.test.d.ts +2 -0
  58. package/dist/test/FileCopyForOthers.test.d.ts.map +1 -0
  59. package/dist/test/FileCopyForOthers.test.js +1143 -0
  60. package/dist/test/FileCopyForOthers.test.js.map +1 -0
  61. package/dist/test/FileMediaMetadata.test.js +60 -5
  62. package/dist/test/FileMediaMetadata.test.js.map +1 -1
  63. package/dist/test/FileStorageError.test.d.ts +2 -0
  64. package/dist/test/FileStorageError.test.d.ts.map +1 -0
  65. package/dist/test/FileStorageError.test.js +68 -0
  66. package/dist/test/FileStorageError.test.js.map +1 -0
  67. package/dist/test/FileTestEnvironment.d.ts.map +1 -1
  68. package/dist/test/FileTestEnvironment.js +3 -0
  69. package/dist/test/FileTestEnvironment.js.map +1 -1
  70. package/dist/test/FileVariants.test.d.ts +2 -0
  71. package/dist/test/FileVariants.test.d.ts.map +1 -0
  72. package/dist/test/FileVariants.test.js +1008 -0
  73. package/dist/test/FileVariants.test.js.map +1 -0
  74. package/dist/test/GetFileRoute.test.js +29 -0
  75. package/dist/test/GetFileRoute.test.js.map +1 -1
  76. package/generated/index.ts +3 -1
  77. package/index.ts +4 -0
  78. package/package.json +5 -5
  79. package/src/DbFileStorageDriver.ts +32 -21
  80. package/src/FileCopyForOthers.ts +71 -0
  81. package/src/FileStorage.ts +351 -18
  82. package/src/FileStorageDriver.ts +12 -0
  83. package/src/FileStorageError.ts +123 -0
  84. package/src/FileVariantMaker.ts +84 -0
  85. package/src/routes/FileResponder.ts +85 -0
  86. package/src/routes/getFile.ts +9 -44
  87. package/src/routes/getFileVariant.ts +53 -0
  88. package/src/routes/getFileVariantRoute.ts +7 -0
  89. package/src/tables/FileTable.ts +57 -1
  90. package/test/DbFileStorageDriverMissingBytes.test.ts +85 -0
  91. package/test/FileByteAccess.test.ts +7 -1
  92. package/test/FileCopyForOthers.test.ts +702 -0
  93. package/test/FileMediaMetadata.test.ts +44 -0
  94. package/test/FileStorageError.test.ts +79 -0
  95. package/test/FileTestEnvironment.ts +3 -0
  96. package/test/FileVariants.test.ts +604 -0
  97. package/test/GetFileRoute.test.ts +19 -0
@@ -0,0 +1,84 @@
1
+ import { Loadable, SourceRepository } from '@proteinjs/reflection';
2
+ import { File } from './tables/FileTable';
3
+
4
+ /**
5
+ * The kinds of derived File a file can carry — each a seat on the row (`File.preview`,
6
+ * `File.stage`) with one lifecycle: made once, named on the row, dropped when the original's
7
+ * bytes change, deleted with the original, readable by whoever can read the original.
8
+ */
9
+ export type FileVariantKind = 'preview' | 'stage';
10
+
11
+ export const FILE_VARIANT_KINDS: readonly FileVariantKind[] = ['preview', 'stage'];
12
+
13
+ /** What the maker hands back for a variant: the bytes, their type, and the facts a consumer renders from without loading them. */
14
+ export interface FileVariant {
15
+ bytes: Buffer;
16
+ type: string;
17
+ width?: number;
18
+ height?: number;
19
+ }
20
+
21
+ /**
22
+ * How a derived File of a File is made — ONE owner for "a variant of this file's bytes".
23
+ *
24
+ * The library owns the seat on the row, the lifecycle and the access rule for every variant
25
+ * (`FileStorage.deriveVariants` at ingest, `FileStorage.getVariant` on the read path); the one
26
+ * package that knows the file's format registers how the variant's bytes are made — a picture's
27
+ * 512 px thumbnail (`preview`) and its 1600 px rendition for the stage (`stage`) are two answers
28
+ * of the same maker, never two mechanisms. Dependency direction is the
29
+ * {@link FileReachabilityResolver} / {@link FileCopyForOthers} pattern: the interface here, the
30
+ * implementation above, discovered through the SourceRepository. With no maker registered no
31
+ * variant is ever made and every consumer draws the original.
32
+ */
33
+ export interface FileVariantMaker extends Loadable {
34
+ /**
35
+ * Whether a variant of this kind is made of this file at all — decided from the row alone,
36
+ * never from its bytes (a document, a clip, a picture already small enough cost no read). True
37
+ * means {@link make} is called once with the original's bytes and its answer is stored.
38
+ */
39
+ appliesTo(file: File, kind: FileVariantKind): boolean;
40
+ /** The variant, made from the original's bytes. Throws when it cannot be made — nothing is stored, the consumer draws the original. */
41
+ make(file: File, bytes: Buffer, kind: FileVariantKind): Promise<FileVariant>;
42
+ }
43
+
44
+ export const getFileVariantMaker = (): FileVariantMaker | undefined =>
45
+ SourceRepository.get().object<FileVariantMaker>('@proteinjs/db-file/FileVariantMaker');
46
+
47
+ /**
48
+ * What a derivation becomes when the registered maker could not make the variant of these
49
+ * bytes: nothing is stored. Thrown by `FileStorage` in place of whatever the maker threw (the
50
+ * maker's own one-line reason kept as `reason`), so the ingest door can report it to the caller
51
+ * holding the bytes and the read path can tell it from a failure of the store or the database
52
+ * (and serve the file itself). Read by shape ({@link FileVariantNotMade.is}), like
53
+ * `FileCopyRefused`, so it holds across duplicate copies of this package.
54
+ */
55
+ export class FileVariantNotMade extends Error {
56
+ private static readonly NAME = 'FileVariantNotMade';
57
+ /** Free text that reaches a message is one line and bounded. */
58
+ private static readonly MAX_REASON_CHARS = 300;
59
+ readonly fileId: string;
60
+ readonly kind: FileVariantKind;
61
+ readonly reason: string;
62
+
63
+ constructor(fileId: string, kind: FileVariantKind, cause: unknown) {
64
+ const reason = FileVariantNotMade.plain(cause);
65
+ super(`No ${kind} variant could be made of file ${fileId}${reason ? `: ${reason}` : ''}`);
66
+ this.name = FileVariantNotMade.NAME;
67
+ Object.setPrototypeOf(this, FileVariantNotMade.prototype);
68
+ this.fileId = fileId;
69
+ this.kind = kind;
70
+ this.reason = reason;
71
+ }
72
+
73
+ static is(error: unknown): error is FileVariantNotMade {
74
+ return (error as { name?: unknown } | null | undefined)?.name === FileVariantNotMade.NAME;
75
+ }
76
+
77
+ private static plain(cause: unknown): string {
78
+ const message = cause instanceof Error ? cause.message : typeof cause === 'string' ? cause : '';
79
+ const line = message.replace(/\s+/g, ' ').trim();
80
+ return line.length > FileVariantNotMade.MAX_REASON_CHARS
81
+ ? `${line.slice(0, FileVariantNotMade.MAX_REASON_CHARS)}…`
82
+ : line;
83
+ }
84
+ }
@@ -0,0 +1,85 @@
1
+ import { File } from '../tables/FileTable';
2
+ import { FileStorage } from '../FileStorage';
3
+ import { FileStorageError } from '../FileStorageError';
4
+ import { ServiceRefusal } from '@proteinjs/service';
5
+ import { resolveByteRange } from './byteRange';
6
+
7
+ /** The express members the file routes touch — typed narrowly so the routes and their tests agree. */
8
+ export type FileRequest = { params: Record<string, string>; headers?: { range?: string } };
9
+ export type FileResponse = {
10
+ status(code: number): { send(body: unknown): unknown };
11
+ send(body: unknown): unknown;
12
+ setHeader(name: string, value: string): void;
13
+ redirect(status: number, url: string): void;
14
+ };
15
+
16
+ /**
17
+ * THE ONE SERVING PATH for a file's bytes — `GET /file/:id` and `GET /file/:id/variant/:kind`
18
+ * answer through it, so a variant is served exactly as any File is. Two shapes:
19
+ *
20
+ * - **302 redirect** to a short-lived signed URL when the driver's store has an external URL
21
+ * space (GCS). The app URL stays the one stable reference every `<img>`/`<video>`/chip uses;
22
+ * the client follows the redirect and reads the store directly — native Range/206 for video
23
+ * seeking, real caching, bytes never transit the app server. The redirect itself is cached
24
+ * briefly (client-private, well under the signed TTL) so repeated loads reuse one URL.
25
+ * - **Proxy** for drivers with no external URL space (`DbFileStorageDriver`): the interface's
26
+ * base64 is decoded so every mime — binary or text — serves its true bytes, and HTTP Range
27
+ * is honored (206/416 via {@link resolveByteRange}) so a `<video>` can SEEK against a
28
+ * proxy-served blob — streaming parity with the signed-URL path instead of download-then-watch.
29
+ *
30
+ * WHICH bytes is `FileStorage`'s decision (`getSignedUrl` / `getFileData` substitute the copy
31
+ * for others for anyone but the owner); the headers are the row's (its name and type). The
32
+ * route decided existence and access before calling here (`FileStorage.getFile`).
33
+ */
34
+ export class FileResponder {
35
+ /** Serve `file` (a row the caller may read) to `response`; every failure shape mapped to its status. */
36
+ static async serve(file: File, request: FileRequest, response: FileResponse): Promise<void> {
37
+ const fileStorage = new FileStorage();
38
+ try {
39
+ const signedUrl = await fileStorage.getSignedUrl(file.id);
40
+ if (signedUrl) {
41
+ response.setHeader('Cache-Control', 'private, max-age=300');
42
+ response.redirect(302, signedUrl);
43
+ return;
44
+ }
45
+
46
+ const bytes = Buffer.from(await fileStorage.getFileData(file.id), 'base64');
47
+ const safeFilename = encodeURIComponent(file.name);
48
+ response.setHeader('Content-Disposition', `inline; filename="${safeFilename}"`);
49
+ response.setHeader('Content-Type', file.type);
50
+ response.setHeader('Accept-Ranges', 'bytes');
51
+ const range = resolveByteRange(request.headers?.range, bytes.length);
52
+ if (range === 'unsatisfiable') {
53
+ response.setHeader('Content-Range', `bytes */${bytes.length}`);
54
+ response.status(416).send('Range Not Satisfiable');
55
+ return;
56
+ }
57
+ if (range) {
58
+ response.setHeader('Content-Range', `bytes ${range.start}-${range.end}/${bytes.length}`);
59
+ response.status(206).send(bytes.subarray(range.start, range.end + 1));
60
+ return;
61
+ }
62
+ response.send(bytes);
63
+ } catch (error) {
64
+ FileResponder.fail(file.id, error, response);
65
+ }
66
+ }
67
+
68
+ /** The failure shapes a file read has: bytes missing behind a row (404), a refusal (its status, 404), anything else (500, logged). */
69
+ static fail(fileId: string, error: unknown, response: FileResponse): void {
70
+ if (FileStorageError.isNotFound(error)) {
71
+ // The row is there and its bytes are not: the same answer as a row that is not there.
72
+ response.status(404).send('File not found');
73
+ return;
74
+ }
75
+ if (ServiceRefusal.is(error)) {
76
+ // A deliberate refusal, not a failure — a file no copy can be made of is not there for anyone
77
+ // but its owner (`FileStorage` answers every door the same 404 and keeps the maker's reason in
78
+ // its own WARN). The same words as a row the caller cannot read; quiet, and never a 500.
79
+ response.status(error.status).send('File not found');
80
+ return;
81
+ }
82
+ console.error(`Error fetching file (${fileId}):`, error);
83
+ response.status(500).send('Internal Server Error');
84
+ }
85
+ }
@@ -1,23 +1,15 @@
1
1
  import { Route } from '@proteinjs/server-api';
2
2
  import { getFileStorage } from '../FileStorage';
3
3
  import { UserAuth } from '@proteinjs/user';
4
- import { resolveByteRange } from './byteRange';
4
+ import { FileResponder } from './FileResponder';
5
5
 
6
6
  /**
7
7
  * Serves a file's bytes. Auth first: logged-in, then the `FileStorage.getFile` row read as the
8
- * access decision — the caller's SCOPED read, else the shared-content reachability leg
9
- * (`FileReachabilityResolver`: the caller may read a file when they may read a row that
10
- * references it, e.g. a shared thought's media node). Then one of two serving paths:
11
- *
12
- * - **302 redirect** to a short-lived signed URL when the driver's store has an external URL
13
- * space (GCS). The app URL stays the one stable reference every `<img>`/`<video>`/chip uses;
14
- * the client follows the redirect and reads the store directly — native Range/206 for video
15
- * seeking, real caching, bytes never transit the app server. The redirect itself is cached
16
- * briefly (client-private, well under the signed TTL) so repeated loads reuse one URL.
17
- * - **Proxy** for drivers with no external URL space (`DbFileStorageDriver`): the interface's
18
- * base64 is decoded so every mime — binary or text — serves its true bytes, and HTTP Range
19
- * is honored (206/416 via {@link resolveByteRange}) so a `<video>` can SEEK against a
20
- * proxy-served blob — streaming parity with the signed-URL path instead of download-then-watch.
8
+ * access decision — the caller's SCOPED read, else a variant's original, else the shared-content
9
+ * reachability leg (`FileReachabilityResolver`: the caller may read a file when they may read a
10
+ * row that references it, e.g. a shared thought's media node). Then {@link FileResponder} — the
11
+ * one serving path (a 302 to a signed URL, or the proxy with Range) every file route answers
12
+ * through.
21
13
  */
22
14
  export const getFile: Route = {
23
15
  path: '/file/:id',
@@ -29,43 +21,16 @@ export const getFile: Route = {
29
21
  }
30
22
 
31
23
  const fileId = request.params.id;
32
- const fileStorage = getFileStorage();
33
24
  try {
34
25
  // The file row decides existence (a scoped-or-reachable read, so it is also the access check).
35
- const file = await fileStorage.getFile(fileId);
26
+ const file = await getFileStorage().getFile(fileId);
36
27
  if (!file) {
37
28
  response.status(404).send('File not found');
38
29
  return;
39
30
  }
40
-
41
- const signedUrl = await fileStorage.getSignedUrl(fileId);
42
- if (signedUrl) {
43
- response.setHeader('Cache-Control', 'private, max-age=300');
44
- response.redirect(302, signedUrl);
45
- return;
46
- }
47
-
48
- const fileDataBase64 = await fileStorage.getFileData(fileId);
49
- const bytes = Buffer.from(fileDataBase64, 'base64');
50
- const safeFilename = encodeURIComponent(file.name);
51
- response.setHeader('Content-Disposition', `inline; filename="${safeFilename}"`);
52
- response.setHeader('Content-Type', file.type);
53
- response.setHeader('Accept-Ranges', 'bytes');
54
- const range = resolveByteRange(request.headers?.range, bytes.length);
55
- if (range === 'unsatisfiable') {
56
- response.setHeader('Content-Range', `bytes */${bytes.length}`);
57
- response.status(416).send('Range Not Satisfiable');
58
- return;
59
- }
60
- if (range) {
61
- response.setHeader('Content-Range', `bytes ${range.start}-${range.end}/${bytes.length}`);
62
- response.status(206).send(bytes.subarray(range.start, range.end + 1));
63
- return;
64
- }
65
- response.send(bytes);
31
+ await FileResponder.serve(file, request, response);
66
32
  } catch (error) {
67
- console.error(`Error fetching file (${fileId}):`, error);
68
- response.status(500).send('Internal Server Error');
33
+ FileResponder.fail(fileId, error, response);
69
34
  }
70
35
  },
71
36
  };
@@ -0,0 +1,53 @@
1
+ import { Route } from '@proteinjs/server-api';
2
+ import { UserAuth } from '@proteinjs/user';
3
+ import { FileStorage } from '../FileStorage';
4
+ import { FILE_VARIANT_KINDS, FileVariantKind } from '../FileVariantMaker';
5
+ import { FileResponder } from './FileResponder';
6
+
7
+ /**
8
+ * Serves a file's VARIANT of a kind (`preview`, `stage`) — the one URL a surface that draws a
9
+ * variant asks by (a plain `<img src>`, an off-DOM warm-up), whether or not the file has it yet:
10
+ *
11
+ * - the variant the row names is served as any File is ({@link FileResponder} — a 302 to ITS
12
+ * signed URL or the proxy, ITS headers; a non-owner is served ITS copy for others);
13
+ * - a file WITHOUT the variant derives it on this first request, once, through the registered
14
+ * `FileVariantMaker` (`FileStorage.getVariant` — a file stored before the kind existed, or by a
15
+ * producer that did not derive it; the row's word is decided in one transaction, so a second
16
+ * request never re-derives);
17
+ * - a file the maker does not apply to (a clip, a picture already small), or no maker at all, is
18
+ * served AS ITSELF — the consumer always gets a picture from this URL and never branches.
19
+ *
20
+ * Auth as `/file/:id`: logged-in, then the original's `getFile` read is the access decision (a
21
+ * variant is readable by whoever can read its original). A kind the library does not know is a
22
+ * 404, as a file that is not there.
23
+ */
24
+ export const getFileVariant: Route = {
25
+ path: '/file/:id/variant/:kind',
26
+ method: 'get',
27
+ onRequest: async (request, response): Promise<void> => {
28
+ if (!UserAuth.isLoggedIn()) {
29
+ response.status(401).send('User not logged in');
30
+ return;
31
+ }
32
+
33
+ const fileId = request.params.id;
34
+ const kind = request.params.kind as FileVariantKind;
35
+ if (!FILE_VARIANT_KINDS.includes(kind)) {
36
+ response.status(404).send('File not found');
37
+ return;
38
+ }
39
+ try {
40
+ const fileStorage = new FileStorage();
41
+ // The variant first (its own access read of the original inside); the file itself only when
42
+ // none applies — so the common request costs one row read before the bytes.
43
+ const served = (await fileStorage.getVariant(fileId, kind)) ?? (await fileStorage.getFile(fileId));
44
+ if (!served) {
45
+ response.status(404).send('File not found');
46
+ return;
47
+ }
48
+ await FileResponder.serve(served, request, response);
49
+ } catch (error) {
50
+ FileResponder.fail(fileId, error, response);
51
+ }
52
+ },
53
+ };
@@ -0,0 +1,7 @@
1
+ import type { FileVariantKind } from '../FileVariantMaker';
2
+
3
+ /** The URL a surface asks a file's variant of a kind by — served by `GET /file/:id/variant/:kind` (the variant, or the file itself when none applies). */
4
+ export const getFileVariantRoute = {
5
+ path: (fileId: string, kind: FileVariantKind) => `/file/${fileId}/variant/${kind}`,
6
+ method: 'get',
7
+ };
@@ -11,8 +11,41 @@ export interface File extends ScopedRecord {
11
11
  * Optional preview — another `File` (stored the same way, in GCS) used as a preview/thumbnail of
12
12
  * this file's content, e.g. a recording GIF's preview frame. A reference, not an inline blob, so
13
13
  * the bytes stay out of the DB row (consistent with the storage model); deleted with this file.
14
+ *
15
+ * A VARIANT — one of the derived Files of this file the {@link FileVariantMaker} seam makes
16
+ * (`preview`, `stage`): the same lifecycle for each — named here, made once (at ingest with the
17
+ * bytes in hand, or lazily on the first request through `FileStorage.getVariant`), dropped when
18
+ * this file's bytes change (`FileStorage.updateFileData`), deleted with this file (cascade), and
19
+ * readable by whoever can read this file (`variantOf`).
14
20
  */
15
- preview?: Reference<File>;
21
+ preview?: Reference<File> | null;
22
+ /**
23
+ * The stage variant — a second derived `File` sized for the surface that shows the picture large
24
+ * but not full-screen (a 1600 px longest-edge rendition where the preview is a 512 px thumbnail):
25
+ * the same mechanism as `preview` in every respect (see there). Absent on files made before the
26
+ * seam or of a kind the maker does not apply to (a video, an animated picture); the first request
27
+ * derives it once. A viewer that wants every pixel keeps drawing the original.
28
+ */
29
+ stage?: Reference<File> | null;
30
+ /**
31
+ * For a variant (`preview`/`stage`) made by the seam: the File it was derived from. The
32
+ * variant's reachability is its original's — a reader who can read the original (their own
33
+ * scope, or a row the shared-content leg vouches for) can read the variant by its own id, even
34
+ * when no content row names the variant (a variant derived after the content was placed). Never
35
+ * set on a `copyForOthers` copy, whose own id is reachable by nobody but the owner. No cascade
36
+ * in either direction: the original's own reference (`preview`/`stage`) deletes the variant.
37
+ */
38
+ variantOf?: Reference<File> | null;
39
+ /**
40
+ * The copy of this file served to anyone who is not its owner — another `File` (the same
41
+ * store, the owner's scope), made ONCE by the registered {@link FileCopyForOthers} the first
42
+ * time a non-owner reads the bytes, named here so every later read serves it without remaking
43
+ * it. `null`/absent means no copy has been made yet: the next non-owner read makes one when the
44
+ * maker applies to this file, and serves the original when it does not. Dropped when the
45
+ * original's bytes change (`FileStorage.updateFileData`) and deleted with this file (cascade).
46
+ * The owner is never served it — their own scoped read serves the original.
47
+ */
48
+ copyForOthers?: Reference<File> | null;
16
49
  /**
17
50
  * Media metadata — generic file facts (an image/video's pixel dimensions, a video/audio
18
51
  * duration) every consumer needs to render without loading bytes, e.g. reserving a media box's
@@ -49,6 +82,21 @@ export interface File extends ScopedRecord {
49
82
  * an integrity fact. Absent for files written before the column existed.
50
83
  */
51
84
  contentHash?: string;
85
+ /**
86
+ * Producer attribution — HOW these bytes came to exist (e.g. a user upload, a browser
87
+ * capture, model generation, a rendered mockup). Same layer ruling as `width`/`height`:
88
+ * provenance is a generic file fact consumers need without a join — e.g. evidence surfaces
89
+ * that must refuse non-capture media as proof. Set by ingest paths that know their producer;
90
+ * absent for everything else. Values are the producing domain's vocabulary, not enumerated
91
+ * here.
92
+ */
93
+ origin?: string;
94
+ /**
95
+ * The id of the model that made the bytes, when a model did — `origin` names the KIND of
96
+ * producer, this names the one. Absent for every other producer. Read by a consumer that
97
+ * labels a made file with what made it, without a join.
98
+ */
99
+ originModel?: string;
52
100
  }
53
101
 
54
102
  export class FileTable extends Table<File> {
@@ -67,6 +115,12 @@ export class FileTable extends Table<File> {
67
115
  size: new IntegerColumn('size'),
68
116
  // Self-reference (the preview is itself a File). cascadeDelete: removing a file removes its preview.
69
117
  preview: new ReferenceColumn<File>('preview', FILE_TABLE_NAME, true),
118
+ // Self-reference (the stage variant is itself a File). cascadeDelete: removing a file removes its stage variant.
119
+ stage: new ReferenceColumn<File>('stage', FILE_TABLE_NAME, true),
120
+ // Self-reference (a variant names its original). No cascade: the original's own reference deletes the variant.
121
+ variantOf: new ReferenceColumn<File>('variant_of', FILE_TABLE_NAME, false),
122
+ // Self-reference (the copy is itself a File). cascadeDelete: removing a file removes the copy others were served.
123
+ copyForOthers: new ReferenceColumn<File>('copy_for_others', FILE_TABLE_NAME, true),
70
124
  width: new IntegerColumn('width'),
71
125
  height: new IntegerColumn('height'),
72
126
  durationMs: new IntegerColumn('duration_ms'),
@@ -80,6 +134,8 @@ export class FileTable extends Table<File> {
80
134
  licenseUrl: new StringColumn('license_url', {}, 'MAX'),
81
135
  attribution: new StringColumn('attribution', {}, 'MAX'),
82
136
  contentHash: new StringColumn('content_hash', {}, 64),
137
+ origin: new StringColumn('origin', undefined, 50),
138
+ originModel: new StringColumn('origin_model', undefined, 200),
83
139
  });
84
140
  // Dedup lookup path: find the caller's existing copy of these bytes (content_hash is only
85
141
  // ever queried per-user — createScopedIndex prefixes the scope column).
@@ -0,0 +1,85 @@
1
+ import { QueryBuilderFactory, getDbAsSystem } from '@proteinjs/db';
2
+ import { User } 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 { FileStorageError } from '../src/FileStorageError';
7
+ import { DbFileStorageDriver } from '../src/DbFileStorageDriver';
8
+ import { FileTestEnvironment } from './FileTestEnvironment';
9
+
10
+ /**
11
+ * The driver contract has one answer for bytes that are not there: the `not-found` code. An
12
+ * object store says it for a missing object; this driver's store is the chunk table, where
13
+ * "nothing stored" and "an empty file" both used to read as zero chunk rows and came back as `''`
14
+ * — so a caller could not tell a lost file from an empty one, and code that branched on the
15
+ * object store's error never ran against this driver.
16
+ */
17
+ const testEnv = new FileTestEnvironment();
18
+ const driver = new DbFileStorageDriver();
19
+ let owner: User;
20
+
21
+ beforeAll(async () => {
22
+ await testEnv.beforeAll();
23
+ testEnv.setDriver(driver);
24
+ owner = await testEnv.createUser({ name: 'File owner', email: 'missing-bytes-owner@test.local' });
25
+ testEnv.actAs(owner);
26
+ });
27
+
28
+ afterAll(async () => {
29
+ await testEnv.afterAll();
30
+ });
31
+
32
+ const chunkRows = async (fileId: string) =>
33
+ await getDbAsSystem().query(
34
+ tables.FileData,
35
+ new QueryBuilderFactory().getQueryBuilder(tables.FileData, { file: fileId })
36
+ );
37
+
38
+ describe('DbFileStorageDriver reports bytes that are not there as not-found', () => {
39
+ it('a file row whose bytes are gone is not-found, not an empty string', async () => {
40
+ const bytes = Buffer.from('the bytes of a picture');
41
+ const file = await new FileStorage().createFile(
42
+ { name: 'holiday.jpg', type: 'image/jpeg', size: bytes.length } as File,
43
+ bytes.toString('base64')
44
+ );
45
+ await driver.deleteFile(file.id);
46
+
47
+ const failure = await driver.getFileData(file.id).catch((error) => error);
48
+
49
+ expect(FileStorageError.isNotFound(failure)).toBe(true);
50
+ expect(failure.message).toEqual(`getFileData failed for file ${file.id}: not-found`);
51
+ });
52
+
53
+ it('an id that names no file at all is not-found', async () => {
54
+ const failure = await driver.getFileData('no-such-file-id').catch((error) => error);
55
+
56
+ expect(FileStorageError.isNotFound(failure)).toBe(true);
57
+ });
58
+
59
+ it('an empty file is stored, and reads back as empty', async () => {
60
+ const file = await new FileStorage().createFile({ name: 'empty.txt', type: 'text/plain', size: 0 } as File, '');
61
+
62
+ expect(await chunkRows(file.id)).toHaveLength(1);
63
+ expect(await driver.getFileData(file.id)).toEqual('');
64
+ });
65
+
66
+ it('a file overwritten with nothing stays stored, whatever its row says about its size', async () => {
67
+ const bytes = Buffer.from('first');
68
+ const file = await new FileStorage().createFile(
69
+ { name: 'note.txt', type: 'text/plain', size: bytes.length } as File,
70
+ bytes.toString('base64')
71
+ );
72
+
73
+ await driver.updateFileData(file.id, '');
74
+
75
+ expect(await driver.getFileData(file.id)).toEqual('');
76
+ });
77
+
78
+ it('an empty file written before empty files had a chunk row still reads back as empty', async () => {
79
+ const file = await new FileStorage().createFile({ name: 'old-empty.txt', type: 'text/plain', size: 0 } as File, '');
80
+ await driver.deleteFile(file.id);
81
+ expect(await chunkRows(file.id)).toHaveLength(0);
82
+
83
+ expect(await driver.getFileData(file.id)).toEqual('');
84
+ });
85
+ });
@@ -1,5 +1,6 @@
1
1
  import { SourceRepository } from '@proteinjs/reflection';
2
2
  import { User } from '@proteinjs/user';
3
+ import { ServiceRefusal } from '@proteinjs/service';
3
4
  import { File } from '../src/tables/FileTable';
4
5
  import { FileStorage } from '../src/FileStorage';
5
6
  import { DbFileStorageDriver } from '../src/DbFileStorageDriver';
@@ -95,7 +96,12 @@ describe('byte reads gate on the file-row read (the getFile decision)', () => {
95
96
  const file = await createOwnerFile('private-bytes.bin');
96
97
 
97
98
  testEnv.actAs(stranger);
98
- await expect(new FileStorage().getFileData(file.id)).rejects.toThrow(`File not found: ${file.id}`);
99
+ const denial = await new FileStorage().getFileData(file.id).then(
100
+ () => undefined,
101
+ (error: unknown) => error
102
+ );
103
+ // The one answer a byte door gives: a 404 refusal, never the id (a file not there reads the same).
104
+ expect(ServiceRefusal.is(denial) && [denial.status, denial.message]).toEqual([404, 'File not found']);
99
105
  });
100
106
 
101
107
  it('a shared-content recipient (resolver-vouched) reads the bytes byte-identical', async () => {