From b22d798fbc0fcd67da2d8fac866e50cc6497d4a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 14:31:24 +0000 Subject: [PATCH 1/3] feat: thread mimeType and filename through StackAdapter.putAttachment The adapter interface previously accepted only raw bytes, forcing callers to omit Content-Type and Content-Disposition on upload. Now putAttachment accepts optional mimeType and filename params: APIAdapter forwards them as HTTP headers (defaulting to application/octet-stream), while the SQLite and test adapters accept but ignore them since they store locally. Stack.putAttachmentBytes / Stack.putAttachment / ScopedStack.putAttachment all forward the params so the metadata that was already tracked in the _attachment@1 record is also reflected in the binary upload request. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01RqkRy1ZDZqL6JDW9Xod5UX --- packages/adapter-api/src/index.ts | 9 +++++++-- packages/adapter-api/tests/api.test.ts | 25 ++++++++++++++++++++++++- packages/adapter-sqlite/src/index.ts | 2 +- packages/core/src/stack.ts | 8 ++++---- packages/core/src/testing.ts | 2 +- packages/core/src/types.ts | 2 +- 6 files changed, 38 insertions(+), 10 deletions(-) diff --git a/packages/adapter-api/src/index.ts b/packages/adapter-api/src/index.ts index 529b9f3..389845f 100644 --- a/packages/adapter-api/src/index.ts +++ b/packages/adapter-api/src/index.ts @@ -420,8 +420,13 @@ export class APIAdapter implements StackAdapter { // Attachments // ------------------------------------------------------- - async putAttachment(data: Uint8Array): Promise { - const result = await this.uploadBinary('/attachments', data, 'application/octet-stream'); + async putAttachment(data: Uint8Array, mimeType?: string, filename?: string): Promise { + const result = await this.uploadBinary( + '/attachments', + data, + mimeType ?? 'application/octet-stream', + filename, + ); return result.fileId as string; } diff --git a/packages/adapter-api/tests/api.test.ts b/packages/adapter-api/tests/api.test.ts index 45d6e51..4471137 100644 --- a/packages/adapter-api/tests/api.test.ts +++ b/packages/adapter-api/tests/api.test.ts @@ -577,7 +577,7 @@ describe('listTypes', () => { // ------------------------------------------------------- describe('putAttachment', () => { - test('sends POST /attachments with binary body and Content-Type: application/octet-stream', async () => { + test('sends POST /attachments with binary body and Content-Type: application/octet-stream by default', async () => { const adapter = await openAdapter(); mockFetch.mockResolvedValueOnce(jsonResponse({ fileId: 'file-xyz' })); const data = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); @@ -589,6 +589,29 @@ describe('putAttachment', () => { expect((init.headers as Record)['Content-Type']).toBe( 'application/octet-stream', ); + expect((init.headers as Record)['Content-Disposition']).toBeUndefined(); + }); + + test('sends Content-Type when mimeType is provided', async () => { + const adapter = await openAdapter(); + mockFetch.mockResolvedValueOnce(jsonResponse({ fileId: 'file-xyz' })); + const data = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); + await adapter.putAttachment(data, 'image/png'); + const [, init] = mockFetch.mock.lastCall as [string, RequestInit]; + expect((init.headers as Record)['Content-Type']).toBe('image/png'); + expect((init.headers as Record)['Content-Disposition']).toBeUndefined(); + }); + + test('sends Content-Disposition when filename is provided', async () => { + const adapter = await openAdapter(); + mockFetch.mockResolvedValueOnce(jsonResponse({ fileId: 'file-xyz' })); + const data = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); + await adapter.putAttachment(data, 'image/png', 'photo.png'); + const [, init] = mockFetch.mock.lastCall as [string, RequestInit]; + expect((init.headers as Record)['Content-Type']).toBe('image/png'); + expect((init.headers as Record)['Content-Disposition']).toBe( + "attachment; filename*=UTF-8''photo.png", + ); }); }); diff --git a/packages/adapter-sqlite/src/index.ts b/packages/adapter-sqlite/src/index.ts index 23ebf90..6ec3184 100644 --- a/packages/adapter-sqlite/src/index.ts +++ b/packages/adapter-sqlite/src/index.ts @@ -804,7 +804,7 @@ export class SQLiteAdapter implements StackAdapter { // Attachments // ------------------------------------------------------- - async putAttachment(data: Uint8Array): Promise { + async putAttachment(data: Uint8Array, _mimeType?: string, _filename?: string): Promise { const fileId = createHash('sha256').update(data).digest('hex'); // Dedup: same bytes already stored — return existing ID without re-writing. diff --git a/packages/core/src/stack.ts b/packages/core/src/stack.ts index e501c84..ebf2f7a 100644 --- a/packages/core/src/stack.ts +++ b/packages/core/src/stack.ts @@ -582,8 +582,8 @@ export class Stack implements StackClient { * Does not create an _attachment@1 record — use putAttachment() or * ScopedStack.putAttachment() for the full upload flow. */ - async putAttachmentBytes(data: Uint8Array): Promise { - return this.adapter.putAttachment(data); + async putAttachmentBytes(data: Uint8Array, mimeType?: string, filename?: string): Promise { + return this.adapter.putAttachment(data, mimeType, filename); } /** @@ -592,7 +592,7 @@ export class Stack implements StackClient { * specific entity rather than the stack owner. */ async putAttachment(data: Uint8Array, mimeType: string, filename?: string): Promise { - const fileId = await this.putAttachmentBytes(data); + const fileId = await this.putAttachmentBytes(data, mimeType, filename); await this.create(`${SYSTEM_TYPES.ATTACHMENT}@1`, { fileId, mimeType, @@ -948,7 +948,7 @@ export class ScopedStack implements StackClient { if (!(await this.checkCreateGrant(`${SYSTEM_TYPES.ATTACHMENT}@1`))) { throw new StackPermissionError(`No create grant for type "${SYSTEM_TYPES.ATTACHMENT}@1"`); } - const fileId = await this.stack.putAttachmentBytes(data); + const fileId = await this.stack.putAttachmentBytes(data, mimeType, filename); await this.stack.create( `${SYSTEM_TYPES.ATTACHMENT}@1`, { diff --git a/packages/core/src/testing.ts b/packages/core/src/testing.ts index c8c8bb4..957bafb 100644 --- a/packages/core/src/testing.ts +++ b/packages/core/src/testing.ts @@ -132,7 +132,7 @@ export class MemoryAdapter implements StackAdapter { return [...this.types.values()]; } - async putAttachment(_data: Uint8Array): Promise { + async putAttachment(_data: Uint8Array, _mimeType?: string, _filename?: string): Promise { return 'file-123'; } async getAttachment(_fileId: string): Promise { diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 5584d99..302650a 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -345,7 +345,7 @@ export interface StackAdapter { listTypes(): Promise; // Attachments — bytes storage only; metadata lives on _attachment@1 records - putAttachment(data: Uint8Array): Promise; + putAttachment(data: Uint8Array, mimeType?: string, filename?: string): Promise; getAttachment(fileId: FileId): Promise; deleteAttachment(fileId: FileId): Promise; From 1a2913b881f0397e1f0d98378807a9c6b676821e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 17:11:05 +0000 Subject: [PATCH 2/3] refactor(adapter-sqlite): drop attachments table, use filesystem as source of truth The attachments table was stripped to just (file_id TEXT PRIMARY KEY) when _attachment@1 records took over metadata tracking. With content-addressed storage the filesystem already deduplicates by SHA-256 filename, making the table a redundant shadow of the disk. - Remove attachments table from SCHEMA_SQL - runMigrations: drop the table if still present in older databases - putAttachment: dedup via existsSync instead of SELECT 1 - getAttachment: existence check via existsSync (no DB query) - deleteAttachment: remove DELETE FROM attachments (only unlink needed) Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01RqkRy1ZDZqL6JDW9Xod5UX --- packages/adapter-sqlite/src/index.ts | 51 ++++++---------------------- 1 file changed, 11 insertions(+), 40 deletions(-) diff --git a/packages/adapter-sqlite/src/index.ts b/packages/adapter-sqlite/src/index.ts index 6ec3184..5434ad3 100644 --- a/packages/adapter-sqlite/src/index.ts +++ b/packages/adapter-sqlite/src/index.ts @@ -8,8 +8,8 @@ * The database is held in memory and flushed to disk on every * write. Attachments are stored as extension-less files in an * `attachments/` subdirectory next to the database file. - * File IDs are SHA-256 hashes of the content, enabling - * automatic deduplication. + * File IDs are SHA-256 hashes of the content; the filesystem + * is the authoritative store — no separate DB table is needed. * * Stack config (timezone, entity_id, etc.) is stored in a * `stack_config` key/value table. @@ -113,10 +113,6 @@ const SCHEMA_SQL = ` created_at INTEGER NOT NULL ) STRICT; - CREATE TABLE IF NOT EXISTS attachments ( - file_id TEXT PRIMARY KEY - ) STRICT; - CREATE TABLE IF NOT EXISTS tokens ( id TEXT PRIMARY KEY, token_hash TEXT NOT NULL UNIQUE, @@ -561,23 +557,11 @@ export class SQLiteAdapter implements StackAdapter { * changes. Safe to call on both fresh and existing databases. */ private runMigrations(): void { + // The attachments table is obsolete — binary files are content-addressed on + // disk and the filesystem is the authoritative source of truth. Drop it if + // an older database still has it. const cols = this.execQuery<{ name: string }>('PRAGMA table_info(attachments)'); - if (!cols.length) return; // Table doesn't exist yet — SCHEMA_SQL will create it. - const hasPath = cols.some((c) => c.name === 'path'); - if (hasPath) { - // Pre-content-addressed-storage schema: drop and recreate as minimal schema. - this.db.run('DROP TABLE attachments'); - this.db.run('CREATE TABLE attachments (file_id TEXT PRIMARY KEY) STRICT'); - return; - } - const hasMimeType = cols.some((c) => c.name === 'mime_type'); - if (hasMimeType) { - // Metadata columns moved to _attachment@1 records: migrate to minimal schema. - this.db.run('ALTER TABLE attachments RENAME TO attachments_old'); - this.db.run('CREATE TABLE attachments (file_id TEXT PRIMARY KEY) STRICT'); - this.db.run('INSERT INTO attachments (file_id) SELECT file_id FROM attachments_old'); - this.db.run('DROP TABLE attachments_old'); - } + if (cols.length) this.db.run('DROP TABLE attachments'); } // ------------------------------------------------------- @@ -806,34 +790,21 @@ export class SQLiteAdapter implements StackAdapter { async putAttachment(data: Uint8Array, _mimeType?: string, _filename?: string): Promise { const fileId = createHash('sha256').update(data).digest('hex'); - - // Dedup: same bytes already stored — return existing ID without re-writing. - const exists = this.execQuery>( - 'SELECT 1 FROM attachments WHERE file_id = ?', - [fileId], - ); - if (exists.length > 0) return fileId; - - await writeFile(join(this.attachmentsDir, fileId), data); - this.db.run('INSERT INTO attachments (file_id) VALUES (?)', [fileId]); - this.persist(); + if (!existsSync(join(this.attachmentsDir, fileId))) { + await writeFile(join(this.attachmentsDir, fileId), data); + } return fileId; } async getAttachment(fileId: string): Promise { assertFileId(fileId); - const exists = this.execQuery>( - 'SELECT 1 FROM attachments WHERE file_id = ?', - [fileId], - ); - if (!exists.length) throw new Error(`Attachment not found: "${fileId}"`); + if (!existsSync(join(this.attachmentsDir, fileId))) + throw new Error(`Attachment not found: "${fileId}"`); return readFile(join(this.attachmentsDir, fileId)); } async deleteAttachment(fileId: string): Promise { assertFileId(fileId); - this.db.run('DELETE FROM attachments WHERE file_id = ?', [fileId]); - this.persist(); try { await unlink(join(this.attachmentsDir, fileId)); } catch { From 0e57e6de474f262c6390f92cd78f8e18153d5a2f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 22:37:08 +0000 Subject: [PATCH 3/3] refactor: keep StackAdapter.putAttachment bytes-only, drop mimeType/filename params MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The adapter layer is a raw bytes store — metadata belongs exclusively on _attachment@1 records created by Stack/ScopedStack.putAttachment. Passing mimeType and filename through the adapter interface leaked HTTP/semantic concerns into a layer that has no use for them (SQLite accepted but ignored them). The Stack layer already captures the right metadata at the right level. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01RqkRy1ZDZqL6JDW9Xod5UX --- packages/adapter-api/src/index.ts | 9 ++------- packages/adapter-api/tests/api.test.ts | 25 +------------------------ packages/adapter-sqlite/src/index.ts | 2 +- packages/core/src/stack.ts | 8 ++++---- packages/core/src/testing.ts | 2 +- packages/core/src/types.ts | 2 +- 6 files changed, 10 insertions(+), 38 deletions(-) diff --git a/packages/adapter-api/src/index.ts b/packages/adapter-api/src/index.ts index 389845f..529b9f3 100644 --- a/packages/adapter-api/src/index.ts +++ b/packages/adapter-api/src/index.ts @@ -420,13 +420,8 @@ export class APIAdapter implements StackAdapter { // Attachments // ------------------------------------------------------- - async putAttachment(data: Uint8Array, mimeType?: string, filename?: string): Promise { - const result = await this.uploadBinary( - '/attachments', - data, - mimeType ?? 'application/octet-stream', - filename, - ); + async putAttachment(data: Uint8Array): Promise { + const result = await this.uploadBinary('/attachments', data, 'application/octet-stream'); return result.fileId as string; } diff --git a/packages/adapter-api/tests/api.test.ts b/packages/adapter-api/tests/api.test.ts index 4471137..45d6e51 100644 --- a/packages/adapter-api/tests/api.test.ts +++ b/packages/adapter-api/tests/api.test.ts @@ -577,7 +577,7 @@ describe('listTypes', () => { // ------------------------------------------------------- describe('putAttachment', () => { - test('sends POST /attachments with binary body and Content-Type: application/octet-stream by default', async () => { + test('sends POST /attachments with binary body and Content-Type: application/octet-stream', async () => { const adapter = await openAdapter(); mockFetch.mockResolvedValueOnce(jsonResponse({ fileId: 'file-xyz' })); const data = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); @@ -589,29 +589,6 @@ describe('putAttachment', () => { expect((init.headers as Record)['Content-Type']).toBe( 'application/octet-stream', ); - expect((init.headers as Record)['Content-Disposition']).toBeUndefined(); - }); - - test('sends Content-Type when mimeType is provided', async () => { - const adapter = await openAdapter(); - mockFetch.mockResolvedValueOnce(jsonResponse({ fileId: 'file-xyz' })); - const data = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); - await adapter.putAttachment(data, 'image/png'); - const [, init] = mockFetch.mock.lastCall as [string, RequestInit]; - expect((init.headers as Record)['Content-Type']).toBe('image/png'); - expect((init.headers as Record)['Content-Disposition']).toBeUndefined(); - }); - - test('sends Content-Disposition when filename is provided', async () => { - const adapter = await openAdapter(); - mockFetch.mockResolvedValueOnce(jsonResponse({ fileId: 'file-xyz' })); - const data = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); - await adapter.putAttachment(data, 'image/png', 'photo.png'); - const [, init] = mockFetch.mock.lastCall as [string, RequestInit]; - expect((init.headers as Record)['Content-Type']).toBe('image/png'); - expect((init.headers as Record)['Content-Disposition']).toBe( - "attachment; filename*=UTF-8''photo.png", - ); }); }); diff --git a/packages/adapter-sqlite/src/index.ts b/packages/adapter-sqlite/src/index.ts index 5434ad3..306370c 100644 --- a/packages/adapter-sqlite/src/index.ts +++ b/packages/adapter-sqlite/src/index.ts @@ -788,7 +788,7 @@ export class SQLiteAdapter implements StackAdapter { // Attachments // ------------------------------------------------------- - async putAttachment(data: Uint8Array, _mimeType?: string, _filename?: string): Promise { + async putAttachment(data: Uint8Array): Promise { const fileId = createHash('sha256').update(data).digest('hex'); if (!existsSync(join(this.attachmentsDir, fileId))) { await writeFile(join(this.attachmentsDir, fileId), data); diff --git a/packages/core/src/stack.ts b/packages/core/src/stack.ts index ebf2f7a..e501c84 100644 --- a/packages/core/src/stack.ts +++ b/packages/core/src/stack.ts @@ -582,8 +582,8 @@ export class Stack implements StackClient { * Does not create an _attachment@1 record — use putAttachment() or * ScopedStack.putAttachment() for the full upload flow. */ - async putAttachmentBytes(data: Uint8Array, mimeType?: string, filename?: string): Promise { - return this.adapter.putAttachment(data, mimeType, filename); + async putAttachmentBytes(data: Uint8Array): Promise { + return this.adapter.putAttachment(data); } /** @@ -592,7 +592,7 @@ export class Stack implements StackClient { * specific entity rather than the stack owner. */ async putAttachment(data: Uint8Array, mimeType: string, filename?: string): Promise { - const fileId = await this.putAttachmentBytes(data, mimeType, filename); + const fileId = await this.putAttachmentBytes(data); await this.create(`${SYSTEM_TYPES.ATTACHMENT}@1`, { fileId, mimeType, @@ -948,7 +948,7 @@ export class ScopedStack implements StackClient { if (!(await this.checkCreateGrant(`${SYSTEM_TYPES.ATTACHMENT}@1`))) { throw new StackPermissionError(`No create grant for type "${SYSTEM_TYPES.ATTACHMENT}@1"`); } - const fileId = await this.stack.putAttachmentBytes(data, mimeType, filename); + const fileId = await this.stack.putAttachmentBytes(data); await this.stack.create( `${SYSTEM_TYPES.ATTACHMENT}@1`, { diff --git a/packages/core/src/testing.ts b/packages/core/src/testing.ts index 957bafb..c8c8bb4 100644 --- a/packages/core/src/testing.ts +++ b/packages/core/src/testing.ts @@ -132,7 +132,7 @@ export class MemoryAdapter implements StackAdapter { return [...this.types.values()]; } - async putAttachment(_data: Uint8Array, _mimeType?: string, _filename?: string): Promise { + async putAttachment(_data: Uint8Array): Promise { return 'file-123'; } async getAttachment(_fileId: string): Promise { diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 302650a..5584d99 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -345,7 +345,7 @@ export interface StackAdapter { listTypes(): Promise; // Attachments — bytes storage only; metadata lives on _attachment@1 records - putAttachment(data: Uint8Array, mimeType?: string, filename?: string): Promise; + putAttachment(data: Uint8Array): Promise; getAttachment(fileId: FileId): Promise; deleteAttachment(fileId: FileId): Promise;