From 8b9110883de40c5250ab6110e0a28ed42344e1bc Mon Sep 17 00:00:00 2001 From: Jen Garcia Date: Thu, 25 Jun 2026 08:34:39 -0400 Subject: [PATCH 1/3] Add getAttachment and deleteAttachment to StackClient, Stack, and ScopedStack Stack.deleteAttachment mirrors the logic that was only in the server's HTTP route: checks for referencing records, handles missing metadata records, hard-deletes _attachment@1 metadata, then deletes bytes. Uses contentFieldQuery capability flag for adapters that don't support content field filtering. ScopedStack.getAttachment translates the isAttachmentAccessible logic from the server route into the SDK layer: owner always allowed, otherwise checks for a readable referencing record, then falls back to checking uploader ownership of an unassociated _attachment@1 record. ScopedStack.deleteAttachment is owner-only, matching the requireOwner guard on the server's DELETE /attachments/:fileId route. --- packages/core/src/stack.ts | 84 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/packages/core/src/stack.ts b/packages/core/src/stack.ts index bfa906b..ba1ac42 100644 --- a/packages/core/src/stack.ts +++ b/packages/core/src/stack.ts @@ -123,7 +123,9 @@ export interface StackClient { getVersions(id: string): Promise; getVersion(id: string, version: number): Promise; restoreVersion(id: string, version: number): Promise; + getAttachment(fileId: string): Promise; putAttachment(data: Uint8Array, mimeType: string, filename?: string): Promise; + deleteAttachment(fileId: string): Promise; } // ------------------------------------------------------- @@ -606,6 +608,42 @@ export class Stack implements StackClient { return this.adapter.getAttachment(fileId); } + /** + * Delete an attachment's bytes and its _attachment@1 metadata record(s). + * Throws if any record in the stack still references the file. + * Throws StackNotFoundError if neither metadata records nor bytes exist. + */ + async deleteAttachment(fileId: string): Promise { + const refResult = await this.query({ filter: { attachmentFileId: fileId }, limit: 1 }); + if (refResult.records.length > 0) { + throw new Error('Attachment is still referenced by one or more records'); + } + + const metaResult = await this.query({ + filter: { + typeId: `${SYSTEM_TYPES.ATTACHMENT}@1`, + ...(this.features.contentFieldQuery && { content: { fileId } }), + }, + }); + const metaRecords = this.features.contentFieldQuery + ? metaResult.records + : metaResult.records.filter((r) => (r.content as AttachmentContent).fileId === fileId); + + if (!metaRecords.length) { + try { + await this.adapter.getAttachment(fileId); + } catch { + throw new StackNotFoundError(`Attachment not found: "${fileId}"`); + } + } + + for (const record of metaRecords) { + await this.delete(record.id, { hard: true }); + } + + await this.adapter.deleteAttachment(fileId); + } + // ------------------------------------------------------- // Lifecycle // ------------------------------------------------------- @@ -965,4 +1003,50 @@ export class ScopedStack implements StackClient { ); return fileId; } + + /** + * Download attachment bytes. Accessible if the requester is the owner, + * can read any record referencing the file, or uploaded the file themselves + * and it hasn't been associated with a record yet. + */ + async getAttachment(fileId: string): Promise { + if (this.requesterEntityId === this.stack.ownerEntityId) { + return this.stack.getAttachment(fileId); + } + + // Accessible if the requester can read any record that references this file + const refResult = await this.query({ filter: { attachmentFileId: fileId }, limit: 1 }); + if (refResult.records.length > 0) { + return this.stack.getAttachment(fileId); + } + + // Accessible if the requester uploaded it and it hasn't been associated yet + if (this.requesterEntityId) { + const uploadResult = await this.stack.query({ + filter: { + typeId: `${SYSTEM_TYPES.ATTACHMENT}@1`, + entityId: this.requesterEntityId, + ...(this.stack.features.contentFieldQuery && { content: { fileId } }), + }, + limit: 1, + }); + const hasUpload = this.stack.features.contentFieldQuery + ? uploadResult.records.length > 0 + : uploadResult.records.some((r) => (r.content as AttachmentContent).fileId === fileId); + if (hasUpload) return this.stack.getAttachment(fileId); + } + + throw new StackPermissionError(); + } + + /** + * Delete an attachment. Only the stack owner may delete attachments. + * Delegates to Stack.deleteAttachment(), which enforces the "not referenced" check. + */ + async deleteAttachment(fileId: string): Promise { + if (this.requesterEntityId !== this.stack.ownerEntityId) { + throw new StackPermissionError('Only the stack owner can delete attachments'); + } + return this.stack.deleteAttachment(fileId); + } } From 594080a51390385aad11697544c1af6c95dd5073 Mon Sep 17 00:00:00 2001 From: Jen Garcia Date: Thu, 25 Jun 2026 08:47:52 -0400 Subject: [PATCH 2/3] Add StackConflictError and use it in deleteAttachment Replaces the generic Error thrown when an attachment is still referenced by one or more records. StackConflictError is a named, catchable error class analogous to HTTP 409, which is what the server route already returns for this case. --- packages/core/src/stack.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/core/src/stack.ts b/packages/core/src/stack.ts index ba1ac42..72afe2b 100644 --- a/packages/core/src/stack.ts +++ b/packages/core/src/stack.ts @@ -97,6 +97,14 @@ export class StackNotFoundError extends Error { } } +/** Thrown when an operation cannot proceed due to a constraint violation (e.g. deleting an attachment that is still referenced). */ +export class StackConflictError extends Error { + constructor(message: string) { + super(message); + this.name = 'StackConflictError'; + } +} + // ------------------------------------------------------- // StackClient interface // ------------------------------------------------------- @@ -610,13 +618,13 @@ export class Stack implements StackClient { /** * Delete an attachment's bytes and its _attachment@1 metadata record(s). - * Throws if any record in the stack still references the file. + * Throws StackConflictError if any record in the stack still references the file. * Throws StackNotFoundError if neither metadata records nor bytes exist. */ async deleteAttachment(fileId: string): Promise { const refResult = await this.query({ filter: { attachmentFileId: fileId }, limit: 1 }); if (refResult.records.length > 0) { - throw new Error('Attachment is still referenced by one or more records'); + throw new StackConflictError('Attachment is still referenced by one or more records'); } const metaResult = await this.query({ From 2aeefa055f5d77fa6bd53e036f0ce3063b004bfe Mon Sep 17 00:00:00 2001 From: Jen Garcia Date: Thu, 25 Jun 2026 09:04:12 -0400 Subject: [PATCH 3/3] docs(spec): document getAttachment, deleteAttachment, and StackConflictError - Add getAttachment and deleteAttachment to the StackClient method list - Expand the Attachments section with deleteAttachment usage and a Stack vs ScopedStack breakdown for all three attachment methods - Add 409 / StackConflictError to the error responses table --- docs/spec.md | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/docs/spec.md b/docs/spec.md index 9285665..a769930 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -33,7 +33,7 @@ stack.timezone; // from adapter.timezone `LocalAdapter.initialize()` fails if the file already exists. `LocalAdapter.open()` fails if the file does not exist. This makes the distinction explicit and prevents silent config divergence. -Plugin and extension code that doesn't need to know the underlying backend should accept `StackClient` rather than the concrete `Stack` or `ScopedStack`. `StackClient` is the passable interface covering the full record API (`create`, `get`, `query`, `update`, `delete`, `associate`, `dissociate`, `setPermissions`, `getVersions`, `getVersion`, `restoreVersion`, `putAttachment`) plus a `features` getter. Both `Stack` and `ScopedStack` implement it. +Plugin and extension code that doesn't need to know the underlying backend should accept `StackClient` rather than the concrete `Stack` or `ScopedStack`. `StackClient` is the passable interface covering the full record API (`create`, `get`, `query`, `update`, `delete`, `associate`, `dissociate`, `setPermissions`, `getVersions`, `getVersion`, `restoreVersion`, `getAttachment`, `putAttachment`, `deleteAttachment`) plus a `features` getter. Both `Stack` and `ScopedStack` implement it. **Stack identity** (`ownerEntityId`, `timezone`) is stored as a singleton `_config@1` record in the records table. Adapters expose these values as typed readonly properties (`adapter.ownerEntityId`, `adapter.timezone`) rather than as a generic key/value store. @@ -449,6 +449,10 @@ const fileId = await stack.putAttachment(data: Uint8Array, mimeType: string, fil // Fetch the binary const data: Uint8Array = await stack.getAttachment(fileId) + +// Delete the binary and its _attachment@1 metadata record(s) +// Throws StackConflictError if any record still references the file +await stack.deleteAttachment(fileId) ``` A `fileId` is referenced in an `Association` of kind `"attachment"`. To read metadata for a given `fileId`, query `_attachment@1` records: @@ -464,11 +468,17 @@ const results = await stack.query({ const meta = results.records[0]?.content as AttachmentContent | undefined; ``` -**`Stack.putAttachment()` vs `ScopedStack.putAttachment()`:** +**`Stack` vs `ScopedStack` attachment methods:** - `Stack.putAttachment(data, mimeType, filename?)` — owner-level upload. Creates an `_attachment@1` record with no `entityId`. No grant check. - `ScopedStack.putAttachment(data, mimeType, filename?)` — entity-scoped upload. Requires a `create` grant on `_attachment@1`. The created record's `entityId` is set to the uploading entity. +- `Stack.getAttachment(fileId)` — no permission check; always succeeds if the bytes exist. +- `ScopedStack.getAttachment(fileId)` — accessible if the requester is the owner, can read any record that references the file, or uploaded the file themselves and it hasn't been associated with a record yet. Throws `StackPermissionError` otherwise. + +- `Stack.deleteAttachment(fileId)` — deletes bytes and all `_attachment@1` metadata records for the file. Throws `StackConflictError` if any record still references the file. Throws `StackNotFoundError` if the file doesn't exist. +- `ScopedStack.deleteAttachment(fileId)` — owner only. Throws `StackPermissionError` for non-owners. Delegates to `Stack.deleteAttachment()`. + **Deduplication:** Bytes are deduplicated — uploading the same content twice stores the binary only once. However, each call to `putAttachment()` creates a new `_attachment@1` record with its own `mimeType` and `filename`. The same `fileId` may have multiple `_attachment@1` records from separate uploads. --- @@ -618,6 +628,7 @@ Standard HTTP status codes are used throughout: | **401** | Unauthorized | Missing or invalid bearer token | | **403** | Forbidden | `StackPermissionError` — record exists but the requester lacks access | | **404** | Not found | `StackNotFoundError` — record or version does not exist | +| **409** | Conflict | `StackConflictError` — operation blocked by a constraint violation (e.g. deleting an attachment still referenced by a record) | | **413** | Request entity too large | Attachment upload exceeds the server's size limit | | **422** | Unprocessable entity | `StackValidationError` — request is syntactically valid but content fails schema validation (e.g. a required field has the wrong type) | @@ -761,6 +772,8 @@ GET /attachments/?contentType=image/png&filename=photo.png When neither parameter is provided the server queries the `_attachment@1` record: the stored `mimeType` becomes `Content-Type`, and the filename is taken from the requester's own `_attachment@1` record (if one exists). Falls back to `Content-Type: application/octet-stream` when no metadata record is found. +**Delete:** Owner only. Returns `409 Conflict` if any record in the stack still references the file (i.e. has it in an `attachment` association or its content references the `fileId`). The bytes and all `_attachment@1` metadata records for the file are removed atomically on success. + **Attachment permissions** are governed by the Record(s) that reference them, not the attachment itself. If any Record referencing a `fileId` is accessible to the requester, the attachment is accessible. A non-owner requester can also access a file if they own an `_attachment@1` record for it, enabling access in the window between upload and record association. ### Entity