Skip to content

Commit 7e1089e

Browse files
fix(service-storage): sys_file's file-reference columns are lifecycle-owned (#28454)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6Jao4hCgN6Y7K2BDrS18EpHqTB)_ Fixes #27767 Fixes #27778 Clause-②: no (narrowing) Metadata-protocol: none Skills-docs: none — no skill or docs page teaches writing these columns; they are the storage service's own bookkeeping Ruling-ref: 6545931776 (letter A) ## 维护者速读 - 改了什么:`sys_file` 上记录"文件归哪条记录、哪个字段"的五列(`ref_object`/`ref_id`/`ref_field`/`released_ref_object`/`released_ref_id`)改由文件引用生命周期独占:对象上设为 `readonly`,并把 #27765 的建档侧守卫扩到这五列;另按 #27778 在对象头部写明 `key`/`status` 的存在由存储写入方保证,并收掉一个无人引用的导出。 - 为什么改:按裁决 A(6545931776),这五列与 #27765 的五个存储列同属生命周期所有,只能由系统上下文维护。 - 风险与回滚:非系统写入这五列时值被丢弃、其余字段照常落库、不报错(与 #27765 同形);系统上下文的认领、释放、复制不受影响。回滚即还原本 PR。 - 先看:`packages/services/service-storage/src/sys-file-storage-owned-columns.ts`(守卫列表)、`src/objects/system-file.object.ts`(五列 `readonly` 与新常量)、`src/sys-file-storage-owned-columns.integration.test.ts`(新钉)。 - 两分钟验证:`pnpm --filter @objectstack/service-storage exec vitest run src/sys-file-storage-owned-columns.integration.test.ts`,预期 24/24 通过。 - 需要你做的:无,席位落地。 ## What changes - `system-file.object.ts`: a new `SYS_FILE_REFERENCE_COLUMNS` list beside `SYS_FILE_STORAGE_OWNED_COLUMNS`, and `readonly: true` on the five file-reference fields. The engine's static readonly strip now takes a non-system caller's value for them off an update, by id and by predicate, and lands the rest of the write. - `sys-file-storage-owned-columns.ts`: the #27765 create-side guard now drops the storage-owned columns and the file-reference columns from a non-system create (one list, `LIFECYCLE_OWNED_COLUMNS`, built from the two). The row is still written, still starts `pending`, and the write is not refused; one `warn` names the dropped columns. No new refusal code, no spec key, no governed path, no wiring change. - **Choice of form:** a second constant beside the first, not one widened list. The two lists have different owners (the storage backend's columns vs. the file-reference lifecycle's) and each has its own pin that declares exactly its five; the guard reads both. One guard, one hook, one log line. - #27778 item 1: `system-file.object.ts` now states that `key` and `status` stay declared `required`, that the engine checks a `readonly` field for shape only on insert, and that their presence is the storage writers' invariant (presign/chunked start `pending` with a `key`, complete commits, the field copy writes a fresh `key` and `committed`). The declaration stays as it is (triage 6511387772). - #27778 item 2: the helper the guard calls is no longer exported (nothing imported it); it is renamed `dropLifecycleOwnedColumns` since it now covers both lists. - `.changeset/27767-sys-file-reference-columns.md` (patch, `@objectstack/service-storage`). ## Tests New pins in `sys-file-storage-owned-columns.integration.test.ts` (the #27765 rig: real ObjectQL over sqlite, `StorageServicePlugin` on the local adapter, data writes as a signed-in caller with no system context): 1. declares exactly the five file-reference columns, each `readonly`; 2. an update by id cannot change any of the five (one case per column), and the rest of the write lands; 3. a predicate update cannot change any of them; 4. a create cannot set any of them, and the row is written; 5. a batch create cannot set them on any row; 6. the lifecycle's claim, copy and release still write them; 7. a system create and a system update keep the values they write; 8. #27778: every storage writer sets `key` and `status` (presign, complete, field copy). <details><summary>Pins on origin/main 5517005 (premise): 9 red / 15 green</summary> Measured with only the list exported (no `readonly`, no guard change): pins 1–5 red (9 cases), pins 6–8 and every pre-existing case green. Pins 1–5 green after the change. The mechanism of the reading is kept off GitHub under the security dispatch rule. </details> <details><summary>Ablations (one per pin, via scripts/ablation-replace.mjs; each restored to blob == HEAD, git diff HEAD empty)</summary> - A1 `readonly` removed from `ref_id` ⇒ 3 red: pin 1, pin 2 (`ref_id`), pin 3. - A2 guard list without the reference columns ⇒ 2 red: pins 4 and 5. - A3 the lifecycle's claim update written without the system context ⇒ 8 red, including pin 6 (and pin 8, pin 2 ×5, the #27765 claim case, which all start from a claim). - A4 the guard's system early return deleted ⇒ 17 red, including pin 7. (A first attempt with an empty `--replacement` was refused by the tool before running; re-run with `--delete`.) - A5 the field copy's `key` written null ⇒ 1 red: pin 8. </details> ## Acceptance notes - `content/docs/permissions/system-context.mdx` row 47g describes the guard's system early return in terms of the five storage-owned columns. It stays true (a system insert still keeps them) but does not name the reference columns; left untouched, outside this card's file surface. - `storage-service-plugin.ts`'s wiring comment names the guard by the storage-owned columns only; no wiring change, left as it is. - The pending #27399 changeset says every other `sys_file` column ("`name`, `acl`, `scope`, ownership") is written through the data door as before. If "ownership" is read as the reference columns, this PR's changeset, in the same release, supersedes it. It is left unedited: `check-empty-changeset` treats a rewrite of another PR's pending note as a release decision for a person, so the seat decides whether that one word changes. ## Verification Head `7fc7033d` (`2d5586fc` plus the revert of a changeset edit; no source change between them). - Targeted pins: `pnpm --filter @objectstack/service-storage exec vitest run --maxWorkers=2 src/sys-file-storage-owned-columns.integration.test.ts` ⇒ 24/24. - Package: `pnpm --filter @objectstack/service-storage exec vitest run --maxWorkers=2` ⇒ Test Files 71/71, Tests 1326/1326; `pnpm --filter @objectstack/service-storage typecheck` ⇒ exit 0 (dts build of the closure). - Lint: `pnpm exec eslint --no-inline-config` on the three touched source files ⇒ exit 0. - Dogfood (owed by the narrowing arm), on `7fc7033d`: `pnpm --filter @objectstack/dogfood test` under the verify lock ⇒ VERDICT command-exit 0, Test Files 361 passed / 3 skipped (364), Tests 2917 passed / 28 skipped (2945). <details><summary>Derived gates: 62 derived, 62 run, 0 NOT-MEASURED (dispatch-gates --ran reconciled, exit 0)</summary> Every `dispatch-gates --commands` line run with stdin closed, exit codes recorded before any pipe. 58 exit 0 on the first pass. Four were re-run after their prerequisite and then exit 0: `check-plugin-teardown-shape --self-test` (exit 3 until the pinned control commit was fetched), `check:dual-build-cjs-loads` and `check:i18n` (exit 3 until a full dts build), and `check-empty-changeset` (exit 1 on an edit of the pending #27399 note, which was reverted; see Acceptance notes). The derived list is identical for both heads. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01L9qibwQbgGE61XZrK6xRjM)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 462935d commit 7e1089e

