@proteinjs/db-file 1.9.0 → 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.
@@ -0,0 +1,248 @@
1
+ import { getDbAsSystem } from '@proteinjs/db';
2
+ import { SourceRepository } from '@proteinjs/reflection';
3
+ import { UserAuth, UserRepo, User } from '@proteinjs/user';
4
+ import { File, FileTable } from '../src/tables/FileTable';
5
+ import { tables } from '../src/tables/tables';
6
+ import { FileStorage } from '../src/FileStorage';
7
+ import { FileStorageDriver } from '../src/FileStorageDriver';
8
+ import { FileVariantKind } from '../src/FileVariantMaker';
9
+ import { FileTestEnvironment } from './FileTestEnvironment';
10
+
11
+ /**
12
+ * A derived File — a variant the seam makes (at ingest with the bytes in hand, or on the read
13
+ * path's first request) and the copy for others — carries its ORIGINAL's provenance: who made the
14
+ * bytes (`origin`, `originModel`), where they came from (`sourceUrl`, `sourcePageUrl`,
15
+ * `retrievedAt`) and the rights that ride with them (`license`, `licenseUrl`, `attribution`) —
16
+ * the columns the table itself names as provenance (`FileTable.provenanceColumns`). A rendition's
17
+ * provenance is its original's: a consumer that labels a thumbnail with what made it, or refuses a
18
+ * non-capture picture as evidence, reads the row it draws, without a join. The library that makes
19
+ * the row writes it, whichever door made the row; a row with no provenance derives rows with none
20
+ * (nothing is invented); a row derived from a derived row carries what that row carries — its
21
+ * original's. The facts of a row's OWN bytes (`size`, `contentHash`, the dimensions) stay the
22
+ * maker's answer, never the original's.
23
+ */
24
+
25
+ const originalBytes = Buffer.concat([Buffer.from('ORIGINAL:'), Buffer.from(Array.from({ length: 64 }, (_, i) => i))]);
26
+ /** What the copy-for-others maker takes off a copy (the location marker of the real one). */
27
+ const COPY_MARK = Buffer.from('-FOR-OTHERS');
28
+
29
+ /**
30
+ * Every provenance column populated at once — whatever the story, each is seen to travel. The
31
+ * fixture is tied to the table's list below: a provenance column added to the table must be added
32
+ * here, and is then proven to travel like the rest.
33
+ */
34
+ const PROVENANCE = {
35
+ origin: 'generation',
36
+ originModel: 'vendor/picture-model-1',
37
+ sourceUrl: 'https://source.test/pictures/1.jpg',
38
+ sourcePageUrl: 'https://source.test/pages/1',
39
+ retrievedAt: new Date('2026-09-25T10:00:00.000Z'),
40
+ license: 'CC BY-SA 4.0',
41
+ licenseUrl: 'https://creativecommons.org/licenses/by-sa/4.0/',
42
+ attribution: 'Photo by Someone, CC BY-SA 4.0',
43
+ };
44
+
45
+ /** In-memory proxy-shape driver (no signed URLs) — the DbFileStorageDriver serving shape. */
46
+ class ProxyDriver implements FileStorageDriver {
47
+ readonly store = new Map<string, Buffer>();
48
+
49
+ async createFile(file: File, fileData: string): Promise<void> {
50
+ this.store.set(file.id, Buffer.from(fileData, 'base64'));
51
+ }
52
+
53
+ async getFileData(fileId: string): Promise<string> {
54
+ const data = this.store.get(fileId);
55
+ if (data === undefined) {
56
+ throw new Error(`No such object: ${fileId}`);
57
+ }
58
+ return data.toString('base64');
59
+ }
60
+
61
+ async updateFileData(fileId: string, data: string): Promise<void> {
62
+ this.store.set(fileId, Buffer.from(data, 'base64'));
63
+ }
64
+
65
+ async deleteFile(fileId: string): Promise<void> {
66
+ this.store.delete(fileId);
67
+ }
68
+ }
69
+
70
+ const testEnv = new FileTestEnvironment();
71
+ type UserAuthInternals = { userRepo?: unknown };
72
+ type SourceRepositoryInternals = { objectCache: Record<string, unknown[]> };
73
+ const objectCache = () => (SourceRepository.get() as unknown as SourceRepositoryInternals).objectCache;
74
+
75
+ let owner: User;
76
+ let recipient: User;
77
+ const reachableFileIds = new Set<string>();
78
+
79
+ /** The stub variant maker: a picture's `preview` and `stage` — bytes marked with the kind, a type, dimensions. Keeps the copy mark on (the real one keeps a picture's tags for its owner). */
80
+ const registerVariantMaker = () => {
81
+ objectCache()['@proteinjs/db-file/FileVariantMaker'] = [
82
+ {
83
+ appliesTo: (file: File) => file.type.startsWith('image/'),
84
+ make: async (file: File, bytes: Buffer, kind: FileVariantKind) => ({
85
+ bytes: Buffer.concat([Buffer.from(`${kind.toUpperCase()}:`), bytes.subarray(0, 8), COPY_MARK]),
86
+ type: 'image/webp',
87
+ width: kind === 'stage' ? 1600 : 512,
88
+ height: kind === 'stage' ? 800 : 256,
89
+ }),
90
+ },
91
+ ];
92
+ };
93
+
94
+ /** The stub copy maker: a picture's copy is its bytes with the mark taken off. */
95
+ const registerCopyMaker = () => {
96
+ objectCache()['@proteinjs/db-file/FileCopyForOthers'] = [
97
+ {
98
+ appliesTo: (file: File) => file.type.startsWith('image/'),
99
+ make: async (file: File, bytes: Buffer) => bytes.subarray(0, bytes.length - COPY_MARK.length),
100
+ },
101
+ ];
102
+ };
103
+
104
+ beforeAll(async () => {
105
+ await testEnv.beforeAll();
106
+ objectCache()['@proteinjs/user-auth/AuthenticatedUserRepo'] = [new UserRepo()];
107
+ (UserAuth as unknown as UserAuthInternals).userRepo = undefined;
108
+ objectCache()['@proteinjs/db-file/FileReachabilityResolver'] = [
109
+ // The content leg vouches for the recipient alone (a share they accepted).
110
+ {
111
+ canReadViaReference: async (fileId: string) =>
112
+ reachableFileIds.has(fileId) && new UserRepo().getUser().id === recipient.id,
113
+ },
114
+ ];
115
+ owner = await testEnv.createUser({ name: 'File owner', email: 'provenance-owner@test.local' });
116
+ recipient = await testEnv.createUser({ name: 'Share recipient', email: 'provenance-recipient@test.local' });
117
+ testEnv.actAs(owner);
118
+ });
119
+
120
+ afterAll(async () => {
121
+ delete objectCache()['@proteinjs/db-file/FileReachabilityResolver'];
122
+ delete objectCache()['@proteinjs/db-file/FileVariantMaker'];
123
+ delete objectCache()['@proteinjs/db-file/FileCopyForOthers'];
124
+ (UserAuth as unknown as UserAuthInternals).userRepo = undefined;
125
+ await testEnv.afterAll();
126
+ });
127
+
128
+ const driver = new ProxyDriver();
129
+
130
+ beforeEach(() => {
131
+ reachableFileIds.clear();
132
+ registerVariantMaker();
133
+ registerCopyMaker();
134
+ testEnv.setDriver(driver);
135
+ testEnv.actAs(owner);
136
+ });
137
+
138
+ /** A picture of the owner's, with (or without) its provenance, stored with no variant. */
139
+ const createOwnerPicture = async (name: string, provenance: Partial<File> = {}): Promise<File> =>
140
+ await new FileStorage().createFile(
141
+ {
142
+ name,
143
+ type: 'image/jpeg',
144
+ size: originalBytes.length,
145
+ width: 4032,
146
+ height: 3024,
147
+ contentHash: 'a'.repeat(64),
148
+ ...provenance,
149
+ } as File,
150
+ Buffer.concat([originalBytes, COPY_MARK]).toString('base64')
151
+ );
152
+
153
+ const rowAsSystem = async (fileId: string): Promise<File> => (await getDbAsSystem().get(tables.File, { id: fileId }))!;
154
+
155
+ /** The provenance a row carries, column for column as the table names them — `null` for a column it does not hold. */
156
+ const provenanceOf = (row: File): Record<string, unknown> =>
157
+ FileTable.provenanceColumns.reduce<Record<string, unknown>>(
158
+ (carried, column) => ({ ...carried, [column]: row[column] ?? null }),
159
+ {}
160
+ );
161
+
162
+ const NO_PROVENANCE = provenanceOf({} as File);
163
+
164
+ /** The copy for others of a file, made by a recipient's read of its bytes. */
165
+ const copyForOthersOf = async (file: File): Promise<File> => {
166
+ reachableFileIds.add(file.id);
167
+ testEnv.actAs(recipient);
168
+ await new FileStorage().getFileData(file.id);
169
+ testEnv.actAs(owner);
170
+ const copyId = (await rowAsSystem(file.id)).copyForOthers?._id;
171
+ expect(copyId).toBeTruthy();
172
+ return await rowAsSystem(copyId!);
173
+ };
174
+
175
+ describe('the list is the table’s own', () => {
176
+ it('names the producer, the web source and the rights record — and the fixture covers every column it names, so a column added there is proven to travel', () => {
177
+ expect([...FileTable.provenanceColumns].sort()).toEqual(Object.keys(PROVENANCE).sort());
178
+ expect(FileTable.provenanceColumns).toEqual(expect.arrayContaining(['origin', 'originModel']));
179
+ // The facts of a row's own bytes are not provenance.
180
+ expect(FileTable.provenanceColumns).not.toEqual(expect.arrayContaining(['contentHash']));
181
+ expect(FileTable.provenanceColumns).not.toEqual(expect.arrayContaining(['size']));
182
+ });
183
+ });
184
+
185
+ describe('a derived row carries its original’s provenance', () => {
186
+ it("the ingest door: every variant deriveVariants makes carries the original's provenance, column for column; the maker's own answer (bytes, type, dimensions) stays its own", async () => {
187
+ const file = await createOwnerPicture('made.jpg', PROVENANCE);
188
+
189
+ const { variants } = await new FileStorage().deriveVariants(file, Buffer.concat([originalBytes, COPY_MARK]));
190
+
191
+ expect(Object.keys(variants).sort()).toEqual(['preview', 'stage']);
192
+ for (const kind of ['preview', 'stage'] as const) {
193
+ const variant = await rowAsSystem(variants[kind]!.id);
194
+ expect(provenanceOf(variant)).toEqual(PROVENANCE);
195
+ expect([variant.type, variant.width, variant.height]).toEqual([
196
+ 'image/webp',
197
+ kind === 'stage' ? 1600 : 512,
198
+ kind === 'stage' ? 800 : 256,
199
+ ]);
200
+ expect(variant.size).toEqual(driver.store.get(variant.id)!.length);
201
+ // The hash is of THESE bytes, which the maker did not answer: never the original's.
202
+ expect(variant.contentHash ?? null).toBeNull();
203
+ }
204
+ });
205
+
206
+ it("the read path's door: the variant getVariant derives on the first request carries the original's provenance", async () => {
207
+ const file = await createOwnerPicture('existing.jpg', PROVENANCE);
208
+ expect((await rowAsSystem(file.id)).stage?._id ?? null).toBeNull();
209
+
210
+ const stage = (await new FileStorage().getVariant(file.id, 'stage'))!;
211
+
212
+ expect(stage.variantOf?._id).toEqual(file.id);
213
+ expect(provenanceOf(await rowAsSystem(stage.id))).toEqual(PROVENANCE);
214
+ });
215
+
216
+ it("the copy for others carries the original's provenance", async () => {
217
+ const file = await createOwnerPicture('shared.jpg', PROVENANCE);
218
+
219
+ const copy = await copyForOthersOf(file);
220
+
221
+ expect(copy.scope).toEqual(owner.id);
222
+ expect(provenanceOf(copy)).toEqual(PROVENANCE);
223
+ expect(copy.contentHash ?? null).toBeNull();
224
+ });
225
+
226
+ it('a row derived from a derived row carries what that row carries — its original’s: the copy for others of a stage variant', async () => {
227
+ const file = await createOwnerPicture('chain.jpg', PROVENANCE);
228
+ const stage = (await new FileStorage().getVariant(file.id, 'stage'))!;
229
+ reachableFileIds.add(file.id); // a variant's reachability is its original's: the grant names the original
230
+
231
+ const copyOfStage = await copyForOthersOf(await rowAsSystem(stage.id));
232
+
233
+ expect(copyOfStage.variantOf?._id ?? null).toBeNull(); // a copy's own id stays nobody's but the owner's
234
+ expect(provenanceOf(copyOfStage)).toEqual(PROVENANCE);
235
+ });
236
+
237
+ it('a row with NO provenance derives rows with none — nothing is invented, at any door', async () => {
238
+ const file = await createOwnerPicture('plain.jpg');
239
+ expect(provenanceOf(await rowAsSystem(file.id))).toEqual(NO_PROVENANCE);
240
+
241
+ const { variants } = await new FileStorage().deriveVariants(file, Buffer.concat([originalBytes, COPY_MARK]));
242
+ const copy = await copyForOthersOf(file);
243
+
244
+ expect(provenanceOf(await rowAsSystem(variants.preview!.id))).toEqual(NO_PROVENANCE);
245
+ expect(provenanceOf(await rowAsSystem(variants.stage!.id))).toEqual(NO_PROVENANCE);
246
+ expect(provenanceOf(copy)).toEqual(NO_PROVENANCE);
247
+ });
248
+ });