@proteinjs/db-file 1.8.4 → 1.9.1

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 (102) hide show
  1. package/CHANGELOG.md +41 -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 +158 -11
  18. package/dist/src/FileStorage.d.ts.map +1 -1
  19. package/dist/src/FileStorage.js +562 -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 +66 -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 +70 -2
  48. package/dist/src/tables/FileTable.d.ts.map +1 -1
  49. package/dist/src/tables/FileTable.js +30 -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/FileDerivedProvenance.test.d.ts +2 -0
  62. package/dist/test/FileDerivedProvenance.test.d.ts.map +1 -0
  63. package/dist/test/FileDerivedProvenance.test.js +420 -0
  64. package/dist/test/FileDerivedProvenance.test.js.map +1 -0
  65. package/dist/test/FileMediaMetadata.test.js +60 -5
  66. package/dist/test/FileMediaMetadata.test.js.map +1 -1
  67. package/dist/test/FileStorageError.test.d.ts +2 -0
  68. package/dist/test/FileStorageError.test.d.ts.map +1 -0
  69. package/dist/test/FileStorageError.test.js +68 -0
  70. package/dist/test/FileStorageError.test.js.map +1 -0
  71. package/dist/test/FileTestEnvironment.d.ts.map +1 -1
  72. package/dist/test/FileTestEnvironment.js +3 -0
  73. package/dist/test/FileTestEnvironment.js.map +1 -1
  74. package/dist/test/FileVariants.test.d.ts +2 -0
  75. package/dist/test/FileVariants.test.d.ts.map +1 -0
  76. package/dist/test/FileVariants.test.js +1008 -0
  77. package/dist/test/FileVariants.test.js.map +1 -0
  78. package/dist/test/GetFileRoute.test.js +29 -0
  79. package/dist/test/GetFileRoute.test.js.map +1 -1
  80. package/generated/index.ts +3 -1
  81. package/index.ts +4 -0
  82. package/package.json +5 -5
  83. package/src/DbFileStorageDriver.ts +32 -21
  84. package/src/FileCopyForOthers.ts +71 -0
  85. package/src/FileStorage.ts +377 -19
  86. package/src/FileStorageDriver.ts +12 -0
  87. package/src/FileStorageError.ts +123 -0
  88. package/src/FileVariantMaker.ts +89 -0
  89. package/src/routes/FileResponder.ts +85 -0
  90. package/src/routes/getFile.ts +9 -44
  91. package/src/routes/getFileVariant.ts +53 -0
  92. package/src/routes/getFileVariantRoute.ts +7 -0
  93. package/src/tables/FileTable.ts +82 -1
  94. package/test/DbFileStorageDriverMissingBytes.test.ts +85 -0
  95. package/test/FileByteAccess.test.ts +7 -1
  96. package/test/FileCopyForOthers.test.ts +702 -0
  97. package/test/FileDerivedProvenance.test.ts +248 -0
  98. package/test/FileMediaMetadata.test.ts +44 -0
  99. package/test/FileStorageError.test.ts +79 -0
  100. package/test/FileTestEnvironment.ts +3 -0
  101. package/test/FileVariants.test.ts +604 -0
  102. package/test/GetFileRoute.test.ts +19 -0
