From d96bd644187641a8e709b39af98e8b3ea3c4c25d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 23 Jun 2026 13:45:06 +0000 Subject: [PATCH 1/2] Split StackAdapter into StackRecordAdapter and StackBlobAdapter StackAdapter is now a type alias for StackRecordAdapter & StackBlobAdapter. Use combineAdapters({ record, blob }) to compose different backends, e.g. SQLite records with S3 blobs. - StackRecordAdapter: capabilities, identity, records, associations, versions, types, lifecycle - StackBlobAdapter: putAttachment, getAttachment, deleteAttachment, lifecycle - combineAdapters() exported from @haverstack/core - DiskBlobAdapter extracted from SQLiteAdapter and exported from @haverstack/adapter-sqlite; SQLiteAdapter delegates its blob methods to it Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01FiDqP6DsUEgtxTaAUj62iE --- packages/adapter-sqlite/src/index.ts | 78 +++++++++++++++++++--------- packages/core/src/combine.ts | 49 +++++++++++++++++ packages/core/src/index.ts | 5 ++ packages/core/src/types.ts | 25 +++++++-- 4 files changed, 128 insertions(+), 29 deletions(-) create mode 100644 packages/core/src/combine.ts diff --git a/packages/adapter-sqlite/src/index.ts b/packages/adapter-sqlite/src/index.ts index a750bc6..c2fdd38 100644 --- a/packages/adapter-sqlite/src/index.ts +++ b/packages/adapter-sqlite/src/index.ts @@ -23,6 +23,7 @@ import { readFile, writeFile, unlink } from 'fs/promises'; import { join, dirname } from 'path'; import type { StackAdapter, + StackBlobAdapter, StackRecord, StackType, TypeId, @@ -31,6 +32,7 @@ import type { QueryResult, Association, AdapterCapabilities, + FileId, } from '@haverstack/core'; // ------------------------------------------------------- @@ -150,6 +152,45 @@ const assertFileId = (fileId: string): void => { } }; +// ------------------------------------------------------- +// DiskBlobAdapter +// ------------------------------------------------------- + +/** + * StackBlobAdapter that stores content-addressed blobs on the local filesystem. + * File IDs are SHA-256 hashes of the content; if a file with that hash already + * exists it is not overwritten (content-addressed deduplication). + */ +export class DiskBlobAdapter implements StackBlobAdapter { + constructor(private readonly dir: string) { + mkdirSync(dir, { recursive: true }); + } + + async putAttachment(data: Uint8Array): Promise { + const fileId = createHash('sha256').update(data).digest('hex'); + if (!existsSync(join(this.dir, fileId))) { + await writeFile(join(this.dir, fileId), data); + } + return fileId; + } + + async getAttachment(fileId: FileId): Promise { + assertFileId(fileId); + if (!existsSync(join(this.dir, fileId))) + throw new Error(`Attachment not found: "${fileId}"`); + return readFile(join(this.dir, fileId)); + } + + async deleteAttachment(fileId: FileId): Promise { + assertFileId(fileId); + try { + await unlink(join(this.dir, fileId)); + } catch { + // Non-fatal — file may already be gone. + } + } +} + // ------------------------------------------------------- // Row <-> domain object mapping // ------------------------------------------------------- @@ -459,14 +500,13 @@ export class SQLiteAdapter implements StackAdapter { timezone!: string; private db!: Database; - private readonly attachmentsDir: string; + private blob!: DiskBlobAdapter; private constructor( private readonly SQL: SqlJsStatic, private readonly path: string, - ) { - this.attachmentsDir = join(dirname(path), 'attachments'); - } + ) {} + /** * Initialize a new stack database. Fails if the file already exists — @@ -481,9 +521,9 @@ export class SQLiteAdapter implements StackAdapter { } const SQL = await initSqlJs(); const adapter = new SQLiteAdapter(SQL, opts.path); + adapter.blob = new DiskBlobAdapter(join(dirname(opts.path), 'attachments')); adapter.db = new SQL.Database(); adapter.db.run(SCHEMA_SQL); - mkdirSync(adapter.attachmentsDir, { recursive: true }); const now = Date.now(); adapter.db.run( `INSERT INTO records (id, type_id, created_at, updated_at, content, version) @@ -509,10 +549,10 @@ export class SQLiteAdapter implements StackAdapter { } const SQL = await initSqlJs(); const adapter = new SQLiteAdapter(SQL, opts.path); + adapter.blob = new DiskBlobAdapter(join(dirname(opts.path), 'attachments')); const fileBuffer = readFileSync(opts.path); adapter.db = new SQL.Database(fileBuffer); adapter.db.run(SCHEMA_SQL); - mkdirSync(adapter.attachmentsDir, { recursive: true }); adapter.readConfig(); return adapter; } @@ -758,31 +798,19 @@ export class SQLiteAdapter implements StackAdapter { } // ------------------------------------------------------- - // Attachments + // Attachments — delegated to DiskBlobAdapter // ------------------------------------------------------- - 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); - } - return fileId; + async putAttachment(data: Uint8Array): Promise { + return this.blob.putAttachment(data); } - async getAttachment(fileId: string): Promise { - assertFileId(fileId); - if (!existsSync(join(this.attachmentsDir, fileId))) - throw new Error(`Attachment not found: "${fileId}"`); - return readFile(join(this.attachmentsDir, fileId)); + async getAttachment(fileId: FileId): Promise { + return this.blob.getAttachment(fileId); } - async deleteAttachment(fileId: string): Promise { - assertFileId(fileId); - try { - await unlink(join(this.attachmentsDir, fileId)); - } catch { - // Non-fatal — file may already be gone. - } + async deleteAttachment(fileId: FileId): Promise { + return this.blob.deleteAttachment(fileId); } // ------------------------------------------------------- diff --git a/packages/core/src/combine.ts b/packages/core/src/combine.ts new file mode 100644 index 0000000..18487f9 --- /dev/null +++ b/packages/core/src/combine.ts @@ -0,0 +1,49 @@ +import type { StackAdapter, StackRecordAdapter, StackBlobAdapter } from './types.js'; + +/** + * Compose a StackRecordAdapter and a StackBlobAdapter into a single StackAdapter. + * Use this when you want different backends for records and blobs, e.g.: + * + * const adapter = combineAdapters({ record: sqliteAdapter, blob: s3Adapter }); + * const stack = await Stack.create(adapter); + */ +export function combineAdapters(parts: { + record: StackRecordAdapter; + blob: StackBlobAdapter; +}): StackAdapter { + return { + get capabilities() { return parts.record.capabilities; }, + get ownerEntityId() { return parts.record.ownerEntityId; }, + get timezone() { return parts.record.timezone; }, + + createRecord: (r) => parts.record.createRecord(r), + getRecord: (id) => parts.record.getRecord(id), + updateRecord: (id, changes) => parts.record.updateRecord(id, changes), + deleteRecord: (id, opts) => parts.record.deleteRecord(id, opts), + queryRecords: (q) => parts.record.queryRecords(q), + + associate: (id, assoc) => parts.record.associate(id, assoc), + dissociate: (id, assoc) => parts.record.dissociate(id, assoc), + + getVersions: (id) => parts.record.getVersions(id), + getVersion: (id, v) => parts.record.getVersion(id, v), + saveVersion: (id, v) => parts.record.saveVersion(id, v), + + saveType: (t) => parts.record.saveType(t), + getType: (id) => parts.record.getType(id), + listTypes: () => parts.record.listTypes(), + + putAttachment: (data) => parts.blob.putAttachment(data), + getAttachment: (id) => parts.blob.getAttachment(id), + deleteAttachment: (id) => parts.blob.deleteAttachment(id), + + async flush() { + await parts.record.flush?.(); + await parts.blob.flush?.(); + }, + async close() { + await parts.record.close?.(); + await parts.blob.close?.(); + }, + }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 934110a..64d8271 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -57,6 +57,8 @@ export type { MigrationFn, AdapterCapabilities, StackFeatures, + StackRecordAdapter, + StackBlobAdapter, StackAdapter, EntityContent, AppContent, @@ -68,6 +70,9 @@ export type { export { SYSTEM_TYPES } from './types.js'; +// Adapter composition +export { combineAdapters } from './combine.js'; + // Utilities export { generateId, crockford32Encode, crockford32Decode } from './id.js'; export { hashSchema, isCompatible, parseTypeId, buildTypeId } from './schema.js'; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 6141b84..3e4d46d 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -319,14 +319,14 @@ export type AdapterCapabilities = { export type StackFeatures = AdapterCapabilities; // ------------------------------------------------------- -// Adapter interface +// Adapter interfaces // ------------------------------------------------------- /** - * Every storage backend implements this interface. - * The Stack class is a thin orchestration layer on top. + * The record-storage half of an adapter. Handles structured data, queries, + * associations, versioning, type definitions, and stack identity. */ -export interface StackAdapter { +export interface StackRecordAdapter { readonly capabilities: AdapterCapabilities; /** Entity ID of the stack owner. Set during adapter initialization. */ @@ -355,6 +355,16 @@ export interface StackAdapter { getType(id: TypeId): Promise; listTypes(): Promise; + // Lifecycle + flush?(): Promise; + close?(): Promise; +} + +/** + * The blob-storage half of an adapter. Handles raw binary data only; + * attachment metadata lives on _attachment@1 records in the record adapter. + */ +export interface StackBlobAdapter { // Attachments — bytes storage only; metadata lives on _attachment@1 records putAttachment(data: Uint8Array): Promise; getAttachment(fileId: FileId): Promise; @@ -364,3 +374,10 @@ export interface StackAdapter { flush?(): Promise; close?(): Promise; } + +/** + * A complete adapter: record storage and blob storage combined. + * Pass this to Stack.create(). Build one with combineAdapters() when you + * want different backends for records and blobs (e.g. SQLite + S3). + */ +export type StackAdapter = StackRecordAdapter & StackBlobAdapter; From 9cac1f135c32f5936b00b7649eafe5c1e84462dc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 23 Jun 2026 17:01:56 +0000 Subject: [PATCH 2/2] Update docs for adapter split and config-as-record changes Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01FiDqP6DsUEgtxTaAUj62iE --- README.md | 7 ++- docs/spec.md | 63 ++++++++++++++------ packages/adapter-sqlite/src/index.ts | 4 +- packages/adapter-sqlite/tests/sqlite.test.ts | 12 ++-- packages/core/src/combine.ts | 12 +++- 5 files changed, 65 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 5fce332..2a83b5a 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,8 @@ Migration is **lazy** — records are migrated in memory on read, and committed | Server API | Hosted/shared stacks, permissions enforcement | | JSON files | Portable, human-readable, backup/export _(planned)_ | +The adapter interface is split into `StackRecordAdapter` (structured data) and `StackBlobAdapter` (binary files). Use `combineAdapters({ record, blob })` from `@haverstack/core` to compose different backends — for example, SQLite records with S3 blobs. `SQLiteAdapter` covers both out of the box for the common case. + --- ## Development @@ -175,8 +177,9 @@ packages/ core/ # @haverstack/core src/ index.ts # Public exports - types.ts # All type definitions + types.ts # All type definitions (StackRecordAdapter, StackBlobAdapter, StackAdapter, …) stack.ts # Stack class + combine.ts # combineAdapters() — compose record + blob adapters access.ts # Permission and grant checking id.ts # Crockford base-32 ID generation schema.ts # Schema hashing and type compatibility @@ -185,7 +188,7 @@ packages/ tests/ adapter-sqlite/ # @haverstack/adapter-sqlite src/ - index.ts # SQLiteAdapter + index.ts # SQLiteAdapter (StackAdapter) + DiskBlobAdapter (StackBlobAdapter) tests/ adapter-api/ # @haverstack/adapter-api src/ diff --git a/docs/spec.md b/docs/spec.md index a718975..a89776d 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -12,7 +12,7 @@ A **Stack** is a structured, portable personal or organizational data store. It ## Stack initialization -A Stack is created via an async factory that reads config from the adapter — the adapter is the single source of truth for stack-level configuration. +A Stack is created via an async factory that reads identity and timezone from the adapter. ```ts // First run — create a new database with initial config @@ -25,27 +25,19 @@ const adapter = await SQLiteAdapter.initialize({ // Subsequent runs — open an existing database const adapter = await SQLiteAdapter.open({ path: './my-stack.db' }); -// Always the same — reads config from the adapter +// Always the same — reads identity and timezone from the adapter const stack = await Stack.create(adapter); -stack.ownerEntityId; // read from adapter config -stack.timezone; // read from adapter config +stack.ownerEntityId; // from adapter.ownerEntityId +stack.timezone; // from adapter.timezone ``` `SQLiteAdapter.initialize()` fails if the file already exists. `SQLiteAdapter.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. -**Stack config** is stored in a `stack_config` key/value table in the adapter. Current keys: +**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. -| Key | Description | -| ----------- | ---------------------------------------------- | -| `entity_id` | The owner Entity's record ID | -| `timezone` | IANA timezone string e.g. `"America/New_York"` | -| `version` | Stack schema version | - -The `timezone` field is a property of the stack owner, not the app — an app running against two different stacks should display dates in each stack's configured timezone. - -**For the API adapter**, config is not stored locally. The values are sourced from the discovery endpoint (`GET /.well-known/stack`) when the adapter is opened and cached for the session. `setConfig` is not supported and will throw — server configuration is managed server-side. +**For the API adapter**, identity values are sourced from the discovery endpoint (`GET /.well-known/stack`) when the adapter is opened and cached for the session as adapter properties. --- @@ -238,7 +230,7 @@ function isCompatible( Apps that care about semantics filter by exact `typeId`. Apps that want flexibility use `isCompatible()`. -**System types** (reserved, library-defined): `_entity@1`, `_app@1`, `_group@1`, `_grant@1`, `_attachment@1`. System types follow the same versioned ID format as user-defined types and can evolve using the same migration mechanism. All five are pre-seeded when a Stack is created via `Stack.create()` — they are always available without any setup by the caller. +**System types** (reserved, library-defined): `_config@1`, `_entity@1`, `_app@1`, `_group@1`, `_grant@1`, `_attachment@1`. System types follow the same versioned ID format as user-defined types and can evolve using the same migration mechanism. All six are pre-seeded when a Stack is created via `Stack.create()` — they are always available without any setup by the caller. ### Type migrations @@ -398,7 +390,40 @@ type RecordVersion = { ## Adapters -The library exposes a single interface regardless of backend. Three adapters are planned: +### Interface split + +The adapter contract is split into two focused interfaces that are composed into a single `StackAdapter`: + +**`StackRecordAdapter`** — structured storage: capabilities, stack identity (`ownerEntityId`, `timezone`), all record/association/version/type methods, and optional lifecycle hooks (`flush`, `close`). + +**`StackBlobAdapter`** — binary storage: `putAttachment`, `getAttachment`, `deleteAttachment`, and optional lifecycle hooks. + +```ts +type StackAdapter = StackRecordAdapter & StackBlobAdapter; +``` + +Use `combineAdapters()` from `@haverstack/core` when you want different backends for records and blobs — for example, SQLite records with S3 blob storage: + +```ts +import { combineAdapters } from '@haverstack/core'; +import { SQLiteAdapter, DiskBlobAdapter } from '@haverstack/adapter-sqlite'; + +const record = await SQLiteAdapter.initialize({ path, entityId, timezone }); +const blob = new S3BlobAdapter(bucketConfig); // hypothetical +const adapter = combineAdapters({ record, blob }); +const stack = await Stack.create(adapter); +``` + +When you don't need to mix backends, just use the adapter directly — `SQLiteAdapter` already implements the full `StackAdapter` and internally delegates blob storage to a `DiskBlobAdapter`: + +```ts +const adapter = await SQLiteAdapter.initialize({ path, entityId, timezone }); +const stack = await Stack.create(adapter); // no combineAdapters needed +``` + +`DiskBlobAdapter` is exported from `@haverstack/adapter-sqlite` for use in custom compositions. + +### Adapter backends | Adapter | Use case | Notes | | -------------- | --------------------------------------- | ------------------------------------------------------- | @@ -550,7 +575,7 @@ The API adapter speaks REST over HTTP with JSON bodies and standard status codes ### Discovery -A client hits this endpoint first to understand the server's identity and capabilities. The response also supplies the stack config values (`entity_id`, `timezone`, `version`) that `Stack.create()` reads from the adapter — these are cached locally for the session rather than stored on the client. +A client hits this endpoint first to understand the server's identity and capabilities. The response supplies `entityId` and `timezone`, which the `APIAdapter` caches as `ownerEntityId` and `timezone` properties for the session. ``` GET /.well-known/stack @@ -721,8 +746,8 @@ The SDK's `Stack.putAttachment()` and `ScopedStack.putAttachment()` perform both **Download:** Two optional query parameters control the response metadata and, when both are supplied, allow the server to skip the `_attachment@1` database lookup entirely: -| Parameter | Effect | -| -------------- | ---------------------------------------------------------------------------------------------------- | +| Parameter | Effect | +| -------------- | ------------------------------------------------------------------------------------------------------------------------------ | | `?contentType` | Sets `Content-Type` on the response. Dangerous types (HTML, SVG, JS, XML) are forced to `application/octet-stream` regardless. | | `?filename` | Sets the filename in `Content-Disposition`. Also infers `Content-Type` from the file extension when `?contentType` is omitted. | diff --git a/packages/adapter-sqlite/src/index.ts b/packages/adapter-sqlite/src/index.ts index c2fdd38..074983d 100644 --- a/packages/adapter-sqlite/src/index.ts +++ b/packages/adapter-sqlite/src/index.ts @@ -176,8 +176,7 @@ export class DiskBlobAdapter implements StackBlobAdapter { async getAttachment(fileId: FileId): Promise { assertFileId(fileId); - if (!existsSync(join(this.dir, fileId))) - throw new Error(`Attachment not found: "${fileId}"`); + if (!existsSync(join(this.dir, fileId))) throw new Error(`Attachment not found: "${fileId}"`); return readFile(join(this.dir, fileId)); } @@ -507,7 +506,6 @@ export class SQLiteAdapter implements StackAdapter { private readonly path: string, ) {} - /** * Initialize a new stack database. Fails if the file already exists — * use open() for existing databases. diff --git a/packages/adapter-sqlite/tests/sqlite.test.ts b/packages/adapter-sqlite/tests/sqlite.test.ts index e46b4dd..7bf7838 100644 --- a/packages/adapter-sqlite/tests/sqlite.test.ts +++ b/packages/adapter-sqlite/tests/sqlite.test.ts @@ -106,12 +106,12 @@ describe('open', () => { }); }); - test('preserves ownerEntityId and timezone across reopen', async () => { - await initAdapter({ entityId: 'owner-abc', timezone: 'Europe/London' }); - const adapter = await SQLiteAdapter.open({ path: dbPath }); - expect(adapter.ownerEntityId).toBe('owner-abc'); - expect(adapter.timezone).toBe('Europe/London'); - }); +test('preserves ownerEntityId and timezone across reopen', async () => { + await initAdapter({ entityId: 'owner-abc', timezone: 'Europe/London' }); + const adapter = await SQLiteAdapter.open({ path: dbPath }); + expect(adapter.ownerEntityId).toBe('owner-abc'); + expect(adapter.timezone).toBe('Europe/London'); +}); // ------------------------------------------------------- // Types diff --git a/packages/core/src/combine.ts b/packages/core/src/combine.ts index 18487f9..4e86305 100644 --- a/packages/core/src/combine.ts +++ b/packages/core/src/combine.ts @@ -12,9 +12,15 @@ export function combineAdapters(parts: { blob: StackBlobAdapter; }): StackAdapter { return { - get capabilities() { return parts.record.capabilities; }, - get ownerEntityId() { return parts.record.ownerEntityId; }, - get timezone() { return parts.record.timezone; }, + get capabilities() { + return parts.record.capabilities; + }, + get ownerEntityId() { + return parts.record.ownerEntityId; + }, + get timezone() { + return parts.record.timezone; + }, createRecord: (r) => parts.record.createRecord(r), getRecord: (id) => parts.record.getRecord(id),