Skip to content

Commit 898703f

Browse files
fix(service-storage): the pending-upload retention keeps a pending sys_file a record or attachment still holds (#26577)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XSxRKLpZsRFYJdB6iumoyqn)_ Fixes #26533 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:`sys_file` 的「未完成上传 7 天清理」现在先问「还有谁持有这个文件」(与墓碑分支同一个判定 `findFileHolder`:`sys_attachment` 行或 `ref_*` 列)。被记录或附件持有的 pending 文件不删字节、不删行、保持 `pending`,每次清理打一条 warn 点名文件和持有者;无人持有的照旧清理。 - **为什么改**:main 上实测:一条被记录占着的 pending 文件过 7 天后,字节和 `sys_file` 行都被清掉,记录字段仍指向该 id(数据丢失)。#26531 之前用户写入能造出这种行;今天系统写入(种子加载器先写记录、后 `claimFiles`)仍会造出。 - **风险与回滚**:只会少删,不会多删;判定读失败时保留到下一轮。被保留的文件不会被标成 `committed`(那会跳过 `/upload/complete` 的字节核对)。回滚 = revert 本 PR。 - **先看**:`packages/services/service-storage/src/attachment-lifecycle.ts` 的 `createSysFileReapGuard` pending 分支;`src/pending-claimed-file-reap.integration.test.ts`。 - **两分钟验证**:`pnpm --filter @objectstack/service-storage exec vitest run src/pending-claimed-file-reap.integration.test.ts`,预期 1/1 通过。 - **需要你做的**:无,席位落地。 ## What changes - `createSysFileReapGuard` (`attachment-lifecycle.ts`), the `pending` branch: before the byte delete it asks `findFileHolder(engine, id, row)`, the same hold question the `deleted` branch asks (a `sys_attachment` join row, or the `ref_*` ownership columns via `hasFieldReferenceOwner`). It is one read per candidate row, only over the rows the sweep already handed the guard. - Held: veto (the row is not confirmed), no byte delete, no write to the row. It stays `pending`: the guard does not commit it, because committing would skip the byte check `/upload/complete` makes. One `warn` per sweep names the file, its holder (`the record OBJECT/ID.FIELD`, or an attachment) and the two ways out: the uploader completes the upload, or the record lets go of the file and the next sweep reclaims it. - Unheld: best-effort byte delete, then confirm, as before. - The hold read throwing: caught by the existing `catch`, so the row is vetoed and retried next sweep (fail toward retention). That warn now says "reclaim failed" instead of "byte delete failed", since either step can be the cause. - A private `describeHolder` helper for the warning text. Nothing exported changes. - Patch changeset for `@objectstack/service-storage`. ## Tests - New `src/pending-claimed-file-reap.integration.test.ts`: a real `ObjectQL` over sqlite `:memory:`, `StorageServicePlugin` on the local adapter registering its own reap guard with a real `LifecycleService` whose clock stands 8 days ahead. Two presigned uploads are PUT and never completed. A system insert makes `media/m_1.poster` hold one of them (`pending` with `ref_*` stamped, the state a signed-in write left before #26531). After `lifecycle.sweep()`: the held file keeps its bytes (`storage.exists`) and its row (`pending`, `ref_*` intact), the record still names it, and exactly one warning names it and `media/m_1.poster`. The unheld file of the same age is reaped, row and bytes. - `src/attachment-lifecycle.test.ts`: the held veto on each surface (`ref_*` columns, a `sys_attachment` row): no delete, no update, one warning naming the file; and a hold read that throws vetoes. ## Acceptance notes - **Measured on `origin/main` `ab3c776a`, before the fix** (a scratch copy of the new test printing the state after the sweep, then deleted): `{"errors":[],"claimedBytes":false,"claimedRow":null,"recordPoster":true,"abandonedBytes":false,"abandonedRow":null}`. So the card's premise holds, and it is worse than its text: the sweep deletes the held file's `sys_file` row as well as its bytes, while the record keeps the id. - **Can a held pending row still be created on `main` after #26531?** Not by a signed-in user write: that is refused. By a system or context-less write, yes: the field-reference hooks stamp `ref_*` and leave the row `pending`. #26531's own test `a system write is not judged: the seed loader writes over a pending row it commits after` pins exactly that state, and the new integration test builds its fixture the same way. In the seed loader the window is short (it calls `claimFiles` right after the record write), but a process that dies between the two leaves the state for good. So the guard covers existing rows and this remaining producer. - The warning repeats each sweep until the file is completed or released. That is the "flagged for an operator" the triage allows; no new state, column or gate. - Not in scope, noted: the download door still answers `404` for a held pending file (#26531 measured it). Completing the upload is the remedy the warning names. ## Verification Run at head `cca42daf`. <details><summary>Red first, and ablation</summary> - Red: commit `a66bfc54` adds only the new integration test on base `ab3c776a`. `vitest run src/pending-claimed-file-reap.integration.test.ts`: **1 failed (1)**, `AssertionError: the held file keeps its bytes: expected false to be true`. The lock's `VERDICT command-exit 1`. - Ablation at `cca42daf` (`node scripts/ablation-replace.mjs`, anchor `if (holder) {` in the pending branch x1 → x0, replaced by `if (false && holder) {`, blob `9392fabf` → `47e2926a`): over the two suites, **3 failed, 48 passed (51)**, exactly the three held-veto cases (the integration test and both `it.each` rows). The hold-read-throws case stays green, as expected: the throw still reaches the `catch`. Restored by the script: blob after restore `9392fabf` = the HEAD blob, `git diff HEAD` empty. - `service-storage`'s vitest resolves its own `src/`, so no rebuild is needed for these legs. </details> <details><summary>Tests</summary> - `pnpm --filter @objectstack/service-storage exec vitest run src/pending-claimed-file-reap.integration.test.ts src/attachment-lifecycle.test.ts`: Test Files 2 passed (2), Tests 51 passed (51). - `pnpm --filter @objectstack/service-storage test` under the lock: Test Files 70 passed (70), Tests 1257 passed (1257). `VERDICT command-exit 0`. - `pnpm --filter @objectstack/service-storage typecheck` under the lock: `VERDICT command-exit 0`, including `check:test-typecheck: OK` over `tsconfig.test.json` (the test layer). - `eslint --no-inline-config packages/services/service-storage`: exit 0. - Not run locally: `test:repo` (CI `Test Core`) and the dogfood battery (no `!` in the title, no `(narrowing)` arm). </details> <details><summary>Derived gates (`dispatch-gates.mjs --commands`, reconciled with `--ran`)</summary> - 61 derived families, all exit 0. `--ran`: `✓ dispatch-gates --ran: 61 derived famil(ies) accounted for — 61 run, 0 NOT-MEASURED`. - Three first exited 3 (PREREQUISITE NOT MET) and were re-run after their prerequisite: `node scripts/check-plugin-teardown-shape.mjs --self-test` after `git fetch --unshallow origin main` (`48 cases pass`); `pnpm check:i18n` and `pnpm check:dual-build-cjs-loads` after the workspace build (`all bundles in sync`; `112 published require entry point(s) across 69 package(s) load`). - Exit codes were captured before any pipe (`cmd > log 2>&1; ex=$?`). </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5pNW4f4hjRtnx9X3UP)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 68edbcf commit 898703f

4 files changed

Lines changed: 292 additions & 2 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/service-storage': patch
3+
---
4+
5+
fix(service-storage): the pending-upload retention no longer deletes a `pending` `sys_file` that a record or an attachment still holds
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
- **Before.** `sys_file` reaps `status: pending` rows after 7 days, through the `sys_file` reap guard. Its pending branch deleted the bytes and the row without asking whether anything held the file. A pending file can be held: until the previous release a signed-in record write could claim one (`ref_object` / `ref_id` / `ref_field` stamped while the row stayed `pending`), and a system write still does, since the seed loader writes the record before `claimFiles` commits the row. Seven days later the record named a file id whose bytes and row were gone.
11+
- **Now.** The pending branch asks the same hold question the tombstone branch asks (a `sys_attachment` row, or the `ref_*` columns). A held pending file is kept, bytes and row, and stays `pending`; each sweep logs one warning naming the file and its holder. An unheld pending file is reaped as before. A failure to answer the hold question keeps the file for the next sweep.
12+
- **What an operator does with the warning.** The uploader completes the upload with `POST /api/v1/storage/upload/complete`, which checks the stored bytes and commits the file. A record that lets go of the file hands it back to the sweep. The guard never commits the file itself, because that would skip the byte check the complete door makes.

‎packages/services/service-storage/src/attachment-lifecycle.test.ts‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -579,6 +579,40 @@ describe('createSysFileReapGuard', () => {
579579
expect(confirmed).toEqual(['p1']);
580580
});
581581

582+
// [#26533] A pending file something holds is kept, on either surface, and
583+
// stays pending — the guard writes nothing to it.
584+
it.each([
585+
['its ref_* columns name an owner', { attachments: [] }, { ref_object: 'media', ref_id: 'm1', ref_field: 'poster' }],
586+
['a sys_attachment row holds it', { attachments: [{ id: 'a1', file_id: 'p1' }] }, {}],
587+
])('vetoes a held pending upload (%s): no byte delete, no write, one warning naming it', async (_label, seed, refs) => {
588+
const engine = fakeEngine({ ...seed, files: [] });
589+
const s = storage();
590+
const logger = silentLogger();
591+
const guard = createSysFileReapGuard(engine, () => s, logger);
592+
593+
const confirmed = await guard('sys_file', [{ id: 'p1', key: 'user/p1.bin', status: 'pending', ...refs }]);
594+
595+
expect(confirmed).toEqual([]);
596+
expect(s.delete).not.toHaveBeenCalled();
597+
expect(engine.updates).toEqual([]);
598+
expect(logger.warn).toHaveBeenCalledTimes(1);
599+
expect(String(logger.warn.mock.calls[0][0])).toContain('sys_file p1');
600+
});
601+
602+
it('vetoes a pending upload when the hold question cannot be answered (fail toward retention)', async () => {
603+
const engine = fakeEngine({ attachments: [], files: [] });
604+
engine.find = vi.fn(async () => { throw new Error('engine down'); });
605+
const s = storage();
606+
const logger = silentLogger();
607+
const guard = createSysFileReapGuard(engine, () => s, logger);
608+
609+
const confirmed = await guard('sys_file', [{ id: 'p1', key: 'user/p1.bin', status: 'pending' }]);
610+
611+
expect(confirmed).toEqual([]);
612+
expect(s.delete).not.toHaveBeenCalled();
613+
expect(logger.warn).toHaveBeenCalledTimes(1);
614+
});
615+
582616
it('vetoes rows in any other state (fail toward retention)', async () => {
583617
const engine = fakeEngine({ attachments: [], files: [] });
584618
const guard = createSysFileReapGuard(engine, storage, silentLogger());

‎packages/services/service-storage/src/attachment-lifecycle.ts‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,13 @@ export async function findFileHolder(
392392
return hasFieldReferenceOwner(row) ? 'field-owner' : null;
393393
}
394394

395+
/** Who {@link findFileHolder} found, in words an operator can look up. */
396+
function describeHolder(holder: Exclude<FileHolder, null>, row: Record<string, unknown>): string {
397+
if (holder === 'attachment') return 'an attachment (a sys_attachment row)';
398+
const field = row.ref_field != null && row.ref_field !== '' ? `.${String(row.ref_field)}` : '';
399+
return `the record ${String(row.ref_object)}/${String(row.ref_id)}${field}`;
400+
}
401+
395402
/**
396403
* The BATCHED form of {@link findFileHolder} — "which of these files is still
397404
* held?" — for callers holding many rows at once (commit c3c72a4bc).
@@ -582,7 +589,12 @@ async function tombstoneRefusedAttachFile(
582589
* (bytes reclaimed first) or vetoed (kept this sweep):
583590
*
584591
* - `pending`: the upload was never completed; bytes may or may not exist.
585-
* Best-effort byte delete, then confirm.
592+
* The same hold question first ({@link findFileHolder}): a record write
593+
* could once claim a pending file, and a system write still does (the seed
594+
* loader writes the record before `claimFiles` commits the row), so a held
595+
* pending row is the only pointer to bytes a record names. Held → veto and
596+
* warn, leaving it `pending`: committing it here would skip the byte check
597+
* `/upload/complete` makes. Unheld → best-effort byte delete, then confirm.
586598
* - `deleted`: re-verify at sweep time that nothing holds the file on
587599
* EITHER surface — zero `sys_attachment` join rows AND empty ownership
588600
* columns (`ref_*`). Either found (hook bypass, restore, re-claim) →
@@ -636,11 +648,22 @@ export function createSysFileReapGuard(
636648

637649
if (row.status === 'pending') {
638650
try {
651+
const holder = await findFileHolder(engine, id, row);
652+
if (holder) {
653+
logger.warn(
654+
`[storage] reap guard: kept pending sys_file ${id} past the pending-upload retention — ` +
655+
`${describeHolder(holder, row)} still holds it, but its upload was never completed, so the ` +
656+
`bytes were never checked against what the upload declared and downloads refuse it. Its ` +
657+
`uploader completes it with POST /api/v1/storage/upload/complete; a record that lets go of ` +
658+
`it hands it back to this sweep. Repeated each sweep until one of the two happens.`,
659+
);
660+
continue;
661+
}
639662
if (storage && typeof row.key === 'string' && row.key) await storage.delete(row.key);
640663
confirmed.push(id);
641664
} catch (err) {
642665
logger.warn(
643-
`[storage] reap guard: byte delete failed for pending sys_file ${id} (${(err as Error)?.message ?? err}); retrying next sweep`,
666+
`[storage] reap guard: reclaim failed for pending sys_file ${id} (${(err as Error)?.message ?? err}); retrying next sweep`,
644667
);
645668
}
646669
continue;
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #26533 — the pending-upload retention never reaps a file a record holds.
5+
*
6+
* `sys_file` declares a 7-day retention on `status: pending` rows: a presigned
7+
* upload nobody completed is reclaimed, row and bytes, by the platform sweep
8+
* through {@link createSysFileReapGuard}. Before #26531 a signed-in record
9+
* write could claim such a file: the record kept the id, and the row was
10+
* stamped with `ref_*` while it stayed `pending`. A system write still leaves
11+
* that state today (the seed loader writes the record before `claimFiles`
12+
* commits the row). The guard's pending branch deleted the bytes without
13+
* asking whether anything held the file, so seven days later the record named
14+
* an id whose bytes were gone.
15+
*
16+
* Now the pending branch asks the one hold question the `deleted` branch asks
17+
* ({@link findFileHolder}): a held pending file is kept and named in a warning
18+
* for an operator, and stays `pending` (committing it would skip the byte
19+
* check `/upload/complete` makes, #24548). An unheld pending file of the same
20+
* age is reaped as before.
21+
*
22+
* Driven as it ships: one real `ObjectQL` engine over sqlite `:memory:`,
23+
* `StorageServicePlugin` booted on the local adapter (its own reap guard,
24+
* registered with a real `LifecycleService`), the upload doors called as
25+
* routes, and the sweep run with its clock past the retention.
26+
*/
27+
28+
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
29+
import { promises as fs } from 'node:fs';
30+
import { join } from 'node:path';
31+
import { tmpdir } from 'node:os';
32+
import type { RouteHandler } from '@objectstack/spec/contracts';
33+
import type { IStorageService } from '@objectstack/spec/contracts';
34+
import { RAW_FILE_VALUES_CONTEXT_KEY, type EngineQueryOptions } from '@objectstack/spec/data';
35+
import { ObjectQL, LifecycleService } from '@objectstack/objectql';
36+
import { SqlDriver } from '@objectstack/driver-sql';
37+
import { StorageServicePlugin } from './storage-service-plugin.js';
38+
import { installDefaultOrganization } from './default-organization.testkit.js';
39+
40+
const BASE = '/api/v1/storage';
41+
const SYS = { isSystem: true };
42+
const SYS_RAW = { isSystem: true, [RAW_FILE_VALUES_CONTEXT_KEY]: true };
43+
const WRITER = 'u_writer';
44+
const DAY_MS = 24 * 60 * 60 * 1000;
45+
46+
const recordingLogger = () => ({
47+
info: vi.fn(), warn: vi.fn(), debug: vi.fn(), error: vi.fn(),
48+
trace: vi.fn(), fatal: vi.fn(), child() { return this; },
49+
});
50+
51+
type Res = { _status: number; _json: any };
52+
53+
describe('#26533 — the pending-upload retention never reaps a file a record holds', () => {
54+
let rootDir: string;
55+
let engine: ObjectQL;
56+
let lifecycle: LifecycleService;
57+
let storage: IStorageService;
58+
const logger = recordingLogger();
59+
const routes = new Map<string, RouteHandler>();
60+
61+
beforeAll(async () => {
62+
rootDir = join(tmpdir(), `os-26533-${Date.now()}-${Math.random().toString(36).slice(2)}`);
63+
await fs.mkdir(rootDir, { recursive: true });
64+
65+
engine = new ObjectQL({ logger: recordingLogger() } as any);
66+
engine.registerDriver(new SqlDriver({
67+
client: 'better-sqlite3',
68+
connection: { filename: ':memory:' },
69+
useNullAsDefault: true,
70+
}) as any, true);
71+
await engine.init();
72+
73+
// The real engine, except that a read of an object this fixture never
74+
// registered — the permission store the door's context resolution
75+
// consults — answers empty.
76+
const dataEngine = new Proxy(engine, {
77+
get(target, prop) {
78+
if (prop === 'find') {
79+
return (object: string, query?: EngineQueryOptions) =>
80+
target.getSchema(object) ? target.find(object, query) : Promise.resolve([]);
81+
}
82+
const value = Reflect.get(target, prop, target);
83+
return typeof value === 'function' ? value.bind(target) : value;
84+
},
85+
});
86+
87+
// The sweep's clock stands eight days ahead: every row created below is
88+
// past the 7-day pending-upload retention.
89+
lifecycle = new LifecycleService({
90+
getEngine: () => engine as any,
91+
logger: { info: () => {}, warn: () => {}, debug: () => {} },
92+
now: () => Date.now() + 8 * DAY_MS,
93+
referenceAudit: { enabled: false },
94+
});
95+
96+
const services = new Map<string, unknown>();
97+
const ready: Array<() => Promise<void> | void> = [];
98+
const mount = (m: string) => (path: string, handler: RouteHandler) => { routes.set(`${m}:${path}`, handler); };
99+
const ctx = {
100+
logger,
101+
registerService: (name: string, svc: unknown) => { services.set(name, svc); },
102+
getService: <T>(name: string): T => {
103+
if (!services.has(name)) throw new Error(`service '${name}' not registered`);
104+
return services.get(name) as T;
105+
},
106+
hook: (event: string, fn: () => Promise<void> | void) => { if (event === 'kernel:ready') ready.push(fn); },
107+
};
108+
ctx.registerService('objectql', dataEngine);
109+
ctx.registerService('lifecycle', lifecycle);
110+
ctx.registerService('http-server', {
111+
get: mount('GET'), post: mount('POST'), put: mount('PUT'),
112+
delete: vi.fn(), patch: vi.fn(), use: vi.fn(),
113+
listen: vi.fn().mockResolvedValue(undefined),
114+
close: vi.fn().mockResolvedValue(undefined),
115+
});
116+
ctx.registerService('manifest', { register: (manifest: any) => engine.registerApp(manifest) });
117+
ctx.registerService('auth', {
118+
api: {
119+
getSession: async ({ headers }: { headers: Headers }) => {
120+
const userId = headers.get('x-test-user');
121+
return userId ? { user: { id: userId }, session: {} } : undefined;
122+
},
123+
},
124+
});
125+
126+
const plugin = new StorageServicePlugin({ adapter: 'local', local: { rootDir }, bindToSettings: false });
127+
await plugin.init(ctx as any);
128+
129+
engine.registerApp({
130+
id: 'com.objectstack.test.pending-claimed-file-reap',
131+
name: 'Pending claimed file reap',
132+
version: '1.0.0',
133+
type: 'plugin',
134+
scope: 'system',
135+
objects: [{
136+
name: 'media',
137+
label: 'Media',
138+
fields: {
139+
name: { name: 'name', type: 'text' },
140+
poster: { name: 'poster', label: 'Poster', type: 'image' },
141+
},
142+
}],
143+
} as never);
144+
await engine.syncSchemas();
145+
await installDefaultOrganization(engine);
146+
await plugin.start(ctx as any);
147+
for (const fn of ready) await fn();
148+
storage = services.get('storage') as IStorageService;
149+
});
150+
151+
afterAll(async () => {
152+
try { await engine?.destroy(); } catch { /* already torn down */ }
153+
if (rootDir) await fs.rm(rootDir, { recursive: true, force: true });
154+
});
155+
156+
/** Call a storage route as the writer. */
157+
const call = async (method: string, path: string, req: Record<string, unknown>): Promise<Res> => {
158+
const res: any = {
159+
_status: 200, _json: null,
160+
json(data: any) { res._json = data; return res; },
161+
send() { return res; },
162+
status(code: number) { res._status = code; return res; },
163+
header() { return res; },
164+
};
165+
const handler = routes.get(`${method}:${path}`);
166+
if (!handler) throw new Error(`no route ${method} ${path}`);
167+
await handler({ params: {}, query: {}, headers: { 'x-test-user': WRITER }, method, path, ...req } as any, res);
168+
return res;
169+
};
170+
171+
/** Presign, then PUT the bytes. `/upload/complete` is never called, so the row stays `pending`. */
172+
const pendingUpload = async (filename: string) => {
173+
const bytes = Buffer.alloc(100, 1);
174+
const presigned = await call('POST', `${BASE}/upload/presigned`, {
175+
body: { filename, mimeType: 'image/png', size: bytes.byteLength },
176+
});
177+
expect(presigned._status, JSON.stringify(presigned._json)).toBe(200);
178+
const { fileId, uploadUrl } = presigned._json.data as { fileId: string; uploadUrl: string };
179+
const token = uploadUrl.slice(uploadUrl.lastIndexOf('/') + 1);
180+
const put = await call('PUT', `${BASE}/_local/raw/:token`, { params: { token }, rawBody: async () => bytes });
181+
expect(put._status, JSON.stringify(put._json)).toBe(200);
182+
return fileId;
183+
};
184+
185+
const fileRow = async (id: string) =>
186+
((await engine.find('sys_file', { where: { id }, context: SYS })) as Array<Record<string, any>>)[0] ?? null;
187+
const record = async (id: string) =>
188+
((await engine.find('media', { where: { id }, context: SYS_RAW })) as Array<Record<string, any>>)[0];
189+
190+
it('past the retention, a pending file a record holds keeps its bytes and row; an unheld one is reaped', async () => {
191+
const claimed = await pendingUpload('claimed.png');
192+
const abandoned = await pendingUpload('abandoned.png');
193+
// The state a signed-in write left before #26531 (and a system write
194+
// still leaves): the record holds the id, and the row carries `ref_*`
195+
// while it stays `pending`.
196+
await engine.insert('media', { id: 'm_1', name: 'm_1', poster: claimed } as any, { context: SYS });
197+
const before = await fileRow(claimed);
198+
expect(before).toMatchObject({ status: 'pending', ref_object: 'media', ref_id: 'm_1', ref_field: 'poster' });
199+
const claimedKey = before!.key as string;
200+
const abandonedKey = (await fileRow(abandoned))!.key as string;
201+
expect(await storage.exists(claimedKey)).toBe(true);
202+
expect(await storage.exists(abandonedKey)).toBe(true);
203+
204+
const report = await lifecycle.sweep();
205+
206+
expect(report.errors, JSON.stringify(report.errors)).toEqual([]);
207+
// The held file: bytes and row kept, still `pending`, still owned.
208+
expect(await storage.exists(claimedKey), 'the held file keeps its bytes').toBe(true);
209+
expect(await fileRow(claimed)).toMatchObject({
210+
status: 'pending', ref_object: 'media', ref_id: 'm_1', ref_field: 'poster',
211+
});
212+
expect((await record('m_1'))?.poster).toBe(claimed);
213+
// Flagged for an operator, naming the file and its holder.
214+
const warned = logger.warn.mock.calls.map((c) => String(c[0])).filter((line) => line.includes(claimed));
215+
expect(warned).toHaveLength(1);
216+
expect(warned[0]).toContain('media/m_1.poster');
217+
// The unheld file of the same age: reaped, bytes and row.
218+
expect(await fileRow(abandoned), 'the abandoned upload is reaped').toBeNull();
219+
expect(await storage.exists(abandonedKey)).toBe(false);
220+
});
221+
});

0 commit comments

Comments
 (0)