@@ -0,0 +1,71 @@
1
+ import { Loadable, SourceRepository } from '@proteinjs/reflection';
2
+ import { File } from './tables/FileTable';
3
+
4
+ /**
5
+ * What a file becomes when it is served to someone who is not its owner.
6
+ *
7
+ * A file's bytes are the owner's, kept exactly as they arrived. When a read resolves through the
8
+ * shared-content leg instead of the owner's own scope (`FileStorage.getFile`: a share recipient
9
+ * reading a file a shared row references), `FileStorage` serves a COPY made by the one registered
10
+ * maker — the package that knows the file's format decides what leaves in it (a picture's
11
+ * location, say, does not). The copy is made once per file, stored as its own `File` in the
12
+ * owner's scope and named on the original's row (`File.copyForOthers`), so every later read by
13
+ * anyone but the owner serves the same bytes without remaking them; it is dropped when the
14
+ * original's bytes change and deleted with the original.
15
+ *
16
+ * Dependency direction is the {@link FileReachabilityResolver} pattern: the seam lives here in
17
+ * db-file, the one implementation lives in the package that knows media, discovered through the
18
+ * SourceRepository. With no maker registered, everyone is served the original.
19
+ */
20
+ export interface FileCopyForOthers extends Loadable {
21
+ /**
22
+ * Whether this file is served to others through a copy at all — decided from the row alone,
23
+ * never from its bytes (a plain document serves as itself and costs no read). True means
24
+ * {@link make} is called once, with the original's bytes, and its answer is what others get.
25
+ */
26
+ appliesTo(file: File): boolean;
27
+ /** The bytes served to anyone but the owner, made from the original's. Throws when no copy can be made — the read then fails rather than serving the original. */
28
+ make(file: File, bytes: Buffer): Promise<Buffer>;
29
+ }
30
+
31
+ export const getFileCopyForOthers = (): FileCopyForOthers | undefined =>
32
+ SourceRepository.get().object<FileCopyForOthers>('@proteinjs/db-file/FileCopyForOthers');
33
+
34
+ /**
35
+ * What a non-owner's read becomes when the registered maker could not make a copy: the file is
36
+ * served to no one but its owner. The seam's own cause, thrown by `FileStorage` in place of
37
+ * whatever the maker threw (the maker's own one-line reason kept as `reason`) and answered by
38
+ * `FileStorage` itself, once for every door — the browser service, `GET /file/:id`, a server-side
39
+ * reader — as a 404 `ServiceRefusal` saying only `File not found`, so a door never meets it: a
40
+ * deliberate refusal every door tells from a failure the same way. Its message and `reason` stay
41
+ * on the server (`FileStorage` logs them at WARN); a caller reads the words a missing file gets.
42
+ * Read by shape ({@link FileCopyRefused.is}), so it holds across duplicate copies of this package.
43
+ */
44
+ export class FileCopyRefused extends Error {
45
+ private static readonly NAME = 'FileCopyRefused';
46
+ /** Free text that reaches a message is one line and bounded. */
47
+ private static readonly MAX_REASON_CHARS = 300;
48
+ readonly fileId: string;
49
+ readonly reason: string;
50
+
51
+ constructor(fileId: string, cause: unknown) {
52
+ const reason = FileCopyRefused.plain(cause);
53
+ super(`File ${fileId} is not available to anyone but its owner${reason ? `: ${reason}` : ''}`);
54
+ this.name = FileCopyRefused.NAME;
55
+ Object.setPrototypeOf(this, FileCopyRefused.prototype);
56
+ this.fileId = fileId;
57
+ this.reason = reason;
58
+ }
59
+
60
+ static is(error: unknown): error is FileCopyRefused {
61
+ return (error as { name?: unknown } | null | undefined)?.name === FileCopyRefused.NAME;
62
+ }
63
+
64
+ private static plain(cause: unknown): string {
65
+ const message = cause instanceof Error ? cause.message : typeof cause === 'string' ? cause : '';
66
+ const line = message.replace(/\s+/g, ' ').trim();
67
+ return line.length > FileCopyRefused.MAX_REASON_CHARS
68
+ ? `${line.slice(0, FileCopyRefused.MAX_REASON_CHARS)}…`
69
+ : line;
70
+ }
71
+ }
@@ -1,11 +1,21 @@
1
- import { ScopedRecord, getScopedDb, getScopedDbAsSystem } from '@proteinjs/user';
2
- import { File } from './tables/FileTable';
1
+ import { Reference, getDbAsSystem } from '@proteinjs/db';
2
+ import { ScopedRecord, UserRepo, getScopedDb, getScopedDbAsSystem } from '@proteinjs/user';
3
+ import { File, FileTable } from './tables/FileTable';
3
4
  import { tables } from './tables/tables';
4
5
  import { FileStorageService, getFileStorageService } from './services/FileStorageService';
5
6
  import { FileStorageDriver } from './FileStorageDriver';
6
7
  import { getFileReachabilityResolvers } from './FileReachabilityResolver';
8
+ import { FileCopyRefused, getFileCopyForOthers } from './FileCopyForOthers';
9
+ import {
10
+ FILE_VARIANT_KINDS,
11
+ FileVariant,
12
+ FileVariantKind,
13
+ FileVariantNotMade,
14
+ getFileVariantMaker,
15
+ } from './FileVariantMaker';
7
16
  import { Loadable, SourceRepository } from '@proteinjs/reflection';
8
17
  import { Logger } from '@proteinjs/logger';
18
+ import { ServiceRefusal } from '@proteinjs/service';
9
19
  import { DbFileStorageDriver } from './DbFileStorageDriver';
10
20
 
