cursedbelt-server 3.0.1 → 4.1.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 (84) hide show
  1. package/dist/server/auth/jwt.d.ts +1 -1
  2. package/dist/server/auth/jwt.js +2 -2
  3. package/dist/server/master-lock/accountsPage.d.ts +39 -0
  4. package/dist/server/master-lock/accountsPage.js +455 -0
  5. package/dist/server/master-lock/guard.js +39 -1
  6. package/dist/server/master-lock/index.d.ts +10 -2
  7. package/dist/server/master-lock/index.js +10 -2
  8. package/dist/server/master-lock/lockPage.d.ts +9 -0
  9. package/dist/server/master-lock/lockPage.js +44 -36
  10. package/dist/server/media-bun/streaming.d.ts +0 -1
  11. package/dist/server/media-bun/streaming.js +1 -1
  12. package/dist/server/storage/binaryStore.d.ts +385 -0
  13. package/dist/server/storage/binaryStore.js +739 -0
  14. package/dist/server/storage/binaryStoreFake.d.ts +56 -0
  15. package/dist/server/storage/binaryStoreFake.js +63 -0
  16. package/dist/server/storage/files/catalogue.d.ts +2 -2
  17. package/dist/server/storage/files/catalogue.js +18 -208
  18. package/dist/server/storage/files/fileRoutes.d.ts +2 -20
  19. package/dist/server/storage/files/fileRoutes.js +1 -74
  20. package/dist/server/storage/files/index.d.ts +1 -1
  21. package/dist/server/storage/files/index.js +1 -1
  22. package/dist/server/storage/files/migrations.js +9 -6
  23. package/dist/server/storage/files/stack.d.ts +6 -31
  24. package/dist/server/storage/files/stack.js +0 -27
  25. package/dist/server/storage/files/types.d.ts +10 -60
  26. package/dist/server/storage/files/types.js +9 -0
  27. package/dist/server/storage/index.d.ts +2 -2
  28. package/dist/server/storage/index.js +1 -1
  29. package/dist/server/storage/mountMediaRoutes.d.ts +1 -4
  30. package/dist/server/storage/types.d.ts +0 -9
  31. package/dist/server/storage/types.js +2 -9
  32. package/package.json +14 -2
  33. package/src/leafSubpathsImportNothing.spec.ts +30 -4
  34. package/src/server/auth/jwt.ts +2 -2
  35. package/src/server/master-lock/accountsPage.spec.ts +231 -0
  36. package/src/server/master-lock/accountsPage.ts +465 -0
  37. package/src/server/master-lock/guard.ts +44 -1
  38. package/src/server/master-lock/index.ts +22 -2
  39. package/src/server/master-lock/lockPage.spec.ts +20 -16
  40. package/src/server/master-lock/lockPage.ts +45 -36
  41. package/src/server/media-bun/streaming.ts +0 -6
  42. package/src/server/storage/binaryStore.spec.ts +908 -0
  43. package/src/server/storage/binaryStore.ts +1049 -0
  44. package/src/server/storage/binaryStoreFake.ts +111 -0
  45. package/src/server/storage/fileUploadClient.integration.spec.ts +2 -21
  46. package/src/server/storage/files/adapter.photo.display.spec.ts +2 -2
  47. package/src/server/storage/files/adapter.photo.spec.ts +2 -2
  48. package/src/server/storage/files/adapter.spec.ts +0 -1
  49. package/src/server/storage/files/catalogue.media-host.spec.ts +6 -71
  50. package/src/server/storage/files/catalogue.recover.spec.ts +14 -23
  51. package/src/server/storage/files/catalogue.ts +19 -240
  52. package/src/server/storage/files/fileRoutes.replace.spec.ts +2 -2
  53. package/src/server/storage/files/fileRoutes.spec.ts +3 -97
  54. package/src/server/storage/files/fileRoutes.ts +2 -96
  55. package/src/server/storage/files/index.ts +1 -4
  56. package/src/server/storage/files/migrations.ts +9 -7
  57. package/src/server/storage/files/stack.spec.ts +11 -111
  58. package/src/server/storage/files/stack.ts +9 -61
  59. package/src/server/storage/files/types.ts +20 -68
  60. package/src/server/storage/index.ts +0 -4
  61. package/src/server/storage/mountMediaRoutes.ts +1 -7
  62. package/src/server/storage/types.ts +2 -11
  63. package/src/shippedFilesAreTracked.spec.ts +3 -2
  64. package/dist/server/private-media/crypto.d.ts +0 -37
  65. package/dist/server/private-media/crypto.js +0 -111
  66. package/dist/server/private-media/index-store.d.ts +0 -31
  67. package/dist/server/private-media/index-store.js +0 -36
  68. package/dist/server/private-media/mountPrivateRoutes.d.ts +0 -26
  69. package/dist/server/private-media/mountPrivateRoutes.js +0 -219
  70. package/dist/server/private-media/pipeline.d.ts +0 -37
  71. package/dist/server/private-media/pipeline.js +0 -97
  72. package/dist/server/private-media/unlock.d.ts +0 -52
  73. package/dist/server/private-media/unlock.js +0 -55
  74. package/src/server/private-media/blobRoute.spec.ts +0 -165
  75. package/src/server/private-media/crypto.spec.ts +0 -81
  76. package/src/server/private-media/crypto.ts +0 -155
  77. package/src/server/private-media/gatePolicy.spec.ts +0 -98
  78. package/src/server/private-media/index-store.ts +0 -83
  79. package/src/server/private-media/mountPrivateRoutes.ts +0 -265
  80. package/src/server/private-media/pipeline.spec.ts +0 -111
  81. package/src/server/private-media/pipeline.ts +0 -170
  82. package/src/server/private-media/routes.spec.ts +0 -265
  83. package/src/server/private-media/unlock.ts +0 -112
  84. package/src/server/storage/files/catalogue.private.spec.ts +0 -106
