Skip to content

Commit 874144e

Browse files
committed
fix(approvals): correct the attachment read-shape rationale and share the id rule
Follow-up on this PR's own change, after ADR-0104 D3 wave 2 landed on main. The fix in this PR is right; its stated cause was inverted. It says the `sys_approval_action.attachments` column "stores rich descriptors — a fileId is resolved to a full descriptor on write". The code says otherwise: approval-service.ts writes `input.attachments` verbatim (a fileId string[]), and there is no write-side descriptor resolution anywhere in the repo. What actually happens is that the column stores an opaque sys_file id — the stored form of every media field — and the ObjectQL read path expands it into `{ id, name, size, mimeType, url }` on the way out. That resolver was already in this PR's base commit, so the descriptors observed in listActions came from the read path, not from storage. Left as written, that inverted account would have been a false statement about storage sitting in the protocol contract, in the changeset, and in a regression test comment — and PR-5a has since made the stored form explicitly an id, so it would also have been contradicted by the schema. Corrected in all three places. Also names the three read forms the normalizer actually handles, rather than one: the expanded value (normal), a bare id (nothing to expand it into), and a legacy inline blob written before the cutover, whose keys are snake_case (`file_id`, `mime_type`). The last of those had no test; it has one now. The id-token test reuses `isFileIdToken` from @objectstack/spec/data — the platform's single arbiter of "opaque id or URL?" — so this normalizer and the engine's read resolver cannot drift apart on that question, and a non-id string is now surfaced as a url rather than mislabelled an id. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SHpGw3GBA9aFpfwVArRWfd
1 parent 3b14f45 commit 874144e

4 files changed

Lines changed: 81 additions & 29 deletions

File tree

.changeset/approval-attachment-descriptors.md

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,27 @@
33
"@objectstack/plugin-approvals": patch
44
---
55