11
21
  /**
@@ -29,12 +39,15 @@ export interface DefaultFileStorageDriverFactory extends Loadable {
29
39
  */
30
40
  export class FileStorage implements FileStorageService {
31
41
  private static driver: FileStorageDriver;
42
+ /** The read path's derivations running in this process, by file and kind — see {@link deriveOnce}. */
43
+ private static derivations = new Map<string, Promise<string | undefined>>();
32
44
 
33
45
  public serviceMetadata = {
34
46
  auth: {
35
47
  allUsers: true,
36
48
  },
37
49
  };
50
+ private logger = new Logger({ name: this.constructor.name });
38
51
 
39
52
  /**
40
53
  * The `FileStorageDriver` for this process — provided by the `DefaultFileStorageDriverFactory`
@@ -77,11 +90,16 @@ export class FileStorage implements FileStorageService {
77
90
  * Retrieves the metadata of a given file — THE file-read access decision every serving path
78
91
  * derives from (`/file/:id`, signed-URL minting, the browser service).
79
92
  *
80
- * Two legs: the caller's own file resolves by the SCOPED read; when that misses, the
81
- * shared-content leg asks the registered {@link FileReachabilityResolver}s whether the caller
82
- * can read a row that REFERENCES the file (a shared thought's media node) — content access
83
- * confers file access, through the content's own grant-filtered read, never by widening file
84
- * scope. No resolver vouching means the miss stands. Reads only: writes/deletes stay scoped.
93
+ * Three legs, cheapest first: the caller's own file resolves by the SCOPED read. When that
94
+ * misses, the derived leg: a VARIANT the seam made (`File.variantOf`) is readable by whoever
95
+ * can read its original — this same decision, asked of the original — so a variant derived
96
+ * after the content was placed (no content row names it) is reachable by exactly the readers
97
+ * of the file it was made from; one system point read decides whether the row is a variant at
98
+ * all. Last, the shared-content leg asks the registered {@link FileReachabilityResolver}s
99
+ * whether the caller can read a row that REFERENCES the file (a shared thought's media node) —
100
+ * content access confers file access, through the content's own grant-filtered read, never by
101
+ * widening file scope. No leg vouching means the miss stands. Reads only: writes/deletes stay
102
+ * scoped.
85
103
  * @param fileId - The `id` of the file.
86
104
  * @returns The file metadata, or `undefined` when the caller can neither own nor reach it.
87
105
  */
@@ -92,15 +110,84 @@ export class FileStorage implements FileStorageService {
92
110
  return file;
93
111
  }
94
112
 
113
+ const row = await getScopedDbAsSystem().get(tables.File, { id: fileId });
114
+ if (!row) {
115
+ return undefined as unknown as File;
116
+ }
117
+ if (row.variantOf?._id) {
118
+ // A variant's reachability is its original's — the copy for others carries no `variantOf`,
119
+ // so its own id stays nobody's but the owner's.
120
+ return (await this.getFile(row.variantOf._id)) ? row : (undefined as unknown as File);
121
+ }
122
+
95
123
  for (const resolver of getFileReachabilityResolvers()) {
96
124
  if (await resolver.canReadViaReference(fileId)) {
97
125
  // Reachability established through the caller's grant-filtered content read — the row
98
126
  // itself lives in the owner's scope, so it is served via system read.
99
- return await getScopedDbAsSystem().get(tables.File, { id: fileId });
127
+ return row;
100
128
  }
101
129
  }
102
130
 
103
- return file;
131
+ return undefined as unknown as File;
132
+ }
133
+
134
+ /**
135
+ * The variant of this kind of a file — the derived File the row names, or one made now, ONCE,
136
+ * by the registered {@link FileVariantMaker} (the read path's door, behind
137
+ * `GET /file/:id/variant/:kind`: a file made before the seam existed, or by a producer that did
138
+ * not derive it, gets its variant on the first request from a surface that draws it; every
139
+ * later request is served the same row without the maker). Server-only, like `getSignedUrl` —
140
+ * the browser asks by URL, never by service call. Access is the original's ({@link getFile});
141
+ * the variant is stored as the owner's own File whoever's read made it, carrying the original's
142
+ * provenance ({@link FileTable.provenanceColumns}), and is served like any File afterwards —
143
+ * with the same non-owner copy rule.
144
+ * @returns The variant's File row, or `undefined` when the caller cannot read the original, no
145
+ * maker is registered, the maker does not apply to this file for this kind, or the
146
+ * maker could not make it of these bytes (the consumer then draws the original).
147
+ */
148
+ async getVariant(fileId: string, kind: FileVariantKind): Promise<File | undefined> {
149
+ const file = await this.getFile(fileId);
150
+ if (!file) {
151
+ return undefined;
152
+ }
153
+ const named = file[kind]?._id;
154
+ if (named) {
155
+ return await getScopedDbAsSystem().get(tables.File, { id: named });
156
+ }
157
+ const maker = getFileVariantMaker();
158
+ if (!maker || !maker.appliesTo(file, kind)) {
159
+ return undefined;
160
+ }
161
+ const variantId = await this.deriveOnce(file, kind);
162
+ return variantId ? await getScopedDbAsSystem().get(tables.File, { id: variantId }) : undefined;
163
+ }
164
+
165
+ /**
166
+ * Make and store every variant the registered maker applies to, with the original's bytes in
167
+ * hand (the ingest's door — one decode of bytes just stored, never a re-read). Each is named on
168
+ * the row and carries the original's provenance ({@link FileTable.provenanceColumns}); a kind
169
+ * the maker does not apply to is left unset (the read path may derive it later if the maker's
170
+ * rule changes). With no maker registered nothing is made.
171
+ * @returns The caller's file row with each variant made here named on it (the row as the caller
172
+ * holds it, not a re-read — an unset column stays unset), and the variant Files made.
173
+ */
174
+ async deriveVariants(
175
+ file: File,
176
+ bytes: Buffer
177
+ ): Promise<{ file: File; variants: Partial<Record<FileVariantKind, File>> }> {
178
+ const maker = getFileVariantMaker();
179
+ const variants: Partial<Record<FileVariantKind, File>> = {};
180
+ const named: Partial<Pick<File, FileVariantKind>> = {};
181
+ if (maker) {
182
+ for (const kind of FILE_VARIANT_KINDS) {
183
+ if (!file[kind]?._id && maker.appliesTo(file, kind)) {
184
+ const variantId = await this.makeVariant(file, kind, bytes);
185
+ variants[kind] = await getScopedDbAsSystem().get(tables.File, { id: variantId });
186
+ named[kind] = new Reference<File>(tables.File.name, variantId);
187
+ }
188
+ }
189
+ }
190
+ return { file: { ...file, ...named }, variants };
104
191
  }