@@ -0,0 +1,111 @@
1
+ /**
2
+ * A `BinaryStore` that does nothing, so adding a method to the interface stops
3
+ * breaking twenty-two test files in apps nobody touched.
4
+ *
5
+ * ── The measurement (2026-09-08) ────────────────────────────────────────────
6
+ * `BinaryStore.fetchMedia` was added to serve audio off this Mac while
7
+ * Cloudflare's daily Worker quota was spent — a good change, landed on `main`,
8
+ * with apps/music green. It also broke **apps/collections and apps/roms**, four
9
+ * test files each, with `Property 'fetchMedia' is missing in type`. Both apps
10
+ * were left unable to typecheck, and therefore unable to gate or deploy, by a
11
+ * commit neither of them appears in. Nobody noticed for hours: a worker's
12
+ * `verify:scoped` only typechecks the workspaces its own diff touches, which is
13
+ * exactly the property that makes it fast.
14
+ *
15
+ * The owner's rule, in his words: *"a change to roms for example cannot block
16
+ * family tree app from passing its tests."* This is that rule for the one
17
+ * interface twenty-two suites all fake by hand.
18
+ *
19
+ * 🔴 **This is why the fake travels WITH the interface, into this package.** An
20
+ * interface whose change can break two other apps from a third app's commit is a
21
+ * shared interface already; a fake living in a different repo from the interface it
22
+ * fakes cannot do the job it exists for, because the two can be changed
23
+ * independently. Published as `cursedbelt-server/binary-store/testing`, beside
24
+ * `cursedbelt-server/binary-store` — the same split as `./d1` and `./d1/testing`, and
25
+ * for the same reason: a consumer's PRODUCTION graph must not be able to reach a
26
+ * fake.
27
+ *
28
+ * ── 🔴 Why defaults and not optional members ────────────────────────────────
29
+ * The obvious alternative is to mark each new capability `fetchMedia?:` on the
30
+ * interface. That moves the cost to every REAL consumer — each one then has to
31
+ * narrow before calling, for a method the production store always has — and it
32
+ * makes "did we implement this?" unanswerable by the typechecker, which is the
33
+ * one question the interface exists to answer. The fakes are the side that
34
+ * should absorb the change, so the defaults live here.
35
+ *
36
+ * ── What a default may do ───────────────────────────────────────────────────
37
+ * As little as possible, and never something a test could mistake for a result.
38
+ * `get` and `meta` answer "not here"; `fetchMedia` answers 404; the mutators do
39
+ * nothing. A suite that cares about any of them overrides it — spread this first
40
+ * and the file's own members win:
41
+ *
42
+ * const store: BinaryStore = {
43
+ * ...binaryStoreFakeDefaults("collections"),
44
+ * async meta(key) { … what this suite is actually about … },
45
+ * };
46
+ */
47
+ import type { BinaryStore, FileTokenClaims } from './binaryStore';
48
+
49
+ /**
50
+ * Every member of {@link BinaryStore}, inert.
51
+ *
52
+ * @param appId the tenant the fake claims to be. Required, and deliberately so —
53
+ * a blank tenant is the mistake `binaryStoreTenant` exists to catch, and a
54
+ * fake that quietly defaults it would hide a test written against the
55
+ * wrong app.
56
+ */
57
+ export function binaryStoreFakeDefaults(appId: string): BinaryStore {
58
+ return {
59
+ appId,
60
+ async put(): Promise<void> {},
61
+ async putLarge(): Promise<void> {},
62
+ async get(): Promise<Uint8Array | null> {
63
+ return null;
64
+ },
65
+ async meta(): Promise<null> {
66
+ return null;
67
+ },
68
+ async stat(): Promise<null> {
69
+ return null;
70
+ },
71
+ async has(): Promise<boolean> {
72
+ return false;
73
+ },
74
+ // 🔴 `"unknown"`, never `"absent"`. A fake that claimed to know a key is missing
75
+ // would let a listing route's "answer only from the memo" branch look exercised
76
+ // when nothing was ever memoized — the one thing a test of that branch is for.
77
+ known(): 'present' | 'absent' | 'unknown' {
78
+ return 'unknown';
79
+ },
80
+ // 🔴 `0`, and never a lie about work done. The real one returns how many
81
+ // negative memos it dropped, and an app's repair route reports that number to
82
+ // the owner — a fake that invented one would make a broken repair read as a
83
+ // working one in the only test that could catch it.
84
+ forgetMisses(): number {
85
+ return 0;
86
+ },
87
+ cacheStats(): { present: number; absent: number } {
88
+ return { present: 0, absent: 0 };
89
+ },
90
+ async remove(): Promise<void> {},
91
+ async removePrefix(): Promise<void> {},
92
+ async mediaUrl(key: string): Promise<string> {
93
+ // Carries the key, so an assertion on "which object was linked" reads.
94
+ return `https://binary-server.test/media/${appId}/${key}`;
95
+ },
96
+ async mediaPrefixUrl(prefix: string, master: string): Promise<string> {
97
+ // Carries BOTH, because the bug this shape exists to prevent is signing the wrong
98
+ // one of them — a URL pointing at the master but scoped to the wrong prefix 403s
99
+ // every segment while the master itself loads fine.
100
+ return `https://binary-server.test/media/${appId}/${master}?p=${encodeURIComponent(prefix)}`;
101
+ },
102
+ async fetchMedia(): Promise<Response> {
103
+ // 🔴 404, not an empty 200. A fake that answers "here are zero bytes" makes a
104
+ // streaming route look like it worked on an object that does not exist.
105
+ return new Response(null, { status: 404 });
106
+ },
107
+ async signToken(_claims: FileTokenClaims, _ttlSeconds: number): Promise<string> {
108
+ return 'fake-token';
109
+ },
110
+ };
111
+ }
@@ -1,7 +1,7 @@
1
1
  /*
2
2
  * Moved from cursedbelt's src/react/media at the 2026-09-15 split: this spec
3
3
  * drives the published react-side upload client against this package's storage
4
- * INTERNALS (diskStore, streamToken, private-media), which are deliberately not
4
+ * INTERNALS (diskStore, streamToken), which are deliberately not
5
5
  * exported — so the spec lives beside them and takes the client half from the
6
6
  * published `cursedbelt` instead. Runtime deps stay acyclic; this is dev-only.
7
7
  */
