Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,10 @@ const stack = await Stack.create(adapter);

All adapters support the full Record API. Performance guarantees differ; correctness does not.

**`@haverstack/sqlite-shared`** is an internal, non-public package holding the SQL that's identical across every SQLite-backed record adapter — schema DDL, `WHERE`/`ORDER` building, the cursor codec, row mappers, and the FTS4 sanitizer. `record-adapter-sqljs` consumes it today; a future native SQLite adapter would too, sharing everything but the FTS dialect (FTS4 vs FTS5) and the engine binding itself. Keeping this logic in one place is what keeps a cursor minted by one SQLite-backed adapter decodable by another.

`record-adapter-sqljs` enables SQLite foreign-key enforcement (`PRAGMA foreign_keys = ON`) so that operations like `associate()` against a nonexistent record fail loudly (`StackNotFoundError`) instead of silently creating an orphan row.

---

## Concurrency & storage ownership
Expand Down Expand Up @@ -538,6 +542,8 @@ const meta = results.records[0]?.content as AttachmentContent | undefined;
- `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()`.

**Atomicity of the reference check.** The reference check and the metadata-record deletes must happen as one unit — otherwise a concurrent `associate()` can add a new reference in the gap between them, leaving a dangling association after the delete completes. Adapters MAY implement `StackRecordAdapter.deleteUnreferencedAttachmentRecords(fileId, metadataTypeId)` to close this race (`Stack.deleteAttachment()` uses it when present, falling back to a non-atomic check-then-act sequence otherwise). Byte deletion always happens after the metadata step commits: a crash in between leaves orphaned bytes, which is harmless and later reclaimed by garbage collection, rather than a dangling reference, which is not.

**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.