105
192
 
106
193
  /**
@@ -111,37 +198,65 @@ export class FileStorage implements FileStorageService {
111
198
  * exactly as reachable as the row that names them. The gate lives here because this is the
112
199
  * browser-facing service boundary (`FileStorageService` is `allUsers`) and the driver byte ops
113
200
  * below are deliberately unscoped. Server-side doors that make their OWN documented access
114
- * decision (the avatar route, issue/ticket attachment doors) read bytes through
115
- * `FileStorage.getDriver()` instead.
201
+ * decision (an attachment door keyed by a record's linkage, say) serve through
202
+ * {@link getAuthorizedFileData} — the same owner-or-copy bytes, without this gate.
203
+ *
204
+ * WHICH bytes: the owner's read serves the original; anyone else's — a read that reached the
205
+ * file through the shared-content leg — serves the {@link FileCopyForOthers} copy
206
+ * ({@link servedFileId}): the same substitution `getSignedUrl` makes, so the proxy route, the
207
+ * signed-URL route and every server-side reader of this door agree.
116
208
  * @param fileId - The `id` of the file.
117
209
  * @returns The file data as a single string.
118
- * @throws When the file row does not exist or is not readable by the caller — the same named
119
- * miss either way, so existence is not leaked to unauthorized callers.
210
+ * @throws ServiceRefusal (404, `File not found`) when the file row does not exist or is not
211
+ * readable by the caller, and when the caller is not the owner and no copy can be made
212
+ * ({@link servedFileId}) — ONE answer for all three ({@link notFound}), so no door tells
213
+ * a missing file from one this caller may not have.
120
214
  */