@@ -13,8 +13,6 @@ import path from 'node:path';
13
13
  import { Hono } from 'hono';
14
14
  import { MAX_SINGLE_UPLOAD_BYTES } from 'cursedbelt-core/media/upload-limits';
15
15
  import { runMigrations } from '../migration/runner';
16
- import { generateUserSalt } from '../private-media/crypto';
17
- import { createUnlockService } from '../private-media/unlock';
18
16
  import { createDiskStore } from './diskStore';
19
17
  import { createSqliteFileCatalogue } from './files/catalogue';
20
18
  import { mountFileRoutes, SINGLE_UPLOAD_TOO_LARGE } from './files/fileRoutes';
@@ -24,11 +22,9 @@ import type { MediaCtx } from './types';
24
22
  import { createFileUploadClient } from 'cursedbelt/react/media/upload-client';
25
23
 
26
24
  // Drives the REAL mountFileRoutes edge — the client and the routes share one header
27
- // protocol (X-Filename/X-Tags/X-Unlock-Token), so the round-trip is the contract.
25
+ // protocol (X-Filename/X-Tags), so the round-trip is the contract.
28
26
 
29
27
  const CTX: MediaCtx = { userId: 'u1', tenantId: 't1' };
30
- const unlock = createUnlockService({ serverSecret: 'srv', unlockSecret: 'unl' });
31
- const salt = generateUserSalt();
32
28
 
