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.
- package/dist/server/auth/jwt.d.ts +1 -1
- package/dist/server/auth/jwt.js +2 -2
- package/dist/server/master-lock/accountsPage.d.ts +39 -0
- package/dist/server/master-lock/accountsPage.js +455 -0
- package/dist/server/master-lock/guard.js +39 -1
- package/dist/server/master-lock/index.d.ts +10 -2
- package/dist/server/master-lock/index.js +10 -2
- package/dist/server/master-lock/lockPage.d.ts +9 -0
- package/dist/server/master-lock/lockPage.js +44 -36
- package/dist/server/media-bun/streaming.d.ts +0 -1
- package/dist/server/media-bun/streaming.js +1 -1
- package/dist/server/storage/binaryStore.d.ts +385 -0
- package/dist/server/storage/binaryStore.js +739 -0
- package/dist/server/storage/binaryStoreFake.d.ts +56 -0
- package/dist/server/storage/binaryStoreFake.js +63 -0
- package/dist/server/storage/files/catalogue.d.ts +2 -2
- package/dist/server/storage/files/catalogue.js +18 -208
- package/dist/server/storage/files/fileRoutes.d.ts +2 -20
- package/dist/server/storage/files/fileRoutes.js +1 -74
- package/dist/server/storage/files/index.d.ts +1 -1
- package/dist/server/storage/files/index.js +1 -1
- package/dist/server/storage/files/migrations.js +9 -6
- package/dist/server/storage/files/stack.d.ts +6 -31
- package/dist/server/storage/files/stack.js +0 -27
- package/dist/server/storage/files/types.d.ts +10 -60
- package/dist/server/storage/files/types.js +9 -0
- package/dist/server/storage/index.d.ts +2 -2
- package/dist/server/storage/index.js +1 -1
- package/dist/server/storage/mountMediaRoutes.d.ts +1 -4
- package/dist/server/storage/types.d.ts +0 -9
- package/dist/server/storage/types.js +2 -9
- package/package.json +14 -2
- package/src/leafSubpathsImportNothing.spec.ts +30 -4
- package/src/server/auth/jwt.ts +2 -2
- package/src/server/master-lock/accountsPage.spec.ts +231 -0
- package/src/server/master-lock/accountsPage.ts +465 -0
- package/src/server/master-lock/guard.ts +44 -1
- package/src/server/master-lock/index.ts +22 -2
- package/src/server/master-lock/lockPage.spec.ts +20 -16
- package/src/server/master-lock/lockPage.ts +45 -36
- package/src/server/media-bun/streaming.ts +0 -6
- package/src/server/storage/binaryStore.spec.ts +908 -0
- package/src/server/storage/binaryStore.ts +1049 -0
- package/src/server/storage/binaryStoreFake.ts +111 -0
- package/src/server/storage/fileUploadClient.integration.spec.ts +2 -21
- package/src/server/storage/files/adapter.photo.display.spec.ts +2 -2
- package/src/server/storage/files/adapter.photo.spec.ts +2 -2
- package/src/server/storage/files/adapter.spec.ts +0 -1
- package/src/server/storage/files/catalogue.media-host.spec.ts +6 -71
- package/src/server/storage/files/catalogue.recover.spec.ts +14 -23
- package/src/server/storage/files/catalogue.ts +19 -240
- package/src/server/storage/files/fileRoutes.replace.spec.ts +2 -2
- package/src/server/storage/files/fileRoutes.spec.ts +3 -97
- package/src/server/storage/files/fileRoutes.ts +2 -96
- package/src/server/storage/files/index.ts +1 -4
- package/src/server/storage/files/migrations.ts +9 -7
- package/src/server/storage/files/stack.spec.ts +11 -111
- package/src/server/storage/files/stack.ts +9 -61
- package/src/server/storage/files/types.ts +20 -68
- package/src/server/storage/index.ts +0 -4
- package/src/server/storage/mountMediaRoutes.ts +1 -7
- package/src/server/storage/types.ts +2 -11
- package/src/shippedFilesAreTracked.spec.ts +3 -2
- package/dist/server/private-media/crypto.d.ts +0 -37
- package/dist/server/private-media/crypto.js +0 -111
- package/dist/server/private-media/index-store.d.ts +0 -31
- package/dist/server/private-media/index-store.js +0 -36
- package/dist/server/private-media/mountPrivateRoutes.d.ts +0 -26
- package/dist/server/private-media/mountPrivateRoutes.js +0 -219
- package/dist/server/private-media/pipeline.d.ts +0 -37
- package/dist/server/private-media/pipeline.js +0 -97
- package/dist/server/private-media/unlock.d.ts +0 -52
- package/dist/server/private-media/unlock.js +0 -55
- package/src/server/private-media/blobRoute.spec.ts +0 -165
- package/src/server/private-media/crypto.spec.ts +0 -81
- package/src/server/private-media/crypto.ts +0 -155
- package/src/server/private-media/gatePolicy.spec.ts +0 -98
- package/src/server/private-media/index-store.ts +0 -83
- package/src/server/private-media/mountPrivateRoutes.ts +0 -265
- package/src/server/private-media/pipeline.spec.ts +0 -111
- package/src/server/private-media/pipeline.ts +0 -170
- package/src/server/private-media/routes.spec.ts +0 -265
- package/src/server/private-media/unlock.ts +0 -112
- 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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
45
|
+
let cat: FileCatalogue;
|
|
46
46
|
let photoId: string;
|
|
47
47
|
let videoId: string;
|
|
48
48
|
|
|
@@ -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
|
|
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
|
|
11
|
+
import type { FileCatalogue } from './types';
|
|
19
12
|
import { bytesStream } from 'cursedbelt-core/testing';
|
|
20
13
|
|
|
21
|
-
//
|
|
22
|
-
//
|
|
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
|
|
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
|
|
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;
|
|
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
|
|
29
|
-
|
|
30
|
-
`INSERT INTO files (id, owner, name, mime, size, file_type,
|
|
31
|
-
VALUES ($id, 'u1', $name, 'video/mp4', 10, 'video',
|
|
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
|
|
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(
|
|
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
|
|
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,
|
|
83
|
-
VALUES ('loop1', 'u1', 'l.mp4', 'video/mp4', 10, 'video',
|
|
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,
|
|
115
|
-
VALUES ('raw1', 'u1', 'r.mp4', 'video/mp4', 10, 'video',
|
|
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();
|