121
215
  async getFileData(fileId: string): Promise<string> {
122
216
  const file = await this.getFile(fileId);
123
217
  if (!file) {
124
- throw new Error(`File not found: ${fileId}`);
218
+ throw FileStorage.notFound();
125
219
  }
126
220
 
127
- return await FileStorage.getDriver().getFileData(fileId);
221
+ return await FileStorage.getDriver().getFileData(await this.servedFileId(file));
222
+ }
223
+
224
+ /**
225
+ * The bytes THIS caller is served for a file a server-side door has ALREADY decided the caller
226
+ * may read, by its own documented rule (an attachment door keyed by a record's linkage, say —
227
+ * a read neither the caller's scope nor a {@link FileReachabilityResolver} would open). The
228
+ * door vouches for access; this decides WHICH bytes, exactly as {@link getFileData} does: the
229
+ * original for the file's owner, the {@link FileCopyForOthers} copy for anyone else — the same
230
+ * copy, made once and named on the row, that every other door serves them. Server-only (not
231
+ * part of the browser-facing `FileStorageService`), so the rule that opens the file stays the
232
+ * door's.
233
+ * @param file - The file's row, as the door read it (its `scope` names the owner).
234
+ * @returns The file data as a single string.
235
+ * @throws ServiceRefusal (404) when the caller is not the owner and no copy can be made — the same
236
+ * refusal every door gives ({@link servedFileId}).
237
+ */
238
+ async getAuthorizedFileData(file: File): Promise<string> {
239
+ return await FileStorage.getDriver().getFileData(await this.servedFileId(file));
128
240
  }
129
241
 
130
242
  /**
131
243
  * Mint a short-lived, read-only URL for the file's bytes (server-only; not part of the
132
244
  * browser-facing `FileStorageService`). A signed URL is a bearer capability, so minting
133
245
  * verifies the caller can read the file row (the two-legged {@link getFile} read — own scope
134
- * or shared-content reachability) before signing.
246
+ * or shared-content reachability) before signing — and signs the bytes THAT caller is served:
247
+ * the original for the owner, the {@link FileCopyForOthers} copy for anyone else.
135
248
  * @param fileId - The `id` of the file.
136
249
  * @param options.ttlMs - How long the URL stays valid; the driver applies its default when omitted.
137
250
  * @returns The signed URL, or `undefined` when the driver has no external URL space
138
251
  * (`DbFileStorageDriver`) — the caller then serves bytes through the proxy route.
139
- * @throws When the file row does not exist or is not readable by the caller.
252
+ * @throws ServiceRefusal (404, `File not found`) when the file row does not exist or is not
253
+ * readable by the caller, and when the caller is not the owner and no copy can be made —
254
+ * the one answer {@link getFileData} gives ({@link notFound}).
140
255
  */
141
256
  async getSignedUrl(fileId: string, options?: { ttlMs?: number }): Promise<string | undefined> {
142
257
  const file = await this.getFile(fileId);
143
258
  if (!file) {
144
- throw new Error(`File not found: ${fileId}`);
259
+ throw FileStorage.notFound();
145
260
  }
146
261
 
147
262
  const driver = FileStorage.getDriver();
@@ -149,7 +264,7 @@ export class FileStorage implements FileStorageService {
149
264
  return undefined;
150
265
  }
151
266
 
152
- return await driver.getSignedUrl(fileId, options);
267
+ return await driver.getSignedUrl(await this.servedFileId(file), options);
153
268
  }
154
269
 
