Skip to content

Commit 416d9dd

Browse files
fix(service-storage)!: a file field refuses a file whose upload was never completed (#26531)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1X93jJ4BvZWR6vdK9XuH8QCS)_ Fixes #26479 Clause-②: yes (narrowing) Metadata-protocol: none Skills-docs: updated here — `content/docs/api/plugin-endpoints.mdx` (the upload protocol section) now says a file field takes the `fileId` only after `/upload/complete`. No `skills/**` file teaches the upload flow: `git grep -n -i "presign\|upload/complete" -- skills/` finds nothing. ## 维护者速读 - **改了什么**:记录写入时,文件字段若引用一个上传还没 complete 的文件(`sys_file.status = pending`),现在在字段处拒绝,返回 400 `VALIDATION_FAILED`,条目点名字段和文件 id;记录不落库,`sys_file` 行不盖 `ref_*`。调用过 `/upload/complete` 之后同一写入照常通过。 - **为什么改**:之前这种写入答 201,记录显示「已附件」,但文件下载口答 404,字节也从没被核对过(实测:声明 100 字节、实际 PUT 2 MiB,能过 1 MiB 的 `maxSize`,之后 complete 又被 #24548 拒,文件永远停在 pending 却被记录占着)。 - **风险与回滚**:会拒掉「先存 fileId、后调 complete」的客户端写法(201 变 400);SDK 的 `storage.upload()` 和 Console 的 UploadProvider 都先 complete 再返回 id,不受影响。系统写入(种子加载器)不判。回滚 = revert 本 PR。 - **先看**:`packages/services/service-storage/src/file-reference-lifecycle.ts` 的 `applyCopyOnClaim` 判定循环(`incomplete` 分支与 `held` 豁免);`file-field-pending-upload.integration.test.ts`。 - **两分钟验证**:`pnpm --filter @objectstack/service-storage exec vitest run src/file-field-pending-upload.integration.test.ts`,预期 8/8 通过。 - **需要你做的**:无,席位落地。 **BREAKING** accept-set narrowing at the file-field write gate (`installFileReferenceHooks`, `@objectstack/service-storage`). A signed-in record write that names a `sys_file` whose upload never completed used to answer `201`. It now answers `400 VALIDATION_FAILED`. Remedy: call `POST /api/v1/storage/upload/complete`, then save again. ADR-0087: `not-required (no-migration-prescription)`, in the changeset. ## What changes - `file-reference-lifecycle.ts`: copy-on-claim's judging pass (`applyCopyOnClaim`, run by `beforeInsert` and `beforeUpdate` before the driver) now refuses a field naming a file whose status is not `committed` or `deleted`, when the write has a caller. The refusal is `refuseIncompleteUpload`: the #26043 envelope (one ADR-0114 `invalid_value` entry per field, through `assertNoFileFieldRefused`), naming the field and the file id. It runs after the read check, so a file the caller may not read keeps #26043's answer, which names nothing. - What "completed" means here: the ruling says "not `completed`". `sys_file.status` is `pending | committed | deleted`. `committed` is what `/upload/complete` writes. `deleted` is a committed file a record let go, which `claimFile` already revives within its grace window, so it stays accepted. This is the same split `field-file-service.ts`'s `describeRow` uses. - Not judged, as before: a system write and a context-less programmatic write (`referencingCallerOf` answers null). The seed loader depends on this: `IFieldFileService.storeFile` leaves the row `pending`, the seed writes the record, and `claimFiles` commits it after. - Also not refused: an update that resends a pending file the slot already holds (`isSameSlot`). That claims nothing, and a record left in that state by a write before this change can still be edited. An ablation pins it (below). - Doc: one paragraph in `content/docs/api/plugin-endpoints.mdx`. ### Doors checked Every door that resolves a file field's `sys_file` is this one hook pass. - Single insert, update: covered. - Batch insert: the engine fires `beforeInsert` per row (`engine.ts` "Batch inserts trigger beforeInsert/afterInsert PER ROW"). Pinned: a two-row batch is refused whole. - Predicate (`multi: true`) update: a file id token is already refused before this pass (`FILE_FIELD_BULK_WRITE_REFUSED`, #7102). - REST CRUD, batch and the import routes (`data-action-routes.ts` → `p.createData` / `p.updateData`) all reach the engine, so they reach this pass. - `IFieldFileService.claimFiles`: a server verb (seed loader only) that commits the file itself. It is not a record write door. - Anonymous doors (public forms, guest endpoints): refused earlier for any file value (#25694). ## Tests New `packages/services/service-storage/src/file-field-pending-upload.integration.test.ts`. It uses a real `ObjectQL` on sqlite `:memory:` and `StorageServicePlugin` on the local adapter, calls the upload doors as routes, and writes as the uploader: 1. The card's steps 1–4: the write is refused at the field (pinned whole), no record is written, and the row stays `pending` with no `ref_*`. 2. After `/upload/complete`, the same write is taken and the file is claimed and `committed`. 3. Declared 100 bytes, PUT 2 MiB: the write is refused, `/upload/complete` answers 409 (#24548), and the row stays pending and unclaimed. 4. Update door: refused, and the record keeps its value. 5. Batch insert: refused whole, and neither row is written. 6. `multiple: true`: only the pending ids are named, and the committed one is not claimed either. 7. Control: a system write is not judged (the seed path). 8. Control: a slot resending the pending file it holds is not refused. ## Acceptance notes Measured on `origin/main` `45de8c6c` (triage step 1), with a scratch copy of this harness before the fix: - M1, the card's steps: `201`, the id is stored, and the row reads `status=pending` with `ref_object=media`, `ref_field=poster` and `ref_id` stamped. Reproduced. - `accept` / `maxSize` on a pending file are **not skipped**. They run on the row's declared metadata: declared `application/zip` is refused `400 ERR_FILE_CONSTRAINT`, and declared 2 MiB against `maxSize` 1 MiB is refused the same way. - The #24548 byte check **is skipped**. It lives only at `/upload/complete`. Declared 100 / PUT 2 MiB was claimed with `201`. `/upload/complete` then answered `409 RESOURCE_CONFLICT`, so the row stayed `pending` and claimed. - `/upload/complete` on a file a record already claimed runs no field constraint. It answered `200`, set `committed` and kept the `ref_*`. Only uploader, organization and held-bytes are judged there. - The download door for a claimed pending file answered `404 FILE_NOT_FOUND`. - Read, not measured: the `sys_file` retention (`onlyWhen: { status: 'pending' }`, 7d) and the reap guard (`attachment-lifecycle.ts`, `row.status === 'pending'` branch) delete a pending row's bytes without looking at `ref_*`. So on main, a claimed pending file would be reaped from under its record. After this change, a user write can no longer produce that state. ## Verification Run at head `c3aca48d` (the only commit on the branch). <details><summary>Red first, and ablations</summary> - Red: `file-reference-lifecycle.ts` restored to base `45de8c6c` (`git checkout 45de8c6 -- PATH`, `grep -c isCompletedUpload` = 0), then the new suite: **6 failed, 2 passed (8)**. The two that pass are the controls (system write, held slot). Restored with `git checkout HEAD -- PATH`: `git diff HEAD` is empty, and the blob is `f111b144` = the HEAD blob. - Ablation of the held-slot exemption (`node scripts/ablation-replace.mjs`, anchor `if (caller && !held && !isCompletedUpload(found))` x1 → x0, blob `f111b144` → `91f32d81`): exactly `a record already holding a pending file can still be edited…` fails. Restored, blob matches HEAD. - `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/file-field-pending-upload.integration.test.ts`: 8 passed (8). - `pnpm --filter @objectstack/service-storage test`: Test Files 69 passed (69), Tests 1253 passed (1253). - `pnpm --filter @objectstack/service-storage typecheck` under the lock: `VERDICT command-exit 0`. - `eslint --no-inline-config packages/services/service-storage`: exit 0. </details> <details><summary>Full dogfood battery (bang title / narrowing arm)</summary> - `pnpm --filter '@objectstack/dogfood^...' build && pnpm --filter @objectstack/dogfood test` under the lock, at `c3aca48d`: `VERDICT command-exit 0`, Test Files 300 passed | 3 skipped (303), Tests 2532 passed | 18 skipped (2550). No failures. </details> <details><summary>Derived gates (`dispatch-gates.mjs --commands`, reconciled with `--ran`)</summary> - 93 derived families: 91 run with exit 0, 2 NOT MEASURED. `--ran` reconciliation: `✓ dispatch-gates --ran: 93 derived famil(ies) accounted for — 91 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3)`. - NOT MEASURED: `pnpm --filter @objectstack/spec run check:skill-examples` and `pnpm check:dual-build-cjs-loads`. Both exited 3 (PREREQUISITE NOT MET: they need a full workspace build, and only the dogfood closure was built). Neither reads a file this diff touches; CI runs both. - `node scripts/check-plugin-teardown-shape.mjs --self-test` first exited 3 on the shallow clone. After `git fetch origin 621a487` (its positive control) it exits 0: `48 cases pass`. - Exit codes were captured before any pipe (`cmd > log 2>&1; ex=$?`). </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5oz3SuMTEoG66BkXVV)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 185512c commit 416d9dd

4 files changed

Lines changed: 401 additions & 2 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/service-storage': patch
3+
---
4+
5+
fix(service-storage)!: a file field refuses a file whose upload was never completed, so a record cannot claim a `pending` `sys_file`
6+
7+
Clause-②: yes (narrowing)
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) A record write naming a pending sys_file is refused at the file-field write gate: no key of any metadata schema is removed, renamed or re-shaped, no export, type or error code is removed or renamed, and the refusal reuses the existing VALIDATION_FAILED envelope with an invalid_value field entry. There is nothing for `objectstack migrate meta` to rewrite and no tombstone. -->
11+
12+
**BREAKING** accept-set narrowing at the file-field write gate (`installFileReferenceHooks`, `@objectstack/service-storage`), which every engine record write runs before the driver: a single insert, each row of a batch insert, and a single update.
13+
14+
- **Before.** A presigned upload is a `sys_file` row with `status: pending` until `POST /api/v1/storage/upload/complete` commits it, and that door is the one that checks the storage backend holds the bytes the row declares (#24548). A signed-in record write naming a `pending` file was taken like a committed one: `201`, the record held the id, and the row was stamped with `ref_object` / `ref_id` / `ref_field` while it stayed `pending`. The field read as attached, the download door answered `404` for it, and the bytes were never measured: a file declared at 100 bytes and PUT at 2 MiB passed a 1 MiB `maxSize`, and the commit door then refused it, so it stayed `pending` and claimed.
15+
- **Now.** That write is refused before the driver runs, with the envelope a file the caller may not read already gets (#26043): one `400 VALIDATION_FAILED` with an ADR-0114 `invalid_value` entry per field. The entry names the field and the file, because the caller may read it. Nothing is written and the `sys_file` row is not stamped. Once `/upload/complete` has run, the same write is taken.
16+
- **Unchanged.** A committed file, and a released (`deleted`) file a claim revives within its grace window. The read check still runs first, so a file the caller may not read keeps #26043's answer. A system write and a context-less programmatic write are not judged: the seed loader writes a record over the `pending` row `storeFile` makes and commits it with `claimFiles` after. A record that already holds a `pending` file can still be updated while it resends that value.
17+
- **Who sees it.** A client that saved the `fileId` from `/upload/presigned` into a record before calling `/upload/complete` now gets `400` instead of `201`. `storage.upload(file)` in the client SDK completes the upload before it returns the id, so it is not affected.
18+
19+
**The remedy.** Call `POST /api/v1/storage/upload/complete` with the `fileId`, then save the record again.

‎content/docs/api/plugin-endpoints.mdx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,11 @@ other adapter.
165165
`storage.upload(file)` runs steps 1–3 for you, including the direct-to-storage
166166
`PUT` in step 2.
167167

168+
A file field takes the `fileId` only after step 3. A signed-in record write
169+
that names a file whose upload was never completed is refused with `400
170+
VALIDATION_FAILED`, one `invalid_value` entry naming the field and the file,
171+
and the file is not claimed. Complete the upload, then save the record again.
172+
168173
<Callout type="warn">
169174
**This table used to list two routes that never worked (#4087).** `POST
170175
/storage/upload` and `GET /storage/file/:id` were a dispatcher-side bridge to
Lines changed: 320 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,320 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #26479 — a file field takes only a file whose upload was completed.
5+
*
6+
* A presigned upload is a `pending` `sys_file` row until `POST
7+
* …/upload/complete` commits it, and that door is the one that asks the
8+
* backend whether it holds the bytes the row declares (#24548). A record write
9+
* used to take a `pending` file as it takes a committed one: `201`, the
10+
* record held the id, and the row was stamped with `ref_*` while it stayed
11+
* `pending` — a field that reads as attached, a download door that answers
12+
* `404` for it, and bytes nobody ever measured.
13+
*
14+
* Now a write whose caller names such a file is refused at the field, with the
15+
* envelope #26043 gives a file the caller may not read: one `400
16+
* VALIDATION_FAILED` with an ADR-0114 `invalid_value` entry per field. It
17+
* names the file too, because the caller may read it and the remedy is to
18+
* complete that upload. Nothing is written, and the row is not stamped. Once
19+
* `/upload/complete` has run, the same write is taken.
20+
*
21+
* Every write door that resolves a file field's `sys_file` is the engine's
22+
* `beforeInsert` / `beforeUpdate` pass, so the doors pinned here are its
23+
* shapes: a single insert, a batch insert, a single update, and a
24+
* `multiple: true` field. (A predicate update naming a file id is refused
25+
* before this judgement, #7102.) A system write — the seed loader, which
26+
* writes a record over the `pending` row `storeFile` made and commits it with
27+
* `claimFiles` after — is not judged.
28+
*
29+
* Driven through the doors as they ship: one real `ObjectQL` engine over
30+
* sqlite `:memory:`, `StorageServicePlugin` booted on the local adapter, the
31+
* upload doors called as routes, and the write as the uploader.
32+
*/
33+
34+
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
35+
import { promises as fs } from 'node:fs';
36+
import { join } from 'node:path';
37+
import { tmpdir } from 'node:os';
38+
import type { RouteHandler } from '@objectstack/spec/contracts';
39+
import { RAW_FILE_VALUES_CONTEXT_KEY, type EngineQueryOptions } from '@objectstack/spec/data';
40+
import { ObjectQL } from '@objectstack/objectql';
41+
import { SqlDriver } from '@objectstack/driver-sql';
42+
import { resolveThrownHttpError } from '@objectstack/types';
43+
import { StorageServicePlugin } from './storage-service-plugin.js';
44+
import { installDefaultOrganization } from './default-organization.testkit.js';
45+
46+
const BASE = '/api/v1/storage';
47+
const SYS = { isSystem: true };
48+
const SYS_RAW = { isSystem: true, [RAW_FILE_VALUES_CONTEXT_KEY]: true };
49+
const WRITER = 'u_writer';
50+
const WRITER_CTX = { userId: WRITER };
51+
const MIB = 1024 * 1024;
52+
53+
const silentLogger = () => ({
54+
info: vi.fn(), warn: vi.fn(), debug: vi.fn(), error: vi.fn(),
55+
trace: vi.fn(), fatal: vi.fn(), child() { return this; },
56+
});
57+
58+
type Res = { _status: number; _json: any };
59+
60+
describe('#26479 — a file field takes only a file whose upload was completed', () => {
61+
let rootDir: string;
62+
let engine: ObjectQL;
63+
const routes = new Map<string, RouteHandler>();
64+
let seq = 0;
65+
66+
beforeAll(async () => {
67+
rootDir = join(tmpdir(), `os-26479-${Date.now()}-${Math.random().toString(36).slice(2)}`);
68+
await fs.mkdir(rootDir, { recursive: true });
69+
70+
engine = new ObjectQL({ logger: silentLogger() } as any);
71+
engine.registerDriver(new SqlDriver({
72+
client: 'better-sqlite3',
73+
connection: { filename: ':memory:' },
74+
useNullAsDefault: true,
75+
}) as any, true);
76+
await engine.init();
77+
78+
// The real engine, except that a read of an object this fixture never
79+
// registered — the permission store the door's context resolution
80+
// consults — answers empty.
81+
const dataEngine = new Proxy(engine, {
82+
get(target, prop) {
83+
if (prop === 'find') {
84+
return (object: string, query?: EngineQueryOptions) =>
85+
target.getSchema(object) ? target.find(object, query) : Promise.resolve([]);
86+
}
87+
const value = Reflect.get(target, prop, target);
88+
return typeof value === 'function' ? value.bind(target) : value;
89+
},
90+
});
91+
92+
const services = new Map<string, unknown>();
93+
const ready: Array<() => Promise<void> | void> = [];
94+
const mount = (m: string) => (path: string, handler: RouteHandler) => { routes.set(`${m}:${path}`, handler); };
95+
const ctx = {
96+
logger: silentLogger(),
97+
registerService: (name: string, svc: unknown) => { services.set(name, svc); },
98+
getService: <T>(name: string): T => {
99+
if (!services.has(name)) throw new Error(`service '${name}' not registered`);
100+
return services.get(name) as T;
101+
},
102+
hook: (event: string, fn: () => Promise<void> | void) => { if (event === 'kernel:ready') ready.push(fn); },
103+
};
104+
ctx.registerService('objectql', dataEngine);
105+
ctx.registerService('http-server', {
106+
get: mount('GET'), post: mount('POST'), put: mount('PUT'),
107+
delete: vi.fn(), patch: vi.fn(), use: vi.fn(),
108+
listen: vi.fn().mockResolvedValue(undefined),
109+
close: vi.fn().mockResolvedValue(undefined),
110+
});
111+
ctx.registerService('manifest', { register: (manifest: any) => engine.registerApp(manifest) });
112+
ctx.registerService('auth', {
113+
api: {
114+
getSession: async ({ headers }: { headers: Headers }) => {
115+
const userId = headers.get('x-test-user');
116+
return userId ? { user: { id: userId }, session: {} } : undefined;
117+
},
118+
},
119+
});
120+
121+
const plugin = new StorageServicePlugin({ adapter: 'local', local: { rootDir }, bindToSettings: false });
122+
await plugin.init(ctx as any);
123+
124+
// The card's scratch object: `poster` accepts PNG/JPEG up to 1 MiB.
125+
engine.registerApp({
126+
id: 'com.objectstack.test.file-field-pending-upload',
127+
name: 'File field pending upload',
128+
version: '1.0.0',
129+
type: 'plugin',
130+
scope: 'system',
131+
objects: [{
132+
name: 'media',
133+
label: 'Media',
134+
fields: {
135+
name: { name: 'name', type: 'text' },
136+
poster: { name: 'poster', label: 'Poster', type: 'image', accept: ['image/png', 'image/jpeg'], maxSize: MIB },
137+
gallery: { name: 'gallery', label: 'Gallery', type: 'image', multiple: true },
138+
},
139+
}],
140+
} as never);
141+
await engine.syncSchemas();
142+
await installDefaultOrganization(engine);
143+
await plugin.start(ctx as any);
144+
for (const fn of ready) await fn();
145+
});
146+
147+
afterAll(async () => {
148+
try { await engine?.destroy(); } catch { /* already torn down */ }
149+
if (rootDir) await fs.rm(rootDir, { recursive: true, force: true });
150+
});
151+
152+
/** Call a storage route as the writer. */
153+
const call = async (method: string, path: string, req: Record<string, unknown>): Promise<Res> => {
154+
const res: any = {
155+
_status: 200, _json: null,
156+
json(data: any) { res._json = data; return res; },
157+
send() { return res; },
158+
status(code: number) { res._status = code; return res; },
159+
header() { return res; },
160+
};
161+
const handler = routes.get(`${method}:${path}`);
162+
if (!handler) throw new Error(`no route ${method} ${path}`);
163+
await handler({ params: {}, query: {}, headers: { 'x-test-user': WRITER }, method, path, ...req } as any, res);
164+
return res;
165+
};
166+
167+
/** The card's steps 1–2: presign, then PUT `bytes` to the URL. `/upload/complete` is not called. */
168+
const pendingUpload = async (bytes: Buffer = Buffer.alloc(100, 1), declaredSize = bytes.byteLength) => {
169+
const presigned = await call('POST', `${BASE}/upload/presigned`, {
170+
body: { filename: 'pend.png', mimeType: 'image/png', size: declaredSize },
171+
});
172+
expect(presigned._status, JSON.stringify(presigned._json)).toBe(200);
173+
const { fileId, uploadUrl } = presigned._json.data as { fileId: string; uploadUrl: string };
174+
const token = uploadUrl.slice(uploadUrl.lastIndexOf('/') + 1);
175+
const put = await call('PUT', `${BASE}/_local/raw/:token`, { params: { token }, rawBody: async () => bytes });
176+
expect(put._status, JSON.stringify(put._json)).toBe(200);
177+
return fileId;
178+
};
179+
180+
const complete = (fileId: string) => call('POST', `${BASE}/upload/complete`, { body: { fileId } });
181+
182+
const fileRow = async (id: string) =>
183+
((await engine.find('sys_file', { where: { id }, context: SYS })) as Array<Record<string, any>>)[0];
184+
const ownerOf = async (id: string) => {
185+
const row = await fileRow(id);
186+
return { status: row?.status, ref: [row?.ref_object ?? null, row?.ref_id ?? null, row?.ref_field ?? null] };
187+
};
188+
const record = async (id: string) =>
189+
((await engine.find('media', { where: { id }, context: SYS_RAW })) as Array<Record<string, any>>)[0];
190+
const failureOf = (write: Promise<unknown>) =>
191+
write.then(() => null, (e: unknown) => resolveThrownHttpError(e));
192+
193+
/** The refusal, as the HTTP boundary answers it: one located entry naming the field and the file. */
194+
const refusedBody = (field: string, label: string, fileIds: string[]) => {
195+
const named = fileIds.length === 1
196+
? `file ${fileIds[0]}: its upload was`
197+
: `files ${fileIds.join(', ')}: their uploads were`;
198+
const message = `${label} cannot reference ${named} never completed. Complete the upload, then save again.`;
199+
return {
200+
status: 400,
201+
code: 'VALIDATION_FAILED',
202+
declaredStatus: 400,
203+
declaredCode: 'VALIDATION_FAILED',
204+
message,
205+
details: { fields: [{ field, code: 'invalid_value', message, label }] },
206+
};
207+
};
208+
209+
const UNCLAIMED = [null, null, null];
210+
211+
it('the card\'s steps 1–4: the write is refused at the field, and nothing is stamped on the pending row', async () => {
212+
const fileId = await pendingUpload();
213+
const id = `m_${++seq}`;
214+
215+
const failure = await failureOf(
216+
engine.insert('media', { id, name: id, poster: fileId } as any, { context: WRITER_CTX }),
217+
);
218+
219+
expect(failure).toEqual(refusedBody('poster', 'Poster', [fileId]));
220+
expect(await record(id)).toBeUndefined();
221+
expect(await ownerOf(fileId)).toEqual({ status: 'pending', ref: UNCLAIMED });
222+
});
223+
224+
it('the same write is taken once /upload/complete has run', async () => {
225+
const fileId = await pendingUpload();
226+
const refusedId = `m_${++seq}`;
227+
expect(await failureOf(
228+
engine.insert('media', { id: refusedId, name: refusedId, poster: fileId } as any, { context: WRITER_CTX }),
229+
)).toEqual(refusedBody('poster', 'Poster', [fileId]));
230+
231+
const done = await complete(fileId);
232+
expect(done._status, JSON.stringify(done._json)).toBe(200);
233+
234+
const id = `m_${++seq}`;
235+
await engine.insert('media', { id, name: id, poster: fileId } as any, { context: WRITER_CTX });
236+
expect((await record(id))?.poster).toBe(fileId);
237+
expect(await ownerOf(fileId)).toEqual({ status: 'committed', ref: ['media', id, 'poster'] });
238+
});
239+
240+
it('bytes the backend holds beyond the declared size are never claimed: the write and the commit both refuse', async () => {
241+
// Declared 100 bytes; 2 MiB PUT, over `poster`'s 1 MiB. The declared size
242+
// passes `maxSize`, so only the commit door's byte check could catch it.
243+
const fileId = await pendingUpload(Buffer.alloc(2 * MIB, 1), 100);
244+
const id = `m_${++seq}`;
245+
246+
expect(await failureOf(
247+
engine.insert('media', { id, name: id, poster: fileId } as any, { context: WRITER_CTX }),
248+
)).toEqual(refusedBody('poster', 'Poster', [fileId]));
249+
const commit = await complete(fileId);
250+
251+
expect(commit._status, JSON.stringify(commit._json)).toBe(409);
252+
expect(await ownerOf(fileId)).toEqual({ status: 'pending', ref: UNCLAIMED });
253+
});
254+
255+
it('an update naming a pending file is refused, and the record keeps what it held', async () => {
256+
const id = `m_${++seq}`;
257+
await engine.insert('media', { id, name: id } as any, { context: WRITER_CTX });
258+
const fileId = await pendingUpload();
259+
260+
const failure = await failureOf(engine.update('media', { id, poster: fileId } as any, { context: WRITER_CTX }));
261+
262+
expect(failure).toEqual(refusedBody('poster', 'Poster', [fileId]));
263+
expect((await record(id))?.poster ?? null).toBeNull();
264+
expect(await ownerOf(fileId)).toEqual({ status: 'pending', ref: UNCLAIMED });
265+
});
266+
267+
it('a batch insert with one row naming a pending file is refused whole', async () => {
268+
const fileId = await pendingUpload();
269+
const clean = `m_${++seq}`;
270+
const named = `m_${++seq}`;
271+
272+
const failure = await failureOf(engine.insert('media', [
273+
{ id: clean, name: clean },
274+
{ id: named, name: named, poster: fileId },
275+
] as any, { context: WRITER_CTX }));
276+
277+
expect(failure).toEqual(refusedBody('poster', 'Poster', [fileId]));
278+
expect(await record(clean)).toBeUndefined();
279+
expect(await record(named)).toBeUndefined();
280+
expect(await ownerOf(fileId)).toEqual({ status: 'pending', ref: UNCLAIMED });
281+
});
282+
283+
it('a multiple field names only its pending files, and its committed file is not claimed either', async () => {
284+
const committed = await pendingUpload();
285+
expect((await complete(committed))._status).toBe(200);
286+
const pendingA = await pendingUpload();
287+
const pendingB = await pendingUpload();
288+
const id = `m_${++seq}`;
289+
290+
const failure = await failureOf(engine.insert(
291+
'media', { id, name: id, gallery: [committed, pendingA, pendingB] } as any, { context: WRITER_CTX },
292+
));
293+
294+
expect(failure).toEqual(refusedBody('gallery', 'Gallery', [pendingA, pendingB]));
295+
expect(await ownerOf(committed)).toEqual({ status: 'committed', ref: UNCLAIMED });
296+
expect(await ownerOf(pendingA)).toEqual({ status: 'pending', ref: UNCLAIMED });
297+
});
298+
299+
it('a system write is not judged: the seed loader writes over a pending row it commits after', async () => {
300+
const fileId = await pendingUpload();
301+
const id = `m_${++seq}`;
302+
303+
await engine.insert('media', { id, name: id, poster: fileId } as any, { context: SYS });
304+
305+
expect((await record(id))?.poster).toBe(fileId);
306+
expect(await ownerOf(fileId)).toEqual({ status: 'pending', ref: ['media', id, 'poster'] });
307+
});
308+
309+
it('a record already holding a pending file can still be edited, resending the value it holds', async () => {
310+
// The state a write left before this refusal existed.
311+
const fileId = await pendingUpload();
312+
const id = `m_${++seq}`;
313+
await engine.insert('media', { id, name: id, poster: fileId } as any, { context: SYS });
314+
315+
await engine.update('media', { id, name: 'renamed', poster: fileId } as any, { context: WRITER_CTX });
316+
317+
expect(await record(id)).toMatchObject({ name: 'renamed', poster: fileId });
318+
expect(await ownerOf(fileId)).toEqual({ status: 'pending', ref: ['media', id, 'poster'] });
319+
});
320+
});

0 commit comments

Comments
 (0)