4 files changed

Lines changed: 211 additions & 9 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/service-storage': patch
3+
---
4+
5+
fix(service-storage): `sys_file`'s file-reference columns (`ref_object`, `ref_id`, `ref_field`, `released_ref_object`, `released_ref_id`) are owned by the file-reference lifecycle and can no longer be set or changed by a write that does not come from it
6+
7+
Clause-②: no (narrowing)
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) A data-door write to five lifecycle-owned sys_file columns is no longer applied: no key of any metadata schema is removed, renamed or re-shaped, no export, type or error code is removed or renamed, and no write is refused (an update keeps the stored values, a create starts with none of them). There is nothing for `objectstack migrate meta` to rewrite and no tombstone. -->
11+
12+
The five columns that record which record and field hold a file, and which record last let it go, take the same form as the five storage-owned columns:
13+
14+
- **Update.** They are now `readonly` on `sys_file`. A non-system update, by id or by predicate, keeps the values the lifecycle wrote and lands the rest of the payload; the write answers as before and the engine logs the dropped column at `warn`, as it does for every read-only column. A caller that passes `strictReadonlyWrites` is refused with `ERR_READONLY_FIELD_REJECTED`, as for every read-only column.
15+
- **Create.** The create-side guard of the storage-owned columns now drops these five too: a non-system create of a `sys_file` row keeps the caller's other columns, drops any value it gives them, starts `pending` as before, and logs one `warn` naming the dropped columns. The create is not refused.
16+
- **Unchanged.** The file-reference lifecycle writes them under the system context, as before: a file field's claim, release and copy, and the operator backfill of file references.
17+
18+
A file is attached to a record by writing its id into the record's file field, never by writing `sys_file` directly.
19+
20+
`system-file.object.ts` now also states that the presence of `key` and `status` on a `sys_file` row is the storage writers' invariant, not the engine's (the engine checks a `readonly` field for shape only on insert), and the guard's unused helper export is no longer exported.

