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
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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/
Expand Down
63 changes: 44 additions & 19 deletions docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.

---

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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 |
| -------------- | --------------------------------------- | ------------------------------------------------------- |
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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. |

Expand Down
76 changes: 51 additions & 25 deletions packages/adapter-sqlite/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { readFile, writeFile, unlink } from 'fs/promises';
import { join, dirname } from 'path';
import type {
StackAdapter,
StackBlobAdapter,
StackRecord,
StackType,
TypeId,
Expand All @@ -31,6 +32,7 @@ import type {
QueryResult,
Association,
AdapterCapabilities,
FileId,
} from '@haverstack/core';

// -------------------------------------------------------
Expand Down Expand Up @@ -150,6 +152,44 @@ 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<FileId> {
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<Uint8Array> {
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<void> {
assertFileId(fileId);
try {
await unlink(join(this.dir, fileId));
} catch {
// Non-fatal — file may already be gone.
}
}
}

// -------------------------------------------------------
// Row <-> domain object mapping
// -------------------------------------------------------
Expand Down Expand Up @@ -459,14 +499,12 @@ 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 —
Expand All @@ -481,9 +519,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)
Expand All @@ -509,10 +547,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;
}
Expand Down Expand Up @@ -758,31 +796,19 @@ export class SQLiteAdapter implements StackAdapter {
}

// -------------------------------------------------------
// Attachments
// Attachments — delegated to DiskBlobAdapter
// -------------------------------------------------------

async putAttachment(data: Uint8Array): Promise<string> {
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<FileId> {
return this.blob.putAttachment(data);
}

async getAttachment(fileId: string): Promise<Uint8Array> {
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<Uint8Array> {
return this.blob.getAttachment(fileId);
}

async deleteAttachment(fileId: string): Promise<void> {
assertFileId(fileId);
try {
await unlink(join(this.attachmentsDir, fileId));
} catch {
// Non-fatal — file may already be gone.
}
async deleteAttachment(fileId: FileId): Promise<void> {
return this.blob.deleteAttachment(fileId);
}

// -------------------------------------------------------
Expand Down
12 changes: 6 additions & 6 deletions packages/adapter-sqlite/tests/sqlite.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
55 changes: 55 additions & 0 deletions packages/core/src/combine.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
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?.();
},
};
}
5 changes: 5 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ export type {
MigrationFn,
AdapterCapabilities,
StackFeatures,
StackRecordAdapter,
StackBlobAdapter,
StackAdapter,
EntityContent,
AppContent,
Expand All @@ -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';
Expand Down
Loading
Loading