---
Expand Down
1 change: 1 addition & 0 deletions packages/adapter-local/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export default defineConfig({
__dirname,
'../record-adapter-sqljs/src/index.ts',
),
'@haverstack/sqlite-shared': resolve(__dirname, '../sqlite-shared/src/index.ts'),
'@haverstack/blob-adapter-disk': resolve(__dirname, '../blob-adapter-disk/src/index.ts'),
},
},
Expand Down
37 changes: 27 additions & 10 deletions packages/core/src/stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -765,34 +765,51 @@ export class Stack implements StackClient {
* Throws StackNotFoundError if neither metadata records nor bytes exist.
*/
async deleteAttachment(fileId: string): Promise<void> {
const metadataTypeId = `${SYSTEM_TYPES.ATTACHMENT}@1`;
const deletedRecordIds = this.adapter.deleteUnreferencedAttachmentRecords
? await this.adapter.deleteUnreferencedAttachmentRecords(fileId, metadataTypeId)
: await this.deleteUnreferencedAttachmentRecordsFallback(fileId, metadataTypeId);

if (!deletedRecordIds.length) {
try {
await this.adapter.getAttachment(fileId);
} catch {
throw new StackNotFoundError(`Attachment not found: "${fileId}"`);
}
}

await this.adapter.deleteAttachment(fileId);
}

/**
* Non-atomic fallback for adapters that don't implement
* deleteUnreferencedAttachmentRecords(): a concurrent associate() can
* race between the reference check below and the deletes it guards.
*/
private async deleteUnreferencedAttachmentRecordsFallback(
fileId: string,
metadataTypeId: string,
): Promise<string[]> {
const refResult = await this.query({ filter: { attachmentFileId: fileId }, limit: 1 });
if (refResult.records.length > 0) {
throw new StackConflictError('Attachment is still referenced by one or more records');
}

const metaResult = await this.query({
filter: {
typeId: `${SYSTEM_TYPES.ATTACHMENT}@1`,
typeId: metadataTypeId,
...(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);
return metaRecords.map((r) => r.id);
}

// -------------------------------------------------------
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/testing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,13 @@ export class MemoryAdapter implements StackAdapter {
const ids = Array.isArray(f.entityId) ? f.entityId : [f.entityId];
results = results.filter((r) => r.entityId !== undefined && ids.includes(r.entityId));
}
if (f.attachmentFileId) {
results = results.filter((r) =>
(r.associations ?? []).some(
(a) => a.kind === 'attachment' && a.fileId === f.attachmentFileId,
),
);
}

const limit = query.limit ?? 50;
const start = query.cursor ? Number(query.cursor) : 0;
Expand Down
14 changes: 14 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,20 @@ export interface StackRecordAdapter {
getType(id: TypeId): Promise<StackType | null>;
listTypes(): Promise<StackType[]>;

/**
* Atomically verify fileId is unreferenced by any record's attachment
* association, then hard-delete every record of `metadataTypeId` whose
* content.fileId matches it — all within a single adapter call, so
* nothing can add a new reference between the check and the delete.
* Returns the ids of the deleted metadata records (empty if none exist,
* e.g. bare bytes left by an interrupted upload). Throws
* StackConflictError if fileId is still referenced.
*
* Optional: Stack.deleteAttachment() falls back to a non-atomic
* check-then-act sequence for adapters that don't implement this.
*/
deleteUnreferencedAttachmentRecords?(fileId: FileId, metadataTypeId: TypeId): Promise<RecordId[]>;

// Lifecycle
flush?(): Promise<void>;
close?(): Promise<void>;
Expand Down
75 changes: 75 additions & 0 deletions packages/core/tests/stack.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -853,3 +853,78 @@ describe('putAttachment', () => {
expect(result.records[0].entityId).toBeUndefined();
});
});

// -------------------------------------------------------
// deleteAttachment
// -------------------------------------------------------

describe('deleteAttachment', () => {
test('throws StackConflictError when a record still references the file (fallback path)', async () => {
const data = new Uint8Array([1, 2, 3]);
const fileId = await stack.putAttachment(data, 'image/png');
const note = await stack.create(NOTE_V1, { text: 'hi' });
await stack.associate(note.id, {
kind: 'attachment',
label: 'cover',
fileId,
mimeType: 'image/png',
});

await expect(stack.deleteAttachment(fileId)).rejects.toThrow(StackConflictError);
});

test('deletes the _attachment@1 metadata record when unreferenced (fallback path)', async () => {
const data = new Uint8Array([1, 2, 3]);
const fileId = await stack.putAttachment(data, 'image/png');

await stack.deleteAttachment(fileId);

const result = await stack.query({ filter: { typeId: '_attachment@1' } });
expect(result.records).toHaveLength(0);
});

test('throws StackNotFoundError when neither metadata nor bytes exist', async () => {
class NoBytesAdapter extends MemoryAdapter {
async getAttachment(_fileId: string): Promise<Uint8Array> {
throw new Error('not found');
}
}
const noBytesStack = await Stack.create(
new NoBytesAdapter({ ownerEntityId: 'owner-123', timezone: 'UTC' }),
);

await expect(noBytesStack.deleteAttachment('nonexistent-file')).rejects.toThrow(
StackNotFoundError,
);
});

test('prefers the adapter atomic path over the fallback when the adapter implements it', async () => {
const calls: string[] = [];
class AtomicAdapter extends MemoryAdapter {
async deleteUnreferencedAttachmentRecords(
fileId: string,
metadataTypeId: string,
): Promise<string[]> {
calls.push('atomic');
const toDelete = [...this.records.values()].filter(
(r) =>
r.typeId === metadataTypeId && (r.content as Record<string, unknown>).fileId === fileId,
);
for (const r of toDelete) {
this.records.delete(r.id);
this.order.splice(this.order.indexOf(r.id), 1);
}
return toDelete.map((r) => r.id);
}
}
const atomicStack = await Stack.create(
new AtomicAdapter({ ownerEntityId: 'owner-123', timezone: 'UTC' }),
);
await atomicStack.defineType(NOTE_V1, 'Note', { text: { kind: 'text', required: true } });

const fileId = await atomicStack.putAttachment(new Uint8Array([9]), 'image/png');
await atomicStack.deleteAttachment(fileId);

expect(calls).toEqual(['atomic']);
});
});
1 change: 1 addition & 0 deletions packages/record-adapter-sqljs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
},
"dependencies": {
"@haverstack/core": "workspace:*",
"@haverstack/sqlite-shared": "workspace:*",
"sql.js": "^1.14.0"
},
"devDependencies": {
Expand Down
Loading
Loading