6-
fix(approvals): return decision attachments as file descriptors, not "[object Object]" (#3504)
6+
fix(approvals): return decision attachments as file values, not "[object Object]" (#3504)
77

8-
The `sys_approval_action.attachments` file field stores rich descriptors
9-
(`{ id, name, url, mimeType, size }`), not bare fileId strings — a fileId passed
10-
to the decision is resolved to a full descriptor on write. But `rowFromAction`
11-
mapped the column with `.map(String)`, collapsing each descriptor object to the
8+
`sys_approval_action.attachments` is a `Field.file`, so the column **stores an
9+
opaque `sys_file` id** (ADR-0104 D3 — the stored form of every media field). The
10+
ObjectQL read path resolves that id into its expanded
11+
`{ id, name, size, mimeType, url }` form on the way out. But `rowFromAction`
12+
mapped the column with `.map(String)`, collapsing each expanded value to the
1213
literal string `"[object Object]"`. Every `listActions` consumer (the approval
1314
inbox timeline) then received garbage: the attachment chip had no filename and
1415
its id was `"[object Object]"`, so opening it 404'd.
1516

1617
- `ApprovalActionRow.attachments` is now `ApprovalActionAttachment[]` — the
17-
descriptor carries `id` + display `name` + a download `url`, so a consumer can
18-
label and open an attachment without needing read access to the system
19-
`sys_file` object (which regular approvers do not have). A bare-string fileId
20-
still normalizes to `{ id }` for safety.
18+
expanded file value plus its id, so a consumer can label and open an
19+
attachment without needing read access to the system `sys_file` object (which
20+
regular approvers do not have).
21+
- Three read forms are accepted: the expanded value (the normal case), a bare id
22+
(nothing to expand it into — storage service absent, file not committed), and
23+
a legacy inline blob written before file-as-reference (`file_id` /
24+
`mime_type`), until the backfill converts it. The id test reuses the
25+
platform's `isFileIdToken`, so this and the engine's read resolver cannot
26+
disagree about what counts as an id.
2127
- The decision *input* (`ApprovalDecisionInput.attachments`) is unchanged — it
22-
still takes fileId strings. Only the read shape changed.
28+
still takes fileId strings, which is also exactly what the column stores. Only
29+
the read shape changed.

packages/plugins/plugin-approvals/src/approval-service.test.ts

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1959,17 +1959,18 @@ describe('ApprovalService — listActions attachments mapping (#3266)', () => {
19591959
expect(approve?.attachments).toEqual([{ id: 'file_a' }]);
19601960
});
19611961

1962-
// The real column value: `attachments` is a `Field.file` (multiple), which
1963-
// stores rich descriptors — NOT fileId strings. The old `.map(String)` turned
1964-
// each into "[object Object]", so the inbox chip had no name and 404'd on open.
1965-
// The mapping must pass the descriptor (id + name + url) through unmangled.
1966-
it('passes a stored file descriptor object through with its name and url', async () => {
1962+
// The normal case. The column STORES an opaque sys_file id (ADR-0104 D3);
1963+
// the ObjectQL read path resolves it into the expanded
1964+
// `{ id, name, size, mimeType, url }` form on the way out. The old
1965+
// `.map(String)` turned that object into "[object Object]", so the inbox chip
1966+
// had no name and 404'd on open. The mapping must pass it through unmangled.
1967+
it('passes the engine-expanded file value through with its name and url', async () => {
19671968
const engine = makeFakeEngine();
19681969
let n = 0;
19691970
const svc = new ApprovalService({ engine: engine as any, clock: { now: () => new Date(1757000000000 + (n++) * 1000) } });
19701971
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
19711972
await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9' }, SYS);
1972-
// Simulate the Field.file column's real shape (what the engine returns).
1973+
// Simulate what the read path hands back after resolving the stored id.
19731974
const row = engine._tables['sys_approval_action'].find((a: any) => a.action === 'approve');
19741975
row.attachments = [
19751976
{ id: 'file_a', name: 'signed-contract.pdf', mimeType: 'application/pdf', size: 24, url: '/api/v1/storage/files/file_a' },
@@ -1980,4 +1981,24 @@ describe('ApprovalService — listActions attachments mapping (#3266)', () => {
19801981
{ id: 'file_a', name: 'signed-contract.pdf', mimeType: 'application/pdf', size: 24, url: '/api/v1/storage/files/file_a' },
19811982
]);
19821983
});
1984+
1985+
// Rows written before file-as-reference hold an inline blob whose keys are
1986+
// snake_case (`file_id`, `mime_type`). They stay readable until the backfill
1987+
// converts them, so both casings must map — the same drift that made objectui
1988+
// stop recognising images when the expanded form arrived.
1989+
it('maps a legacy inline blob (file_id / mime_type) written before the cutover', async () => {
1990+
const engine = makeFakeEngine();
1991+
let n = 0;
1992+
const svc = new ApprovalService({ engine: engine as any, clock: { now: () => new Date(1757000000000 + (n++) * 1000) } });
1993+
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
1994+
await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9' }, SYS);
1995+
const row = engine._tables['sys_approval_action'].find((a: any) => a.action === 'approve');
1996+
row.attachments = [
1997+
{ file_id: 'file_b', name: 'old.pdf', mime_type: 'application/pdf', size: 12, url: 'https://cdn/old.pdf' },
1998+
];
1999+
const acts = await svc.listActions(req.id, SYS);
2000+
expect(acts.find(a => a.action === 'approve')?.attachments).toEqual([
2001+
{ id: 'file_b', name: 'old.pdf', mimeType: 'application/pdf', size: 12, url: 'https://cdn/old.pdf' },
2002+
]);
2003+
});
19832004
});

packages/plugins/plugin-approvals/src/approval-service.ts

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import type {
3131
ApprovalStatus,
3232
SharingExecutionContext,
3333
} from '@objectstack/spec/contracts';
34+
import { isFileIdToken } from '@objectstack/spec/data';
3435
import { isGrantActive } from '@objectstack/core';
3536

3637
/**
@@ -259,22 +260,38 @@ function slaDueAt(createdAt: unknown, cfg: any): string | undefined {
259260
/**
260261
* Normalize one raw `attachments` entry into an {@link ApprovalActionAttachment}.
261262
*
262-
* The `sys_approval_action.attachments` file field stores **rich descriptors**
263-
* (`{ id, name, url, mimeType, size }`), not bare fileId strings — even when the
264-
* decision was recorded with a fileId, the field resolves it on write. The
265-
* original mapping did `String(entry)`, which turned each descriptor object into
266-
* the literal `"[object Object]"` — so the inbox timeline showed a nameless,
263+
* `sys_approval_action.attachments` is a `Field.file` (multiple), so the column
264+
* **stores opaque `sys_file` ids** — that is the stored form of every media
265+
* field (ADR-0104 D3). What arrives here is whichever of three forms the read
266+
* path produced:
267+
*
268+
* 1. the **expanded** `{ id, name, size, mimeType, url }` the ObjectQL read
269+
* path resolves a stored id into — the normal case;
270+
* 2. a **bare id**, when there was nothing to expand it into (storage service
271+
* absent, file not committed);
272+
* 3. a **legacy inline blob** (`{ file_id, name, mime_type, url, … }`) written
273+
* before file-as-reference, until the backfill converts it.
274+
*
275+
* The original mapping did `String(entry)`, which turned form 1 into the
276+
* literal `"[object Object]"` — so the inbox timeline showed a nameless,
267277
* un-openable attachment chip (#3266 follow-up; caught by browser verification).
268-
* We pass the descriptor through, tolerating a bare-string fileId for safety.
278+
*
279+
* Note the casing: the expanded form carries `mimeType`, the legacy blob
280+
* `mime_type`. Both are accepted for the duration of the migration window.
269281
*/
270282
function normalizeActionAttachment(entry: any): ApprovalActionAttachment | undefined {
271283
if (entry == null) return undefined;
284+
// Form 2 — a bare reference. `isFileIdToken` is the platform's single arbiter
285+
// of "is this string an opaque file id, or a URL?", shared with the engine's
286+
// read resolver, so the two cannot disagree about what counts as an id.
272287
if (typeof entry === 'string') {
273288
const id = entry.trim();
274-
return id ? { id } : undefined;
289+
if (!id) return undefined;
290+
return isFileIdToken(id) ? { id } : { id, url: id };
275291
}
276292
if (typeof entry === 'object') {
277-
const id = entry.id ?? entry.fileId ?? entry.file_id;
293+
// Forms 1 and 3 — `file_id` is the legacy blob's key for the same thing.
294+
const id = entry.id ?? entry.file_id;
278295
if (id == null || String(id) === '') return undefined;
279296
const mimeType = entry.mimeType ?? entry.mime_type;
280297
return {

packages/spec/src/contracts/approval-service.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -200,11 +200,18 @@ export type ApprovalActionKind =
200200
| 'ooo_substitute';
201201

202202
/**
203-
* A file attached to a decision action (#3266). Resolved from the
204-
* `sys_approval_action.attachments` file field, which stores rich descriptors
205-
* (not bare fileIds) — so the row carries the display name and a download URL
206-
* directly, and consumers never need read access to the system `sys_file`
207-
* object to label or open an attachment.
203+
* A file attached to a decision action (#3266) — the READ shape of one
204+
* `sys_approval_action.attachments` entry.
205+
*
206+
* The column **stores an opaque `sys_file` id** (ADR-0104 D3: that is the
207+
* stored form of every media field). The name, size, MIME type and URL are not
208+
* stored alongside it — the ObjectQL read path resolves the id into its
209+
* expanded `FileValueSchema` form on the way out, and this interface is that
210+
* form plus the id. So a consumer gets everything it needs to label and open an
211+
* attachment without read access to the system `sys_file` object, while the
212+
* write side stays a plain id (see `ApprovalDecisionInput.attachments`).
213+
*
214+
* Field names follow the expanded form — `mimeType`, not `mime_type`.
208215
*/
209216
export interface ApprovalActionAttachment {
210217
/** The `sys_file` id — pass to `GET /storage/files/:id/url` for a signed URL. */

0 commit comments

Comments
 (0)