155
270
  /**
@@ -159,6 +274,10 @@ export class FileStorage implements FileStorageService {
159
274
  * (the same authorization a scoped row update enforces). Deliberately STRICTER than
160
275
  * {@link getFileData}: the {@link FileReachabilityResolver} leg widens READS only, so a
161
276
  * share recipient who can read the bytes still cannot write them.
277
+ *
278
+ * New bytes make every derived File stale — the copy others were served, the preview, the
279
+ * stage variant: each is dropped here — the row forgets it, then its row (and, through the
280
+ * delete watcher, its bytes) goes — and the next read that wants one makes a fresh one.
162
281
  * @param fileId - The `id` of the file.
163
282
  * @param data - The new data string to replace the existing data.
164
283
  * @throws When the file row does not exist or is not writable by the caller — the same named
@@ -172,6 +291,7 @@ export class FileStorage implements FileStorageService {
172
291
  }
173
292
 
174
293
  await FileStorage.getDriver().updateFileData(fileId, data);
294
+ await this.dropDerivedFiles(file);
175
295
  }
176
296
 
177
297
  /**
@@ -199,4 +319,242 @@ export class FileStorage implements FileStorageService {
199
319
  const db = getScopedDb();
200
320
  await db.delete(tables.File, { id: fileId });
201
321
  }
322
+
323
+ /**
324
+ * The id whose bytes this caller is served: the file's own for its owner, or for a file with
325
+ * no maker (or one the maker does not apply to); otherwise the {@link FileCopyForOthers} copy —
326
+ * the one already named on the row, or made now, once, and named for every later read.
327
+ *
328
+ * ONE REFUSAL, EVERY DOOR: when no copy can be made (the seam's {@link FileCopyRefused}), the file
329
+ * is served to no one but its owner, so to this caller it is not there — a
330
+ * `ServiceRefusal(404, 'File not found')`, thrown here so the browser service, `GET /file/:id` in
331
+ * both its shapes and the server-side door answer the same by construction: the service router
332
+ * answers 404 `{ error: 'File not found' }`, the executor logs one WARN (a refusal, never a
333
+ * failure), the route answers `404 File not found` — the same answer, at every door, as a row the
334
+ * caller cannot read ({@link notFound}). The refusal carries no reason, file id or owner: the
335
+ * maker's reason is the server's, logged here in one WARN, so nothing a caller reads tells a
336
+ * refused copy from a file that is not there.
337
+ */
338
+ private async servedFileId(file: File): Promise<string> {
339
+ if (this.ownedByCaller(file)) {
340
+ return file.id;
341
+ }
342
+ if (file.copyForOthers?._id) {
343
+ return file.copyForOthers._id;
344
+ }
345
+ const maker = getFileCopyForOthers();
346
+ if (!maker || !maker.appliesTo(file)) {
347
+ return file.id;
348
+ }
349
+ try {
350
+ return await this.makeCopyForOthers(file);
351
+ } catch (error) {
352
+ if (FileCopyRefused.is(error)) {
353
+ this.logger.warn({
354
+ message: 'No copy of a file can be made for anyone but its owner; refused as not found',
355
+ obj: { fileId: error.fileId, reason: error.reason },
356
+ });
357
+ throw FileStorage.notFound();
358
+ }
359
+ throw error;
360
+ }
361
+ }
362
+
363
+ /**
364
+ * Whether the caller is the file's owner — the row's `scope` is the owner (the scoped read
365
+ * {@link getFile} makes first is exactly `scope = the caller`), so a row {@link getFile} handed
366
+ * back whose scope is someone else's was reached through the shared-content leg. A row with no
367
+ * owner recorded serves as itself: there is no one to withhold it from.
368
+ */
369
+ private ownedByCaller(file: File): boolean {
370
+ return !file.scope || file.scope === new UserRepo().getUser().id;
371
+ }
372
+
373
+ /**
374
+ * Makes the copy (the maker's bytes, from the original's), stores it as a File in the owner's
375
+ * scope — the original's name, type, dimensions and provenance ({@link provenanceOf}) on it —
376
+ * and names it on the original's row. Two non-owners reading at once may both get here:
377
+ * the row's word wins — decided in ONE transaction (the read of the row and the write of its
378
+ * word together, so racing writers are serialized and the loser's transaction, retried, finds
379
+ * the winner named) — and a copy that lost is deleted after the commit, the first is served.
380
+ */
381
+ private async makeCopyForOthers(file: File): Promise<string> {
382
+ const maker = getFileCopyForOthers()!;
383
+ const driver = FileStorage.getDriver();
384
+ const original = Buffer.from(await driver.getFileData(file.id), 'base64');
385
+ let copyBytes: Buffer;
386
+ try {
387
+ copyBytes = await maker.make(file, original);
388
+ } catch (cause) {
389
+ // The maker's contract: a throw means no copy can be made — the file is the owner's alone.
390
+ throw new FileCopyRefused(file.id, cause);
391
+ }
392
+ // As system, in the OWNER's scope: the copy is the owner's file (their storage, their purge).
393
+ const system = getDbAsSystem();
394
+ const copy = await system.insert(tables.File, {
395
+ name: file.name,
396
+ type: file.type,
397
+ size: copyBytes.length,
398
+ ...(file.width !== undefined && file.width !== null ? { width: file.width } : {}),
399
+ ...(file.height !== undefined && file.height !== null ? { height: file.height } : {}),
400
+ ...(file.durationMs !== undefined && file.durationMs !== null ? { durationMs: file.durationMs } : {}),
401
+ ...this.provenanceOf(file),
402
+ scope: file.scope,
403
+ });
404
+ await driver.createFile(copy, copyBytes.toString('base64'));
405
+
406
+ const named = await system.runTransaction(async () => {
407
+ const current = await system.get(tables.File, { id: file.id });
408
+ if (current?.copyForOthers?._id) {
409
+ return current.copyForOthers._id;
410
+ }
411
+ await system.update(tables.File, { id: file.id, copyForOthers: new Reference<File>(tables.File.name, copy.id) });
412
+ return copy.id;
413
+ });
414
+ if (named !== copy.id) {
415
+ // Outside the transaction: the row's delete takes the bytes with it (the delete watcher), a
416
+ // side effect that must not ride a transaction the runner may retry.
417
+ await system.delete(tables.File, { id: copy.id });
418
+ }
419
+ return named;
420
+ }
421
+
422
+ /**
423
+ * The read path's derivation, ONCE per file and kind at a time in this process: the readers
424
+ * that ask for the same absent variant while one derives (a shared document opened by many on
425
+ * one tick — every face asks the same URL) wait for that derivation's answer instead of each
426
+ * reading the original, encoding it and storing an object the row's word would then discard.
427
+ * Across processes the row's word in one transaction ({@link makeVariant}) still decides: this
428
+ * bounds the cost, that bounds the outcome. A maker that cannot make the variant of these
429
+ * bytes (a picture its decoder cannot read) answers `undefined` — nothing is stored, the reader
430
+ * draws the file itself, the next reader asks again — said in the log, never as a failed read;
431
+ * any other failure (the store, the database) is the reader's as it would be for any File.
432
+ */
433
+ private deriveOnce(file: File, kind: FileVariantKind): Promise<string | undefined> {
434
+ const key = `${file.id}:${kind}`;
435
+ const running = FileStorage.derivations.get(key);
436
+ if (running) {
437
+ return running;
438
+ }
439
+ const derivation = (async () => {
440
+ const original = Buffer.from(await FileStorage.getDriver().getFileData(file.id), 'base64');
441
+ try {
442
+ return await this.makeVariant(file, kind, original);
443
+ } catch (error) {
444
+ if (!FileVariantNotMade.is(error)) {
445
+ throw error;
446
+ }
447
+ new Logger({ name: 'FileStorage' }).warn({
448
+ message: `No ${kind} variant could be made of file ${file.id}; the file itself is served`,
449
+ obj: { reason: error.reason },
450
+ });
451
+ return undefined;
452
+ }
453
+ })().finally(() => FileStorage.derivations.delete(key));
454
+ FileStorage.derivations.set(key, derivation);
455
+ return derivation;
456
+ }
457
+
458
+ /**
459
+ * Makes the variant (the maker's answer from the original's bytes), stores it as a File in the
460
+ * OWNER's scope naming its original and carrying the original's provenance ({@link provenanceOf}
461
+ * — the maker answers the bytes, their type and their dimensions, never where they came from),
462
+ * and names it on the original's row. Two readers may derive
463
+ * the same kind at once: the row's word wins — decided in ONE transaction, exactly as the copy
464
+ * for others is named — and the variant that lost is deleted after the commit. The maker's own
465
+ * failure (it could not make the variant of these bytes) is thrown as {@link FileVariantNotMade}
466
+ * with the maker's reason, so the doors can tell it from a failure of the store or the database;
467
+ * nothing is stored on it.
468
+ * @returns The id of the variant the row names.
469
+ */
470
+ private async makeVariant(file: File, kind: FileVariantKind, bytes: Buffer): Promise<string> {
471
+ const maker = getFileVariantMaker()!;
472
+ const driver = FileStorage.getDriver();
473
+ let made: FileVariant;
474
+ try {
475
+ made = await maker.make(file, bytes, kind);
476
+ } catch (cause) {
477
+ // The maker's contract: a throw means no variant can be made of these bytes.
478
+ throw new FileVariantNotMade(file.id, kind, cause);
479
+ }
480
+ // As system, in the OWNER's scope: the variant is the owner's file (their storage, their purge).
481
+ const system = getDbAsSystem();
482
+ const variant = await system.insert(tables.File, {
483
+ name: `(${kind}) ${file.name}`,
484
+ type: made.type,
485
+ size: made.bytes.length,
486
+ ...(made.width !== undefined ? { width: made.width } : {}),
487
+ ...(made.height !== undefined ? { height: made.height } : {}),
488
+ ...this.provenanceOf(file),
489
+ variantOf: new Reference<File>(tables.File.name, file.id),
490
+ scope: file.scope,
491
+ });
492
+ await driver.createFile(variant, made.bytes.toString('base64'));
493
+
494
+ const named = await system.runTransaction(async () => {
495
+ const current = await system.get(tables.File, { id: file.id });
496
+ if (current?.[kind]?._id) {
497
+ return current[kind]!._id!;
498
+ }
499
+ await system.update(tables.File, { id: file.id, [kind]: new Reference<File>(tables.File.name, variant.id) });
500
+ return variant.id;
501
+ });
502
+ if (named !== variant.id) {
503
+ // Outside the transaction: the row's delete takes the bytes with it (the delete watcher), a
504
+ // side effect that must not ride a transaction the runner may retry.
505
+ await system.delete(tables.File, { id: variant.id });
506
+ }
507
+ return named;
508
+ }
509
+
510
+ /**
511
+ * The row forgets each derived File first (nothing serves a copy or a variant the row no longer
512
+ * names), then their rows go — the bytes with them, through the delete watcher: the copy for
513
+ * others and every variant kind, one rule.
514
+ */
515
+ private async dropDerivedFiles(file: File): Promise<void> {
516
+ const seats: Array<'copyForOthers' | FileVariantKind> = ['copyForOthers', ...FILE_VARIANT_KINDS];
517
+ const stale = seats.filter((seat) => !!file[seat]?._id);
518
+ if (stale.length === 0) {
519
+ return;
520
+ }
521
+ const system = getDbAsSystem();
522
+ const forgotten: Partial<File> & { id: string } = { id: file.id };
523
+ for (const seat of stale) {
524
+ forgotten[seat] = null;
525
+ }
526
+ await system.update(tables.File, forgotten);
527
+ for (const seat of stale) {
528
+ await system.delete(tables.File, { id: file[seat]!._id! });
529
+ }
530
+ }
531
+
532
+ /**
533
+ * The provenance a derived File carries — its original's, column for column, as the table names
534
+ * them ({@link FileTable.provenanceColumns}): a rendition of the bytes came to exist the way the
535
+ * bytes did, from where they did, under the rights they carry. Only what the row holds is
536
+ * copied, so a row with no provenance derives rows with none — nothing is invented. A row that
537
+ * is itself derived already carries its original's, so a derivation of it carries the same.
538
+ */
539
+ private provenanceOf(file: File): Partial<File> {
540
+ const provenance: Partial<File> = {};
541
+ for (const column of FileTable.provenanceColumns) {
542
+ const value = file[column];
543
+ if (value !== undefined && value !== null) {
544
+ Object.assign(provenance, { [column]: value });
545
+ }
546
+ }
547
+ return provenance;
548
+ }
549
+
550
+ /**
551
+ * THE answer a byte door gives a caller who does not get the file's bytes — a row that is not
552
+ * there, one this caller cannot read, or a copy that cannot be made for them: a 404 refusal in
553
+ * the same words every time, never the id, so the answer reveals nothing about existence (the
554
+ * service router sends `{ error: 'File not found' }`, the executor logs it at WARN, the routes
555
+ * send `404 File not found`).
556
+ */
557
+ private static notFound(): ServiceRefusal {
558
+ return new ServiceRefusal(404, 'File not found');
559
+ }
202
560
  }