‎packages/services/service-storage/src/objects/system-file.object.ts‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,41 @@ const UPLOAD_SCOPE_LABELS: Record<UploadScope, string> = {
4242
* under the system context, move these columns: the upload doors through
4343
* `StorageMetadataStore`, the claim, release and copy of a file field, and the
4444
* attachment tombstone.
45+
*
46+
* `key` and `status` are also declared `required`, but the engine does not
47+
* enforce that on an INSERT: its record validator checks a `readonly` field
48+
* for shape only. Their presence is the storage writers' invariant, not the
49+
* engine's: every writer above sets both (a presigned or chunked upload starts
50+
* `pending` with its `key`, `/upload/complete` commits it, and a field copy
51+
* writes a fresh `key` and `committed`). A non-system create, the one write
52+
* that does not come from those writers, has its `key` dropped and starts
53+
* `pending`, so no file field can claim it. The declaration stays as it is.
4554
*/
4655
export const SYS_FILE_STORAGE_OWNED_COLUMNS = ['key', 'mime_type', 'size', 'status', 'etag'] as const;
4756

57+
/**
58+
* The `sys_file` columns the file-reference lifecycle owns: which record and
59+
* field hold the file (`ref_object`, `ref_id`, `ref_field`) and which record
60+
* last let it go (`released_ref_object`, `released_ref_id`), ADR-0104 D3
61+
* wave 2.
62+
*
63+
* They take the same form as {@link SYS_FILE_STORAGE_OWNED_COLUMNS}: each is
64+
* `readonly` below, so the engine takes a non-system caller's value back out
65+
* of an UPDATE, and the same create-side guard
66+
* (`installSysFileStorageOwnedColumnsGuard`) drops them from a non-system
67+
* INSERT. Neither refuses the write. Only the lifecycle moves them, under the
68+
* system context: a file field's claim, release and copy
69+
* (`file-reference-lifecycle.ts`, `field-file-service.ts`) and the operator
70+
* backfill of file references.
71+
*/
72+
export const SYS_FILE_REFERENCE_COLUMNS = [
73+
'ref_object',
74+
'ref_id',
75+
'ref_field',
76+
'released_ref_object',
77+
'released_ref_id',
78+
] as const;
79+
4880
/**
4981
* System File Object
5082
*
@@ -170,25 +202,31 @@ export const SystemFile = ObjectSchema.create({
170202
//
171203
// NULL on: freshly uploaded but not yet claimed by a record write, and on
172204
// every attachments-surface file (those are governed by sys_attachment).
205+
//
206+
// Lifecycle-owned (SYS_FILE_REFERENCE_COLUMNS), as are the two released_*
207+
// columns below.
173208
ref_object: Field.text({
174209
label: 'Referencing Object',
175210
maxLength: 128,
176211
description: 'Short object name of the record whose field owns this file',
177212
group: 'Field Reference',
213+
readonly: true,
178214
}),
179215

180216
ref_id: Field.text({
181217
label: 'Referencing Record',
182218
maxLength: 64,
183219
description: 'Primary key of the record whose field owns this file',
184220
group: 'Field Reference',
221+
readonly: true,
185222
}),
186223

187224
ref_field: Field.text({
188225
label: 'Referencing Field',
189226
maxLength: 128,
190227
description: 'Field name on the owning record that holds this file id',
191228
group: 'Field Reference',
229+
readonly: true,
192230
}),
193231

194232
// [#26046] The record whose field last owned this file, kept when that
@@ -203,13 +241,15 @@ export const SystemFile = ObjectSchema.create({
203241
maxLength: 128,
204242
description: 'Short object name of the record that last owned this file, after it let the file go',
205243
group: 'Field Reference',
244+
readonly: true,
206245
}),
207246

208247
released_ref_id: Field.text({
209248
label: 'Released From Record',
210249
maxLength: 64,
211250
description: 'Primary key of the record that last owned this file, after it let the file go',
212251
group: 'Field Reference',
252+
readonly: true,
213253
}),
214254

215255
metadata: Field.text({

‎packages/services/service-storage/src/sys-file-storage-owned-columns.integration.test.ts‎

Lines changed: 133 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@
1818
* `pending`. Neither refuses the write. The service's own writes, all under
1919
* the system context, still move every column: presign, complete, claim.
2020
*
21+
* The file-reference columns (`ref_object`, `ref_id`, `ref_field`,
22+
* `released_ref_object`, `released_ref_id`) take the same form: the
23+
* file-reference lifecycle owns them, so they are `readonly` and the same
24+
* create-side guard drops them from a non-system create, while the lifecycle's
25+
* claim, release and copy, all under the system context, still write them.
26+
*
2127
* Driven through the doors as they ship, as the #26479 rig is: one real
2228
* `ObjectQL` engine over sqlite `:memory:`, `StorageServicePlugin` booted on
2329
* the local adapter, the upload doors called as routes, and the data writes
@@ -34,7 +40,11 @@ import { ObjectQL } from '@objectstack/objectql';
3440
import { SqlDriver } from '@objectstack/driver-sql';
3541
import { resolveThrownHttpError } from '@objectstack/types';
3642
import { StorageServicePlugin } from './storage-service-plugin.js';
37-
import { SystemFile, SYS_FILE_STORAGE_OWNED_COLUMNS } from './objects/system-file.object.js';
43+
import {
44+
SystemFile,
45+
SYS_FILE_REFERENCE_COLUMNS,
46+
SYS_FILE_STORAGE_OWNED_COLUMNS,
47+
} from './objects/system-file.object.js';
3848
import { installDefaultOrganization } from './default-organization.testkit.js';
3949

4050
const BASE = '/api/v1/storage';
@@ -303,4 +313,126 @@ describe('sys_file storage-owned columns are not writable outside the storage se
303313

304314
expect(await owned(fileId)).toEqual({ ...FORGED });
305315
});
316+
317+
// ── The file-reference columns ────────────────────────────────────
318+
319+
const references = async (id: string) => {
320+
const row = await fileRow(id);
321+
return Object.fromEntries(SYS_FILE_REFERENCE_COLUMNS.map((c) => [c, row?.[c] ?? null]));
322+
};
323+
const UNREFERENCED = { ref_object: null, ref_id: null, ref_field: null, released_ref_object: null, released_ref_id: null };
324+
/** A value for every reference column that differs from any the lifecycle writes in this file. */
325+
const NAMED = {
326+
ref_object: 'named_object',
327+
ref_id: 'r_named',
328+
ref_field: 'named_field',
329+
released_ref_object: 'named_released_object',
330+
released_ref_id: 'r_named_released',
331+
} as const;
332+
333+
/** A committed upload claimed by a fresh `media` record's `poster`: the lifecycle's own claim. */
334+
const claimedUpload = async () => {
335+
const fileId = await committedUpload();
336+
const recordId = `m_${++seq}`;
337+
await engine.insert('media', { id: recordId, name: 'owner', poster: fileId } as any, { context: WRITER_CTX });
338+
return { fileId, recordId };
339+
};
340+
341+
it('declares exactly the five file-reference columns, each readonly on sys_file', () => {
342+
expect([...SYS_FILE_REFERENCE_COLUMNS].sort()).toEqual([
343+
'ref_field', 'ref_id', 'ref_object', 'released_ref_id', 'released_ref_object',
344+
]);
345+
for (const column of SYS_FILE_REFERENCE_COLUMNS) {
346+
expect((SystemFile.fields as Record<string, { readonly?: boolean }>)[column]?.readonly, column).toBe(true);
347+
}
348+
});
349+
350+
it.each(SYS_FILE_REFERENCE_COLUMNS)('an update by id cannot change %s, and the rest of the write lands', async (column) => {
351+
const { fileId } = await claimedUpload();
352+
const before = await references(fileId);
353+
expect(before.ref_object).toBe('media');
354+
355+
await engine.update('sys_file', { id: fileId, name: 'renamed.png', [column]: NAMED[column] } as any, { context: WRITER_CTX });
356+
357+
expect(await references(fileId)).toEqual(before);
358+
expect((await fileRow(fileId))?.name).toBe('renamed.png');
359+
});
360+
361+
it('a predicate update cannot change any of them either', async () => {
362+
const fileId = await committedUpload();
363+
364+
await engine.update('sys_file', { ...NAMED, acl: 'private' } as any, { where: { id: fileId }, context: WRITER_CTX } as any);
365+
366+
expect(await references(fileId)).toEqual(UNREFERENCED);
367+
expect((await fileRow(fileId))?.acl).toBe('private');
368+
});
369+
370+
it('a create cannot set any of them: the row is written with none of the caller\'s values', async () => {
371+
const fileId = `f_named_${++seq}`;
372+
373+
await engine.insert('sys_file', { id: fileId, name: 'named.png', owner_id: WRITER, ...NAMED } as any, { context: WRITER_CTX });
374+
375+
expect(await references(fileId)).toEqual(UNREFERENCED);
376+
expect((await fileRow(fileId))?.name).toBe('named.png');
377+
});
378+
379+
it('a batch create cannot set them on any row', async () => {
380+
const a = `f_named_${++seq}`;
381+
const b = `f_named_${++seq}`;
382+
383+
await engine.insert('sys_file', [
384+
{ id: a, name: 'a.png', ...NAMED },
385+
{ id: b, name: 'b.png', ref_object: 'media', ref_id: 'm_other', ref_field: 'poster' },
386+
] as any, { context: WRITER_CTX });
387+
388+
expect(await references(a)).toEqual(UNREFERENCED);
389+
expect(await references(b)).toEqual(UNREFERENCED);
390+
});
391+
392+
it('the lifecycle\'s claim, release and copy still write them', async () => {
393+
const { fileId, recordId } = await claimedUpload();
394+
expect(await references(fileId)).toEqual({ ...UNREFERENCED, ref_object: 'media', ref_id: recordId, ref_field: 'poster' });
395+
396+
// Writing the owned id into a second slot copies the file into a fresh row the second slot owns.
397+
const second = `m_${++seq}`;
398+
await engine.insert('media', { id: second, name: 'second', poster: fileId } as any, { context: WRITER_CTX });
399+
const copyId = (await record(second))?.poster as string;
400+
expect(copyId).not.toBe(fileId);
401+
expect(await references(copyId)).toEqual({ ...UNREFERENCED, ref_object: 'media', ref_id: second, ref_field: 'poster' });
402+
403+
// Clearing the field releases the file and keeps the record that let it go.
404+
await engine.update('media', { id: recordId, poster: null } as any, { context: WRITER_CTX });
405+
expect(await references(fileId)).toEqual({ ...UNREFERENCED, released_ref_object: 'media', released_ref_id: recordId });
406+
});
407+
408+
it('a system create and a system update keep the values they write', async () => {
409+
const fileId = `f_system_${++seq}`;
410+
411+
await engine.insert('sys_file', { id: fileId, name: 'system.png', ...NAMED } as any, { context: SYS });
412+
expect(await references(fileId)).toEqual({ ...NAMED });
413+
414+
await engine.update('sys_file', { id: fileId, ...UNREFERENCED, ref_object: 'media', ref_id: 'm_system' } as any, { context: SYS });
415+
expect(await references(fileId)).toEqual({ ...UNREFERENCED, ref_object: 'media', ref_id: 'm_system' });
416+
});
417+
418+
// ── The presence of `key` and `status` (#27778) ───────────────────
419+
420+
it('every storage writer sets key and status: presign, complete and the field copy', async () => {
421+
const { fileId: presigned } = await presign();
422+
const pendingRow = await owned(presigned);
423+
expect(pendingRow.status).toBe('pending');
424+
expect(typeof pendingRow.key === 'string' && pendingRow.key.length > 0, JSON.stringify(pendingRow)).toBe(true);
425+
426+
const { fileId } = await claimedUpload();
427+
const committedRow = await owned(fileId);
428+
expect(committedRow.status).toBe('committed');
429+
expect(typeof committedRow.key === 'string' && committedRow.key.length > 0, JSON.stringify(committedRow)).toBe(true);
430+
431+
const second = `m_${++seq}`;
432+
await engine.insert('media', { id: second, name: 'copy', poster: fileId } as any, { context: WRITER_CTX });
433+
const copyRow = await owned((await record(second))?.poster as string);
434+
expect(copyRow.status).toBe('committed');
435+
expect(typeof copyRow.key === 'string' && copyRow.key.length > 0, JSON.stringify(copyRow)).toBe(true);
436+
expect(copyRow.key).not.toBe(committedRow.key);
437+
});
306438
});

