Skip to content

Commit f782f17

Browse files
fix(storage): a refused attach tombstones the uploader's never-attached file, outside the refused write's unit of work (#22542)
Fixes #22466 Clause-②: no ## Summary When the attachment gate refuses a `sys_attachment` insert, the refused `file_id` now goes to the lifecycle. The lifecycle tombstones the caller's own never-attached upload with the same tombstone a last-join-row removal writes (`status: 'deleted'`, `deleted_at`). From there the file follows the existing path: the 30-day `ttl`, then the reap guard, which re-checks `findFileHolder` at sweep time. There is no new sweeper, no new lifecycle trigger, and no change to `sys_file`'s lifecycle declaration. The tombstone runs outside the refused write's unit of work, so a caller's transaction rolling back does not undo it. This is direction (a) as triage ruled it (comment `6080400758`). It covers every refusal leg of the gate on `main`, including the master-detail leg PR #22513 added (unlock `6087255379`). Files: `packages/services/service-storage/src/attachment-lifecycle.ts`, `packages/services/service-storage/src/attachment-access-hooks.ts`, a new pin file `packages/services/service-storage/src/attachment-refused-attach-tombstone.test.ts`, and a `patch` changeset. ## Before: the reproduction on `origin/main` 5910b5e A real `ObjectQL` engine over sqlite `:memory:`, with the storage lifecycle hooks and the attach gate installed as `StorageServicePlugin` installs them. The caller `u1` uploaded `f1`: committed, `attachments` scope, `owner_id` `u1`. | refusal leg | answer | `sys_file` after | join rows | transactions opened by the insert | | --- | --- | --- | --- | --- | | sharing `deny` | 403 `ATTACHMENT_PARENT_ACCESS` | `committed`, `deleted_at` null | 0 | 0 | | sharing non-verdict | 403 `ATTACHMENT_PARENT_ACCESS` | `committed`, `deleted_at` null | 0 | 0 | | master-detail check `deny` | 403 `ATTACHMENT_PARENT_ACCESS` | `committed`, `deleted_at` null | 0 | 0 | | master-detail check `unresolvable` | 403 `ATTACHMENT_PARENT_ACCESS` | `committed`, `deleted_at` null | 0 | 0 | Nothing later reaches the file. The tombstone hooks fire only when a file loses its last join row, and the declared lifecycle nominates only `deleted_at` (`ttl`) or `pending` (`retention`) rows. ## Where the refusal throws, and where the tombstone is written - **The refusal throws** from the gate's `beforeInsert` hook, inside `engine.insert`'s middleware chain. Every refusal leg funnels through one `mayEditParent` false, then one `forbid('ATTACHMENT_PARENT_ACCESS', …)`. That single site makes the one call to the new tombstoner, so no leg can drift. A rejection from either check (an outage, with its own `503`) never reaches that line, and it is not tombstoned. - **The transaction boundary, measured:** `engine.insert` opens no transaction of its own. A driver `beginTransaction` spy counted 0 calls during a refused insert. The generic `/data` create door (`createData` in the protocol, read from source) calls it without one. A system write made while the refusal unwinds lands and survives. Inside a caller-opened `engine.transaction` (an `atomic` batch, an explicit `transaction()`), the same write joins the caller's transaction through the engine's ambient store (ADR-0034) and was rolled back. So mechanism assumption 2 ("the refusal throws inside the insert's transaction") is half false: the insert has none, and only a caller's own unit of work can roll the write back. - **The tombstone is written** by a run detached from the refusal's async context. `createRefusedAttachTombstoner` is created when the gate is installed (`kernel:ready`, outside every engine operation) and captures `AsyncLocalStorage.snapshot()` there. Each refusal schedules its run inside that snapshot on a later event-loop turn. The engine's ambient transaction store holds nothing there, so the run reads and writes on its own connection after the refusal has left the operation. The refused write never awaits it. Awaiting an out-of-transaction query from inside an open single-connection transaction is the deadlock ADR-0034 describes. A detached one queues until the transaction releases the connection. - **Reuse:** the tombstone write and the "live attachments file" predicate are now one function each (`writeTombstone`, `isLiveAttachmentsFile`), shared by `tombstoneOrphanedFiles` (behaviour unchanged) and the refusal path. "Is anything still holding it" is `findFileHolder`, the one definition the reap guard, the download path and the inventory already use. ## The conditions, read after the refusal The file is tombstoned only when all of these hold: - the file is `attachments`-scope and `committed`; - nothing holds it (`findFileHolder`: zero join rows and no `ref_*` owner); - the refused caller is its uploader (`isFileUploader`, the upload ownership rule). Triage named the uploader `uploaded_by`. `sys_file` has no such column: its uploader is `owner_id`, which the upload doors stamp from the session. So that is the column the condition reads. The refusal does not wait on any of this, and the refusal envelope is byte-identical whether the file is the caller's, another user's, or unknown. The refusal discloses nothing about the file, not even through its timing (mechanism assumption 4). ## A client retry of the same `file_id` inside the 30-day window Measured on `main` before the change: a tombstoned file that is attached again is revived by the existing `afterInsert` leg (`committed`, `deleted_at` null). Pinned after the change: - **Admitted on retry** (for example, the grant changed): 201, the file is attached and revived to `committed`. - **Refused on retry:** the same refusal (code, status, message and object identical), and the file stays tombstoned with its first `deleted_at` untouched (run verdict `kept: not a committed attachments-scope file`). There is no silent half-state in either case. ## Pins and ablations The tombstone runs detached, so every pin waits on the one debug line each run ends on (its own verdict) rather than on a timer. | pin | asserts | | --- | --- | | one refusal per leg (5 cases) | sharing `deny`, a sharing non-verdict, master-detail `deny`, master-detail `unresolvable`, degraded mode (no sharing service, unreadable parent): 403 `ATTACHMENT_PARENT_ACCESS`, then `tombstoned`, `status: 'deleted'`, `deleted_at` set, 0 join rows | | the sweep reclaims it | the real `LifecycleService.sweep` over the real `SystemFile` schema (its declared `ttl`), clock at +31 days, real `createSysFileReapGuard`: row reaped, one byte delete of its key; an attached control file survives | | default door, no transaction | `beginTransaction` not called; the tombstone lands | | caller transaction rolls back | a caller write in the same unit of work is gone (the rollback is real); the tombstone survives it; no warn | | control: admitted attach | `committed`, 1 join row, no run scheduled | | control: `ref_*` lineage | `kept: still held (field-owner)`, `committed` | | control: field scope, not uploader, held elsewhere, pending, unknown id | each kept with its reason; nothing written | | retry | the two answers above | | refusal unchanged | identical envelope for own, another user's and unknown file; no file id in it; only the caller's own file moved | Each ablation ran through `scripts/ablation-replace.mjs` (WRAP mode, the anchor must hit, the mutation is proven on disk), and the restore was proven blob-equal to HEAD with an empty `git diff HEAD`: | ablation | expected | observed | | --- | --- | --- | | A1: drop the gate's call to the tombstoner | every pin that waits on a verdict goes red; the admitted control stays green | 16 failed, 1 passed (the admitted control) | | A2: drop the snapshot (run in the refusal's own context) | only the caller-transaction pin goes red | 1 failed: `no run verdict for sys_file f1; warn lines: […failed to tombstone sys_file f1 after a refused attach (The database refused to run this query for object 'sys_file' …)]`. The run inherited the caller's closed transaction. | | A3: drop the uploader condition | the not-uploader control and the no-leak pin go red | 2 failed | | A4: holder check by join rows only | the `ref_*` control goes red | 1 failed | ## Verification All on HEAD `0862db087` (the branch with `origin/main` `faf634850` merged), after rebuilding the `@objectstack/service-storage...` closure: - `pnpm --filter @objectstack/service-storage test`: 48 files, 821 tests passed. - `pnpm --filter @objectstack/service-storage typecheck`: exit 0, including `check:test-typecheck` (the new pin file compiles under `tsconfig.test.json`). - Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 67 commands. All 67 ran with exit 0, and `--ran` reconciled `67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN`, a zero derived from the recorded exit codes. Also run: `pnpm check:durability-log-level` and `pnpm check:startup-registry-verdict`, both exit 0. - An earlier run of the same families went red on `check:query-options-erasure`: the pin file had 3 `as any` engine-option erasures (test surface 236 → 239). They are typed now, and the gate holds at 236. - Wording round, HEAD `714081a66`: the changeset text only, with no source or test change. All 67 derived families re-ran. 65 exited 0, including every changeset gate (`check-adr-0087-registration`, `check-changeset-no-major`, `check-empty-changeset`, `check:changeset-gate-self-tests`, `check:pm-changeset-deadline-census`) and `check-issue-citations` (the new `#22547` reference resolves). `check:dual-build-cjs-loads` and `check:i18n` answered PREREQUISITE NOT MET (exit 3), so they are not measured on this head: the recreated worktree has only the `@objectstack/service-storage...` closure built. Both were green on `0862db087`, and neither reads a changeset. - Lint, a declared narrowing: `pnpm exec eslint --no-inline-config --format json` over the 3 changed TS files reported 3 files, 0 errors, 0 warnings. `eslint.config.mjs` sets no type-aware option (0 hits for `projectService` or `project:`), so this diff cannot move another file's lint verdict. The full `pnpm lint` is CI's. ## Acceptance notes - **Mechanism assumption 1** holds on the four gate legs measured on `main`, and the degraded leg is pinned after the change. The create-grant leg (plugin-security's object CRUD check) is different. It refuses inside plugin-security's data middleware before `next()`, so the storage gate never runs and the file stays `committed`. A model in the same rig showed this: an outer middleware refusing first, the storage gate asked 0 times, 0 run verdicts, the file `committed`. No service-storage seam inside this card's file surface sees that refusal. See the out-of-lane findings; it is tracked in #22547. - **Mechanism assumption 2** is half false, as measured above. **Assumption 3** holds: one `writeTombstone` and one `isLiveAttachmentsFile` now serve both triggers. **Assumption 4** holds: everything is read under system context, nothing reaches the caller, and the refusal never waits on it. - **The update verb, an observation:** a refused `sys_attachment` update whose payload re-points `file_id` at a fresh upload would leave that upload committed and unheld the same way. No producer writes such an update today (the console never updates `file_id`), so it is not covered or filed here. Carrier: none. - **The last-join-row path, an observation:** `tombstoneOrphanedFiles` asks only join rows, not the `ref_*` limb. That is benign: the sweep's `findFileHolder` re-check un-tombstones a field-owned file, and downloads and hydration ask the same question. It is unchanged here. Carrier: none. - **A refusal later in the same insert:** a refusal after the storage gate admits the insert (validation, a write-image check that runs after `beforeInsert`, a driver fault) is not this gate's refusal and is not covered. It belongs to the same family as the findings below. ## Out-of-lane findings (for the seat to file) The seat filed this family as #22547 (`Blocked-by: #22466`). How to cover it is triage's call on that card; nothing about it rides this PR. - **One family: attach refusals decided outside the storage gate leave the same orphan.** Filed as #22547. The create-grant refusal (plugin-security CRUD check, 403 `PERMISSION_DENIED`, raised in the middleware registered in its `start()`, before `next()`) and the `enable.files` refusal (plugin-audit `enforceFilesCapability`, 403 `FILES_DISABLED`) both refuse an attach without the storage gate refusing. The uploaded file stays committed and unheld. - Reach: the card's own measured producer. hotcrm#2029's run on 17.7.0 used a read-only `sys_attachment` grant: the console upload, then the final attach answered 403, leaving a committed `sys_file` with no attachment. Today the console hides Upload without the create grant, so the remaining reach is a grant that changes between render and click. - Covering it needs a seam that sees every refusal of a `sys_attachment` insert. That is either an outermost storage middleware (only an `init()`-phase registration would precede plugin-security's `start()`-time one, and it would be the first such registration in the repo), or the refusing plugins, which are outside this card's file surface. - Dedupe words: attach create grant refusal orphan sys_file · PERMISSION_DENIED sys_attachment insert tombstone · FILES_DISABLED refused attach orphan · refusal outside storage gate never reaches tombstone --- _Generated by [Claude Code](https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ce78ff7 commit f782f17

4 files changed

Lines changed: 606 additions & 13 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/service-storage': patch
3+
---
4+
5+
fix(service-storage): an attach the attachment gate refuses no longer leaves the uploaded file stored forever — the caller's own never-attached file is tombstoned and reclaimed by the sweep
6+
7+
Clause-②: no
8+
9+
Attaching a file is two writes: the upload commits a `sys_file` (scope `attachments`), and then a `sys_attachment` insert attaches it to a record. When the attachment gate refused that insert with `403 ATTACHMENT_PARENT_ACCESS`, the file stayed `committed` with no attachment pointing at it. Nothing ever reclaimed it: files were tombstoned only when they lost their last attachment, and this one never had one.
10+
11+
- **Now:** when the attachment gate refuses an attach, the file is tombstoned (`status: 'deleted'`, `deleted_at` set), the same way a file is tombstoned when its last attachment is removed. This covers every way the gate refuses: sharing denies edit on the parent, the parent's master record denies it (`controlled_by_parent`), or the parent cannot be read. From there the file follows the existing path. The lifecycle sweep reclaims the row and its bytes 30 days after `deleted_at`. At sweep time it checks again that nothing holds the file.
12+
- **Only the caller's own unheld upload:** the file must be an `attachments`-scope, `committed` file. The refused caller must be its uploader (`sys_file.owner_id`). It must have no attachment and no field owner (`ref_*`). A refused attach that names someone else's file, a file another record still holds, or a field file changes nothing.
13+
- **It survives a rollback:** the tombstone is written after the refusal, outside the refused write's transaction. If the attach ran inside a caller's own transaction, an `atomic` batch for example, rolling that transaction back does not undo the tombstone.
14+
- **Unchanged:** the refusal itself (status, code, message), which does not depend on the file and says nothing about it; an admitted attach; and a retry. Retrying the same `file_id` within the 30 days is admitted or refused like any attach. An admitted retry attaches the file and brings it back to `committed`, as re-attaching a detached file always has.
15+
- **Not covered:** an attach refused before the attachment gate runs still leaves the uploaded file `committed`. That is an attach with no `sys_attachment` create grant (`403 PERMISSION_DENIED`), or one to a parent with `enable.files` off (`403 FILES_DISABLED`). This is tracked in #22547.
16+
17+
What changes for you: nothing to do. When the attachment gate refuses an attach, the uploaded file no longer uses storage indefinitely.

‎packages/services/service-storage/src/attachment-access-hooks.ts‎

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,11 @@ import type { ISecurityService, ISharingService } from '@objectstack/spec/contra
66
import type { ExecutionContext } from '@objectstack/spec/kernel';
77
import { renderOperationMessage, type ValidationMessageTranslator } from '@objectstack/spec/system';
88

9-
import type {
10-
AttachmentLifecycleEngine,
11-
AttachmentLifecycleLogger,
12-
AttachmentReadMiddlewareCtx,
9+
import {
10+
createRefusedAttachTombstoner,
11+
type AttachmentLifecycleEngine,
12+
type AttachmentLifecycleLogger,
13+
type AttachmentReadMiddlewareCtx,
1314
} from './attachment-lifecycle.js';
1415

1516
/**
@@ -26,7 +27,9 @@ import type {
2627
* "Parent EDIT" below; Salesforce parity, #2970 item 3 — v1 asked read
2728
* visibility, which is now only the degraded mode). Fail-closed 403
2829
* `ATTACHMENT_PARENT_ACCESS`. `uploaded_by` is server-stamped from the
29-
* session — a client-supplied value never wins.
30+
* session — a client-supplied value never wins. A refusal also hands the
31+
* refused `file_id` to the lifecycle, which tombstones the caller's own
32+
* never-attached upload (#22466, `createRefusedAttachTombstoner`).
3033
* - beforeUpdate (commit da891e0ef): the caller must be the uploader OR hold edit on
3134
* the parent record — the delete rule, applied to the verb that could
3235
* otherwise rewrite the other two gates away: an ungated update let any
@@ -371,6 +374,15 @@ export function installAttachmentAccessHooks(
371374
messageTranslator?: () => ValidationMessageTranslator | undefined,
372375
getSecurity?: () => AttachmentSecurityLike | null | undefined,
373376
): void {
377+
/**
378+
* [#22466] What a refused attach does about its file. Created HERE, at
379+
* installation — outside every engine operation — because the tombstone it
380+
* schedules runs in a snapshot of this context: outside the refused write's
381+
* unit of work, so a caller's transaction rolling back cannot take the
382+
* tombstone with it. See {@link createRefusedAttachTombstoner}.
383+
*/
384+
const tombstoneRefusedAttach = createRefusedAttachTombstoner(engine, logger);
385+
374386
/**
375387
* May the caller EDIT the parent record `(object, recordId)`? The one
376388
* question the attach rule, the row rule's parent-editor limb and the
@@ -573,6 +585,14 @@ export function installAttachmentAccessHooks(
573585
// its master, as its own update is.
574586
const allowed = await mayEditParent(ctx, parentObject, parentId, callerContext(ctx), 'attach');
575587
if (!allowed) {
588+
// [#22466] Every refusal leg of the attach rule arrives HERE — sharing
589+
// `deny` or a non-verdict, the master-detail check's `deny` or
590+
// `unresolvable`, the degraded read probe's miss — so this one call
591+
// covers them all and no leg can drift. A REJECTION from either check
592+
// (an outage) never reaches this line: it is no verdict, and keeps
593+
// its own status. The tombstone is scheduled, never awaited, and is
594+
// conditional on the file being the caller's own unheld upload.
595+
tombstoneRefusedAttach(data.file_id, ctx.session.userId);
576596
forbid(
577597
'ATTACHMENT_PARENT_ACCESS',
578598
`Cannot attach to ${parentObject}/${parentId}: the parent record does not exist or you cannot edit it`,

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

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

3+
import { AsyncLocalStorage } from 'node:async_hooks';
34
import type { IStorageService } from '@objectstack/spec/contracts';
5+
import type { FileRecord } from './metadata-store.js';
6+
import { isFileUploader } from './upload-ownership.js';
47

58
/**
69
* sys_file orphan lifecycle (#2755, ADR-0057).
@@ -15,8 +18,10 @@ import type { IStorageService } from '@objectstack/spec/contracts';
1518
* LAST join row referencing an attachments-scope file goes away, the
1619
* `sys_file` row is marked `status='deleted'` + `deleted_at=now`. A join
1720
* row goes away two ways, and both are covered: it is DELETED, or an
18-
* UPDATE re-points its `file_id` at some other file (#10171).
19-
* Re-attaching before the grace window expires un-tombstones it.
21+
* UPDATE re-points its `file_id` at some other file (#10171). A file that
22+
* never GAINED a join row because its attach was refused takes the same
23+
* tombstone, from the attach gate ({@link createRefusedAttachTombstoner},
24+
* #22466). Re-attaching before the grace window expires un-tombstones it.
2025
* 2. The `lifecycle` declaration on `sys_file` (system-file.object.ts):
2126
* the platform LifecycleService reaps tombstones `30d` after
2227
* `deleted_at`, and never-completed `pending` uploads after `7d`.
@@ -87,6 +92,32 @@ export interface AttachmentLifecycleLogger {
8792
const PACKAGE_ID = 'com.objectstack.service.storage';
8893
const SYSTEM_CTX = { isSystem: true } as const;
8994

95+
/**
96+
* May this module tombstone this `sys_file` row at all? Only a live
97+
* (`committed`) file of the `attachments` scope — the one answer every trigger
98+
* asks, whatever made it look orphaned. Field-file scopes have their own seam
99+
* (see the module header), and a row that is `pending` or already `deleted`
100+
* has nothing to move.
101+
*/
102+
function isLiveAttachmentsFile(file: Record<string, unknown> | null | undefined): file is Record<string, unknown> {
103+
return !!file && file.scope === 'attachments' && file.status === 'committed';
104+
}
105+
106+
/**
107+
* THE tombstone — `status='deleted'` + `deleted_at=now`, written under system
108+
* context. One shape for every trigger, because the declared lifecycle reads
109+
* exactly these two columns: `ttl { field: 'deleted_at' }` nominates the row
110+
* and the reap guard below confirms it on `status === 'deleted'`. A second
111+
* spelling of it would be a second thing for the sweep to miss.
112+
*/
113+
async function writeTombstone(engine: Pick<AttachmentLifecycleEngine, 'update'>, fileId: string): Promise<void> {
114+
await engine.update(
115+
'sys_file',
116+
{ id: fileId, status: 'deleted', deleted_at: new Date().toISOString() },
117+
{ context: { ...SYSTEM_CTX } },
118+
);
119+
}
120+
90121
/**
91122
* Tombstone every id in `fileIds` that no longer has a join row — the orphan
92123
* rule, in ONE place because two write verbs now ask it (`afterDelete`, and
@@ -110,12 +141,8 @@ async function tombstoneOrphanedFiles(
110141
});
111142
if (remaining?.length) continue;
112143
const file = await engine.findOne('sys_file', { where: { id: fileId }, context: { ...SYSTEM_CTX } });
113-
if (!file || file.scope !== 'attachments' || file.status !== 'committed') continue;
114-
await engine.update(
115-
'sys_file',
116-
{ id: fileId, status: 'deleted', deleted_at: new Date().toISOString() },
117-
{ context: { ...SYSTEM_CTX } },
118-
);
144+
if (!isLiveAttachmentsFile(file)) continue;
145+
await writeTombstone(engine, fileId);
119146
logger.debug?.(`[storage] attachment lifecycle: tombstoned orphan sys_file ${fileId}`);
120147
} catch (err) {
121148
logger.warn(
@@ -413,6 +440,140 @@ export async function findHeldFiles(
413440
return held;
414441
}
415442

443+
/**
444+
* Why a refused attach did nothing about its file, or that it tombstoned it —
445+
* the one debug line each scheduled run ends on, whichever way it went.
446+
*/
447+
type RefusedAttachOutcome =
448+
| 'tombstoned'
449+
| 'kept: no such file'
450+
| 'kept: not a committed attachments-scope file'
451+
| 'kept: the refused caller is not its uploader'
452+
| `kept: still held (${Exclude<FileHolder, null>})`;
453+
454+
/**
455+
* [#22466] A REFUSED attach tombstones the caller's own never-attached file.
456+
*
457+
* The console's upload is two writes: the presigned upload commits a
458+
* `sys_file` (scope `attachments`, `owner_id` = the uploader), and only then
459+
* does a `sys_attachment` insert attach it. When the attach gate refuses that
460+
* insert, the file is left `committed` with zero join rows, and nothing above
461+
* ever reaches it: the hooks tombstone only a file that LOSES its last join
462+
* row, and the declared lifecycle nominates only `deleted_at` (ttl) or
463+
* `pending` (retention) rows. Measured on `main` before this change, on every
464+
* refusal leg of the gate: refused 403, file `committed`, `deleted_at` null,
465+
* zero join rows — kept forever.
466+
*
467+
* So the gate hands the refused `file_id` here, and the file takes the SAME
468+
* tombstone a last-join-row removal writes ({@link writeTombstone}). From
469+
* there it follows the existing path and nothing new: `deleted_at` → the 30d
470+
* `ttl` → {@link createSysFileReapGuard}, which re-checks {@link
471+
* findFileHolder} at sweep time. A retry that attaches the same `file_id`
472+
* inside the window is admitted or refused exactly as any attach is; an
473+
* admitted one is revived by the `afterInsert` leg above, as a re-attach of a
474+
* detached file always was.
475+
*
476+
* Tombstoned only when ALL of these hold, read AFTER the refusal:
477+
* - the file is `attachments`-scope and `committed` ({@link isLiveAttachmentsFile});
478+
* - nothing holds it — {@link findFileHolder}, the ONE definition: zero join
479+
* rows AND no `ref_*` owner, so a file in field-file lineage is never
480+
* tombstoned by this path, whatever its scope says;
481+
* - the refused caller is its uploader — {@link isFileUploader}, the upload
482+
* ownership rule, which reads `sys_file.owner_id` (the uploader the upload
483+
* doors stamp; the file row carries no `uploaded_by`). A caller who names
484+
* someone else's file id in a refused attach moves nothing.
485+
*
486+
* ## Outside the refused write's unit of work — and why that needs a snapshot
487+
*
488+
* Measured on a real engine over sqlite: `engine.insert` opens NO transaction
489+
* of its own (the refusal throws from `beforeInsert` with none open), and the
490+
* generic `/data` create door calls it without one (`createData` in the
491+
* protocol), so on that door a system write made while the refusal unwinds
492+
* lands and survives. But a caller may wrap the attach in a transaction of its
493+
* own — an `atomic` batch, an explicit `transaction()` — and every engine call
494+
* issued inside it, hook and middleware writes included, joins that
495+
* transaction through the engine's ambient store (ADR-0034) and is rolled back
496+
* with it: measured, the same write did not survive. A tombstone written from
497+
* the gate in-line would therefore vanish exactly when the refusal aborts the
498+
* caller's unit of work.
499+
*
500+
* The run is therefore DETACHED from the refusal's async context: it starts
501+
* on a later turn of the event loop, inside a snapshot of the context this
502+
* tombstoner was CREATED in — the hook installation, outside every engine
503+
* operation — where the engine's ambient transaction store holds nothing. It
504+
* reads and writes on its own connection, after the refusal has left the
505+
* operation, and outlives a rollback. Two properties make detaching safe:
506+
* - it is never awaited by the refused write. Awaiting an out-of-transaction
507+
* query from inside an open transaction is the single-connection deadlock
508+
* ADR-0034 was written about; a detached one simply queues for the
509+
* connection until the transaction lets go of it;
510+
* - the tombstone is reconcilable by design. If something attaches the file
511+
* between the refusal and this run, the sweep's `findFileHolder` re-check
512+
* un-tombstones it instead of reaping it, and the download and hydration
513+
* paths already ask the same question of a tombstone (#10246, c3c72a4bc).
514+
*
515+
* Best-effort, like every leg here: a failure is logged and never reaches the
516+
* refused caller, whose refusal is byte-identical whether this ran or not.
517+
* Nothing it reads is returned to the caller, and the refusal does not wait on
518+
* it, so the refusal discloses nothing about the file — not even through its
519+
* timing.
520+
*
521+
* ⚠️ Create it where the gate is INSTALLED (outside any engine operation).
522+
* Created inside a transaction, the snapshot would capture that transaction
523+
* and every later run would ride a closed handle.
524+
*/
525+
export function createRefusedAttachTombstoner(
526+
engine: Pick<AttachmentLifecycleEngine, 'find' | 'findOne' | 'update'>,
527+
logger: AttachmentLifecycleLogger,
528+
): (fileId: unknown, callerUserId: string | undefined) => void {
529+
const outsideAnyOperation = AsyncLocalStorage.snapshot();
530+
return (fileId, callerUserId) => {
531+
// No file named, or no caller to be its uploader: nothing could pass the
532+
// conditions below, so nothing is scheduled.
533+
if (!((typeof fileId === 'string' && fileId !== '') || typeof fileId === 'number')) return;
534+
if (typeof callerUserId !== 'string' || callerUserId === '') return;
535+
const id = String(fileId);
536+
outsideAnyOperation(() => {
537+
setImmediate(() => {
538+
void tombstoneRefusedAttachFile(engine, logger, id, callerUserId);
539+
});
540+
});
541+
};
542+
}
543+
544+
async function tombstoneRefusedAttachFile(
545+
engine: Pick<AttachmentLifecycleEngine, 'find' | 'findOne' | 'update'>,
546+
logger: AttachmentLifecycleLogger,
547+
fileId: string,
548+
callerUserId: string,
549+
): Promise<void> {
550+
let outcome: RefusedAttachOutcome;
551+
try {
552+
const file = await engine.findOne('sys_file', { where: { id: fileId }, context: { ...SYSTEM_CTX } });
553+
if (!file) {
554+
outcome = 'kept: no such file';
555+
} else if (!isLiveAttachmentsFile(file)) {
556+
outcome = 'kept: not a committed attachments-scope file';
557+
} else if (!isFileUploader(callerUserId, file as Pick<FileRecord, 'owner_id'>)) {
558+
outcome = 'kept: the refused caller is not its uploader';
559+
} else {
560+
const holder = await findFileHolder(engine, fileId, file);
561+
if (holder) {
562+
outcome = `kept: still held (${holder})`;
563+
} else {
564+
await writeTombstone(engine, fileId);
565+
outcome = 'tombstoned';
566+
}
567+
}
568+
} catch (err) {
569+
logger.warn(
570+
`[storage] attachment lifecycle: failed to tombstone sys_file ${fileId} after a refused attach (${(err as Error)?.message ?? err})`,
571+
);
572+
return;
573+
}
574+
logger.debug?.(`[storage] attachment lifecycle: refused attach of sys_file ${fileId} — ${outcome}`);
575+
}
576+
416577
/**
417578
* The `sys_file` reap guard ({@link LifecycleReapGuard} shape from
418579
* `@objectstack/objectql`, duck-typed here to avoid the dependency).

0 commit comments

Comments
 (0)