@@ -1,4 +1,5 @@
1
1
  import { File } from './tables/FileTable';
2
+ import type { FileStorageError } from './FileStorageError';
2
3
 
3
4
  /**
4
5
  * Byte store behind `FileStorage`.
@@ -7,6 +8,17 @@ import { File } from './tables/FileTable';
7
8
  * file's true bytes — for text and binary files alike. Drivers own their at-rest representation,
8
9
  * with one hard requirement: a driver that serves blobs directly to clients (`getSignedUrl`)
9
10
  * MUST store the true bytes at rest, because clients read its objects without any decode step.
11
+ *
12
+ * Error contract: a failed byte operation throws a {@link FileStorageError} — a code, a plain
13
+ * message and the store's HTTP status where it has one — and NEVER the store client's own error
14
+ * object or anything hanging off one (a request config, a request, a response, headers, a signed
15
+ * URL): a client's error holds the request it made, and that request holds the driver's
16
+ * credentials, so an application logging what it caught would write a live credential. Bytes that
17
+ * are not there are reported as the `not-found` code on every read and overwrite, so callers
18
+ * branch on one thing whichever driver is behind them (`deleteFile` alone treats missing bytes as
19
+ * success — see its contract). A driver whose store IS the application's database
20
+ * (`DbFileStorageDriver`) lets the database layer's own errors through unchanged: they are
21
+ * already that layer's owned errors, and a transaction runner must see them to retry.
10
22
  */
11
23
  export interface FileStorageDriver {
12
24
  createFile(file: File, fileData: string): Promise<void>;