‎packages/services/service-storage/src/sys-file-storage-owned-columns.ts‎

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

3-
import { SYS_FILE_STORAGE_OWNED_COLUMNS } from './objects/system-file.object.js';
3+
import { SYS_FILE_REFERENCE_COLUMNS, SYS_FILE_STORAGE_OWNED_COLUMNS } from './objects/system-file.object.js';
44

55
/**
66
* The create-side half of "the storage service owns `sys_file`'s lifecycle
7-
* columns" ({@link SYS_FILE_STORAGE_OWNED_COLUMNS}).
7+
* columns": the storage-owned ones ({@link SYS_FILE_STORAGE_OWNED_COLUMNS})
8+
* and the file-reference ones ({@link SYS_FILE_REFERENCE_COLUMNS}).
89
*
910
* The update side needs no code here: the columns are `readonly`, and the
1011
* engine's static readonly strip takes a non-system caller's value off an
@@ -24,6 +25,11 @@ import { SYS_FILE_STORAGE_OWNED_COLUMNS } from './objects/system-file.object.js'
2425
* hook does not judge: the upload doors (through `StorageMetadataStore`), the
2526
* field-file copy and backfill, and the attachment tombstone.
2627
*
28+
* The file-reference columns are owned the same way, by the file-reference
29+
* lifecycle, which alone records which record and field hold a file,
30+
* through its claim, release and copy, all under the system context. A
31+
* non-system create starts with none of them, as a fresh upload does.
32+
*
2733
* Judged as the update strip judges: every write whose context is not
2834
* `isSystem`, a context-less one included.
2935
*/
@@ -44,13 +50,16 @@ const PACKAGE_ID = 'com.objectstack.service.storage';
4450
/** The state a non-system create starts in: an upload the service has not committed. */
4551
const NON_SYSTEM_CREATE_STATUS = 'pending';
4652