33
29
  describe('createFileUploadClient ⇄ mountFileRoutes', () => {
34
30
  let db: Database;
@@ -55,10 +51,6 @@ describe('createFileUploadClient ⇄ mountFileRoutes', () => {
55
51
  token: createStreamTokenService({ secret: 's' }),
56
52
  catalogue,
57
53
  authorizeUpload: () => CTX,
58
- privateUpload: {
59
- unlock,
60
- authorize: async () => ({ userId: CTX.userId, userSalt: salt }),
61
- },
62
54
  });
63
55
  });
64
56
  afterAll(async () => {
@@ -90,17 +82,6 @@ describe('createFileUploadClient ⇄ mountFileRoutes', () => {
90
82
  expect(res.file?.size).toBe('replaced'.length);
91
83
  });
92
84
 
93
- test('uploadPrivate with an unlock token lands an encrypted item', async () => {
94
- const { token } = await unlock.unlock({ userId: CTX.userId, userSalt: salt, password: 'pw' });
95
- const res = await client.uploadPrivate(
96
- new File(['secret-bytes'], 'secret.jpg', { type: 'image/jpeg' }),
97
- { unlockToken: token, tags: ['hidden'] },
98
- );
99
- expect(res.ok).toBe(true);
100
- if (!res.ok) return;
101
- expect(res.file?.processingStatus).toBe('ready'); // image blob path — no ffmpeg
102
- });
103
-
104
85
  test('uploadAuto falls back to the streamed PUT when chunked is unwired (404 init)', async () => {
105
86
  const big = new File([new Uint8Array(9 * 1024 * 1024)], 'big.bin', {
106
87
  type: 'application/octet-stream',
@@ -12,7 +12,7 @@ import { createCataloguePhotoSource } from './adapter';
12
12
  import { createSqliteFileCatalogue } from './catalogue';
13
13
  import type { FileUrls } from './fileRoutes';
14
14
  import { FILE_CATALOGUE_MIGRATIONS } from './migrations';
15
- import type { FileCatalogue, PrivateFileCatalogue } from './types';
15
+ import type { FileCatalogue } from './types';
16
16
  import { bytesStream } from 'cursedbelt-core/testing';
17
17
 
18
18
  // End-to-end: an HEIC photo must gain a browser-safe full-size display derivative + advertise it as
@@ -79,7 +79,7 @@ async function heicBytes(): Promise<Uint8Array | null> {
79
79
  describe.skipIf(!haveFfmpeg)('photo display derivative (HEIC)', () => {
80
80
  let db: Database;
81
81
  let root: string;
82
- let cat: FileCatalogue & PrivateFileCatalogue;
82
+ let cat: FileCatalogue;
83
83
 
84
84
  beforeAll(async () => {
85
85
  db = new Database(':memory:');
@@ -11,7 +11,7 @@ import { createCataloguePhotoSource } from './adapter';
11
11
  import { createSqliteFileCatalogue } from './catalogue';
12
12
  import type { FileUrls } from './fileRoutes';
13
13
  import { FILE_CATALOGUE_MIGRATIONS } from './migrations';
14
- import type { FileCatalogue, PrivateFileCatalogue } from './types';
14
+ import type { FileCatalogue } from './types';
15
15
 
16
16
  // The photo source is an image-filtered VIEW over the catalogue: primary bytes via the file
17
17
  // route URL, grid thumb via the type-agnostic poster URL (only when the thumbnailer produced one).
@@ -42,7 +42,7 @@ const fakeMediaUrls: MediaUrls = {
42
42
  describe('createCataloguePhotoSource', () => {
43
43
  let db: Database;
44
44
  let root: string;
45
- let cat: FileCatalogue & PrivateFileCatalogue;
45
+ let cat: FileCatalogue;
46
46
  let photoId: string;
47
47
  let videoId: string;
48
48
 
@@ -39,7 +39,6 @@ const videoRecord = (over: Partial<FileRecord> = {}): FileRecord => ({
39
39
  size: 10,
40
40
  fileType: 'video',
41
41
  tags: ['fun'],
42
- isPrivate: false,
43
42
  favorite: false,
44
43
  collectionIds: [],
45
44
  hasThumbnail: true,
@@ -4,22 +4,15 @@ import { mkdtemp, rm } from 'node:fs/promises';
4
4
  import { tmpdir } from 'node:os';
5
5
  import path from 'node:path';
6
6
  import { runMigrations } from '../../migration/runner';
7
- import {
8
- cryptBlobStream,
9
- derivePasswordSetKey,
10
- generateUserSalt,
11
- openMeta,
12
- unwrapContentKey,
13
- } from '../../private-media/crypto';
14
7
  import { createDiskStore } from '../diskStore';
15
- import { fsPosterKey, type MediaCtx, type MediaStore, privateSourceKey } from '../types';
8
+ import { fsPosterKey, type MediaCtx, type MediaStore } from '../types';
16
9
  import { createSqliteFileCatalogue, fileBinaryKey } from './catalogue';
17
10
  import { FILE_CATALOGUE_MIGRATIONS } from './migrations';
18
- import type { FileCatalogue, PrivateFileCatalogue } from './types';
11
+ import type { FileCatalogue } from './types';
19
12
  import { bytesStream } from 'cursedbelt-core/testing';
20
13
 
21
- // New media-host capabilities: private NON-video blobs (photos), in-place byte replacement
22
- // (the editor's "overwrite original"), and ingest-time image thumbnails.
14
+ // Media-host capabilities: in-place byte replacement (the editor's "overwrite original") and
15
+ // ingest-time image thumbnails.
23
16
 
24
17
  const CTX: MediaCtx = { userId: 'u1', tenantId: 't1' };
25
18
  const OWNER = CTX.userId;
@@ -30,7 +23,7 @@ const drain = async (s: ReadableStream<Uint8Array>): Promise<Uint8Array> =>
30
23
  let db: Database;
31
24
  let root: string;
32
25
  let store: MediaStore;
33
- let cat: FileCatalogue & PrivateFileCatalogue;
26
+ let cat: FileCatalogue;
34
27
 
35
28
  beforeAll(async () => {
36
29
  db = new Database(':memory:');
@@ -51,55 +44,6 @@ afterAll(async () => {
51
44
  await rm(root, { recursive: true, force: true });
52
45
  });
53
46
 
54
- describe('private blob ingest (non-video)', () => {
55
- const salt = generateUserSalt();
56
- const setKey = derivePasswordSetKey('photo-pass', salt);
57
- const plaintext = new TextEncoder().encode('these are secret photo bytes — not for the feed');
58
- let id: string;
59
-
60
- test('ingests an image as a ready, encrypted blob (no ffmpeg involved)', async () => {
61
- const item = await cat.ingestPrivate(
62
- CTX,
63
- { name: 'secret.jpg', mime: 'image/jpeg', body: stream(plaintext), tags: ['hidden'] },
64
- { setKey, keyMode: 'deniable' },
65
- );
66
- id = item.id;
67
- expect(item.processingStatus).toBe('ready');
68
- expect(item.size).toBe(plaintext.byteLength);
69
- expect(item.hasHls).toBe(false);
70
- });
71
-
72
- test('stored bytes are ciphertext; the wrapped content key decrypts them', async () => {
73
- const stored = await store.get(privateSourceKey(OWNER, id));
74
- expect(stored).not.toBeNull();
75
- expect(stored?.byteLength).toBe(plaintext.byteLength); // CTR is length-preserving
76
- expect(Buffer.from(stored as Uint8Array).equals(Buffer.from(plaintext))).toBe(false);
77
-
78
- const item = await cat.asPrivateIndex().get(OWNER, id);
79
- const content = unwrapContentKey(setKey, item?.wrappedContentKey ?? '');
80
- expect(content).not.toBeNull();
81
- const decrypted = await drain(
82
- cryptBlobStream(content as NonNullable<typeof content>, stream(stored as Uint8Array)),
83
- );
84
- expect(Buffer.from(decrypted).equals(Buffer.from(plaintext))).toBe(true);
85
- });
86
-
87
- test('the sealed descriptor reveals name/mime/tags to its set key only', async () => {
88
- const item = await cat.asPrivateIndex().get(OWNER, id);
89
- const meta = openMeta<{ name: string; mime: string; tags: string[] }>(
90
- setKey,
91
- item?.sealedMeta ?? '',
92
- );
93
- expect(meta).toEqual({ name: 'secret.jpg', mime: 'image/jpeg', tags: ['hidden'] });
94
- expect(openMeta(derivePasswordSetKey('wrong', salt), item?.sealedMeta ?? '')).toBeNull();
95
- });
96
-
97
- test('index.remove wipes the ciphertext blob too', async () => {
98
- await cat.asPrivateIndex().remove(OWNER, id);
99
- expect(await store.get(privateSourceKey(OWNER, id))).toBeNull();
100
- });
101
- });
102
-
103
47
  describe('replaceContent — overwrite bytes, keep identity', () => {
104
48
  let id: string;
105
49
  let collectionId: string;
@@ -137,19 +81,10 @@ describe('replaceContent — overwrite bytes, keep identity', () => {
137
81
  expect(new TextDecoder().decode(bytes as Uint8Array)).toBe('v2 much longer replacement bytes');
138
82
  });
139
83
 
140
- test('unknown id → null; private rows refuse replacement', async () => {
84
+ test('unknown id → null', async () => {
141
85
  expect(await cat.replaceContent(CTX, 'nope', { mime: 'text/plain', body: stream('x') })).toBe(
142
86
  null,
143
87
  );
144
- const salt = generateUserSalt();
145
- const priv = await cat.ingestPrivate(
146
- CTX,
147
- { name: 'p.jpg', mime: 'image/jpeg', body: stream('p') },
148
- { setKey: derivePasswordSetKey('pw', salt), keyMode: 'deniable' },
149
- );
150
- expect(
151
- cat.replaceContent(CTX, priv.id, { mime: 'image/jpeg', body: stream('q') }),
152
- ).rejects.toThrow(/private/);
153
88
  });
154
89
  });
155
90
 
@@ -10,7 +10,8 @@ import { FILE_CATALOGUE_MIGRATIONS } from './migrations';
10
10
  import type { FileCatalogue } from './types';
11
11
 
12
12
  // recoverProcessing (ported from ca's recoverOnBoot): a restart mid-transcode leaves video rows
13
- // stuck in processing/pending; public rows get re-kicked (source persists), private rows are failed.
13
+ // stuck in processing/pending; they get re-kicked (the source persists) unless the row has already
14
+ // burned its kick budget, in which case it lands on `failed` — see the attempt-cap describe below.
14
15
  describe('FileCatalogue.recoverProcessing', () => {
15
16
  let db: Database;
16
17
  let root: string;
@@ -25,13 +26,11 @@ describe('FileCatalogue.recoverProcessing', () => {
25
26
  store: createDiskStore(root),
26
27
  workDir: path.join(root, 'w'),
27
28
  });
28
- // Simulate two rows a crash left mid-transcode: one public, one private.
29
- const insert = db.query(
30
- `INSERT INTO files (id, owner, name, mime, size, file_type, is_private, processing_status, processing_progress, storage_backend, created_at, updated_at)
31
- VALUES ($id, 'u1', $name, 'video/mp4', 10, 'video', $priv, 'processing', 40, 'disk', 1, 1)`,
32
- );
33
- insert.run({ $id: 'pub1', $name: 'a.mp4', $priv: 0 });
34
- insert.run({ $id: 'prv1', $name: '', $priv: 1 });
29
+ // Simulate a row a crash left mid-transcode.
30
+ db.query(
31
+ `INSERT INTO files (id, owner, name, mime, size, file_type, processing_status, processing_progress, storage_backend, created_at, updated_at)
32
+ VALUES ($id, 'u1', $name, 'video/mp4', 10, 'video', 'processing', 40, 'disk', 1, 1)`,
33
+ ).run({ $id: 'pub1', $name: 'a.mp4' });
35
34
  });
36
35
  afterAll(async () => {
37
36
  await Bun.sleep(150); // let the re-kicked public job settle before closing the db
@@ -39,18 +38,10 @@ describe('FileCatalogue.recoverProcessing', () => {
39
38
  await rm(root, { recursive: true, force: true });
40
39
  });
41
40
 
42
- test('re-queues stuck public videos and fails stuck private ones', () => {
41
+ test('re-queues stuck videos', () => {
43
42
  const res = cat.recoverProcessing();
44
43
  expect(res.requeued).toBe(1); // pub1 re-kicked
45
- expect(res.failed).toBe(1); // prv1 can't be re-kicked at boot → failed
46
-
47
- const status = (id: string) =>
48
- db
49
- .query<{ processing_status: string }, { $id: string }>(
50
- 'SELECT processing_status FROM files WHERE id = $id',
51
- )
52
- .get({ $id: id })?.processing_status;
53
- expect(status('prv1')).toBe('failed');
44
+ expect(res.failed).toBe(0);
54
45
  // pub1 was re-kicked (its final status is settled by the async pipeline; here we just assert it
55
46
  // left the recover call counted as requeued, not failed).
56
47
  });
@@ -68,7 +59,7 @@ describe('FileCatalogue.recoverProcessing', () => {
68
59
  // The boot-loop guard: when the transcode itself keeps killing the process (ffmpeg OOM →
69
60
  // restart → re-kick), the attempt cap must land the row on `failed` instead of re-kicking forever.
70
61
  describe('FileCatalogue.recoverProcessing attempt cap', () => {
71
- test('marks a public video failed once processing_attempts reaches the cap', async () => {
62
+ test('marks a video failed once processing_attempts reaches the cap', async () => {
72
63
  const db = new Database(':memory:');
73
64
  runMigrations(db, FILE_CATALOGUE_MIGRATIONS);
74
65
  const root = await mkdtemp(path.join(tmpdir(), 'fc-recover-cap-'));
@@ -79,8 +70,8 @@ describe('FileCatalogue.recoverProcessing attempt cap', () => {
79
70
  maxProcessingAttempts: 2,
80
71
  });
81
72
  db.query(
82
- `INSERT INTO files (id, owner, name, mime, size, file_type, is_private, processing_status, processing_progress, storage_backend, processing_attempts, created_at, updated_at)
83
- VALUES ('loop1', 'u1', 'l.mp4', 'video/mp4', 10, 'video', 0, 'processing', 40, 'disk', 2, 1, 1)`,
73
+ `INSERT INTO files (id, owner, name, mime, size, file_type, processing_status, processing_progress, storage_backend, processing_attempts, created_at, updated_at)
74
+ VALUES ('loop1', 'u1', 'l.mp4', 'video/mp4', 10, 'video', 'processing', 40, 'disk', 2, 1, 1)`,
84
75
  ).run();
85
76
 
86
77
  const res = cat.recoverProcessing();
@@ -111,8 +102,8 @@ describe('FileCatalogue transcodeVideos: false', () => {
111
102
  transcodeVideos: false,
112
103
  });
113
104
  db.query(
114
- `INSERT INTO files (id, owner, name, mime, size, file_type, is_private, processing_status, processing_progress, storage_backend, created_at, updated_at)
115
- VALUES ('raw1', 'u1', 'r.mp4', 'video/mp4', 10, 'video', 0, 'processing', 40, 'disk', 1, 1)`,
105
+ `INSERT INTO files (id, owner, name, mime, size, file_type, processing_status, processing_progress, storage_backend, created_at, updated_at)
106
+ VALUES ('raw1', 'u1', 'r.mp4', 'video/mp4', 10, 'video', 'processing', 40, 'disk', 1, 1)`,
116
107
  ).run();
117
108
 
118
109
  const res = cat.recoverProcessing();