53+
/** Every column a non-system create may not set: the storage-owned ones, then the file-reference ones. */
54+
const LIFECYCLE_OWNED_COLUMNS: readonly string[] = [...SYS_FILE_STORAGE_OWNED_COLUMNS, ...SYS_FILE_REFERENCE_COLUMNS];
55+
4756
/**
48-
* Drop the storage-owned columns a non-system create supplied, and start the
49-
* row `pending`. Returns the names it dropped. Exported for the guard's pin.
57+
* Drop the lifecycle-owned columns a non-system create supplied, and start the
58+
* row `pending`. Returns the names it dropped.
5059
*/
51-
export function dropStorageOwnedColumns(data: Record<string, unknown>): string[] {
60+
function dropLifecycleOwnedColumns(data: Record<string, unknown>): string[] {
5261
const dropped: string[] = [];
53-
for (const column of SYS_FILE_STORAGE_OWNED_COLUMNS) {
62+
for (const column of LIFECYCLE_OWNED_COLUMNS) {
5463
if (!Object.prototype.hasOwnProperty.call(data, column)) continue;
5564
delete data[column];
5665
dropped.push(column);
@@ -70,11 +79,12 @@ export function installSysFileStorageOwnedColumnsGuard(
7079
if (ctx?.session?.isSystem) return;
7180
const data = ctx?.input?.data;
7281
if (!data || typeof data !== 'object' || Array.isArray(data)) return;
73-
const dropped = dropStorageOwnedColumns(data as Record<string, unknown>);
82+
const dropped = dropLifecycleOwnedColumns(data as Record<string, unknown>);
7483
if (dropped.length === 0) return;
7584
logger.warn(
7685
`[storage] sys_file: dropped ${dropped.join(', ')} from a create that did not come from the storage service. ` +
77-
'These columns record what the storage backend holds, and only the storage service writes them; ' +
86+
'These columns record what the storage backend holds and which record owns the file, ' +
87+
'and only the storage service writes them; ' +
7888
`the row was created ${NON_SYSTEM_CREATE_STATUS}. Upload files through the storage upload doors instead.`,
7989
{ object: 'sys_file', operation: 'insert', fields: dropped, userId: ctx?.session?.userId ?? 'unknown' },
8090
);

0 commit comments

Comments
 (0)