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
52 changes: 35 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,13 @@ The key idea: apps only talk to the Haverstack library. They don't know or care

This is a monorepo. Packages are published to npm under the `@haverstack` scope.

| Package | Description |
| --------------------------------------------------------- | ----------------------------------------------------- |
| [`@haverstack/core`](./packages/core) | Stack class, types, schema, validation, ID generation |
| [`@haverstack/adapter-sqlite`](./packages/adapter-sqlite) | SQLite storage adapter |
| [`@haverstack/adapter-api`](./packages/adapter-api) | HTTP adapter for remote stack servers |
| Package | Description |
| --------------------------------------------------------------------- | ----------------------------------------------------- |
| [`@haverstack/core`](./packages/core) | Stack class, types, schema, validation, ID generation |
| [`@haverstack/adapter-local`](./packages/adapter-local) | Local adapter (SQLite + disk) — the common case |
| [`@haverstack/record-adapter-sqljs`](./packages/record-adapter-sqljs) | sql.js (SQLite/WASM) `StackRecordAdapter` |
| [`@haverstack/blob-adapter-disk`](./packages/blob-adapter-disk) | Disk filesystem `StackBlobAdapter` |
| [`@haverstack/adapter-api`](./packages/adapter-api) | HTTP adapter for remote stack servers |

Planned:

Expand All @@ -38,17 +40,17 @@ Planned:

```ts
import { Stack } from '@haverstack/core';
import { SQLiteAdapter } from '@haverstack/adapter-sqlite';
import { LocalAdapter } from '@haverstack/adapter-local';

// First run — initialize a new stack
const adapter = await SQLiteAdapter.initialize({
const adapter = await LocalAdapter.initialize({
path: './my-stack.db',
entityId: 'my-entity-id',
timezone: 'America/New_York',
});

// Subsequent runs — open the existing stack
// const adapter = await SQLiteAdapter.open({ path: './my-stack.db' });
// const adapter = await LocalAdapter.open({ path: './my-stack.db' });

const stack = await Stack.create(adapter);

Expand Down Expand Up @@ -140,13 +142,21 @@ Migration is **lazy** — records are migrated in memory on read, and committed

### Adapters

| Adapter | Use case |
| ---------- | --------------------------------------------------- |
| SQLite | Local app storage, full query support, FTS |
| Server API | Hosted/shared stacks, permissions enforcement |
| JSON files | Portable, human-readable, backup/export _(planned)_ |
The adapter interface is split into `StackRecordAdapter` (structured records) and `StackBlobAdapter` (binary files). Packages follow a naming convention that makes the type clear:

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.
- **`adapter-*`** — full `StackAdapter` (convenience packages that cover both halves)
- **`record-adapter-*`** — `StackRecordAdapter` only
- **`blob-adapter-*`** — `StackBlobAdapter` only

| Package | Type | Use case |
| ---------------------- | ------ | ----------------------------------------------- |
| `adapter-local` | full | Local app storage — SQLite records + disk blobs |
| `record-adapter-sqljs` | record | sql.js records, full query support, FTS |
| `blob-adapter-disk` | blob | Content-addressed blobs on the local filesystem |
| `adapter-api` | full | Hosted/shared stacks via HTTP |
| `adapter-json` | full | Portable JSON files _(planned)_ |

Use `combineAdapters({ record, blob })` from `@haverstack/core` to compose a record adapter with a different blob backend — for example, `SQLiteRecordAdapter` with a future `S3BlobAdapter`. `adapter-local` wraps this pattern for the common case.

---

Expand Down Expand Up @@ -186,13 +196,21 @@ packages/
validate.ts # Content validation
testing.ts # MemoryAdapter test helper (@haverstack/core/testing)
tests/
adapter-sqlite/ # @haverstack/adapter-sqlite
adapter-local/ # @haverstack/adapter-local
src/
index.ts # LocalAdapter (StackAdapter) — wraps record + blob adapters below
tests/
record-adapter-sqljs/ # @haverstack/record-adapter-sqljs
src/
index.ts # SQLiteRecordAdapter (StackRecordAdapter) + token management
tests/
blob-adapter-disk/ # @haverstack/blob-adapter-disk
src/
index.ts # SQLiteAdapter (StackAdapter) + DiskBlobAdapter (StackBlobAdapter)
index.ts # DiskBlobAdapter (StackBlobAdapter)
tests/
adapter-api/ # @haverstack/adapter-api
src/
index.ts # APIAdapter
index.ts # APIAdapter (StackAdapter)
tests/
```

Expand Down
50 changes: 27 additions & 23 deletions docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,22 +16,22 @@ A Stack is created via an async factory that reads identity and timezone from th

```ts
// First run — create a new database with initial config
const adapter = await SQLiteAdapter.initialize({
const adapter = await LocalAdapter.initialize({
path: './my-stack.db',
entityId: 'abc123', // required — owner entity ID
timezone: 'America/New_York', // required — IANA timezone string
});

// Subsequent runs — open an existing database
const adapter = await SQLiteAdapter.open({ path: './my-stack.db' });
const adapter = await LocalAdapter.open({ path: './my-stack.db' });

// Always the same — reads identity and timezone from the adapter
const stack = await Stack.create(adapter);
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.
`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.

Expand Down Expand Up @@ -402,35 +402,39 @@ The adapter contract is split into two focused interfaces that are composed into
type StackAdapter = StackRecordAdapter & StackBlobAdapter;
```

### Package naming convention

Packages follow a naming convention that makes the adapter type discoverable:

- **`adapter-*`** — full `StackAdapter` (convenience packages covering both halves)
- **`record-adapter-*`** — `StackRecordAdapter` only
- **`blob-adapter-*`** — `StackBlobAdapter` only

### Adapter backends

| Package | Type | Use case |
| ---------------------- | ------ | --------------------------------------- |
| `adapter-local` | full | Local app storage — SQLite + disk blobs |
| `record-adapter-sqljs` | record | sql.js records, FTS, full query support |
| `blob-adapter-disk` | blob | Content-addressed blobs on disk |
| `adapter-api` | full | Hosted/shared stacks via HTTP |
| `adapter-json` | full | Portable JSON files _(planned)_ |

`adapter-local` is the batteries-included package for the common local case. It wraps `SQLiteRecordAdapter` and `DiskBlobAdapter` and stores attachments in an `attachments/` subdirectory next to the database file.

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';
import { SQLiteRecordAdapter } from '@haverstack/record-adapter-sqljs';
import { S3BlobAdapter } from '@haverstack/blob-adapter-s3'; // hypothetical

const record = await SQLiteAdapter.initialize({ path, entityId, timezone });
const blob = new S3BlobAdapter(bucketConfig); // hypothetical
const record = await SQLiteRecordAdapter.initialize({ path, entityId, timezone });
const blob = new S3BlobAdapter(bucketConfig);
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 |
| -------------- | --------------------------------------- | ------------------------------------------------------- |
| **JSON files** | Portable, human-readable, backup/export | Slow queries (O(n) scan); may maintain an `_index.json` |
| **SQLite** | Local app storage, fast queries | Indexes associations, parentId, appId, etc. |
| **Server API** | Hosted/shared stacks | Enforces permissions and app identity |

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

---
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@
"format": "prettier --write .",
"format:check": "prettier --check .",
"publish:core": "pnpm --filter @haverstack/core publish --access public",
"publish:sqlite": "pnpm --filter @haverstack/adapter-sqlite publish --access public",
"publish:adapter-local": "pnpm --filter @haverstack/adapter-local publish --access public",
"publish:record-adapter-sqljs": "pnpm --filter @haverstack/record-adapter-sqljs publish --access public",
"publish:blob-adapter-disk": "pnpm --filter @haverstack/blob-adapter-disk publish --access public",
"publish:api": "pnpm --filter @haverstack/adapter-api publish --access public",
"publish:wire-types": "pnpm --filter @haverstack/wire-types publish --access public"
},
Expand Down
2 changes: 1 addition & 1 deletion packages/adapter-api/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@haverstack/adapter-api",
"version": "0.5.0",
"version": "0.6.0",
"description": "Remote server adapter for Haverstack",
"type": "module",
"exports": {
Expand Down
48 changes: 48 additions & 0 deletions packages/adapter-local/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
{
"name": "@haverstack/adapter-local",
"version": "0.6.0",
"description": "Local (SQLite + disk) stack adapter for Haverstack",
"type": "module",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"repository": {
"type": "git",
"url": "https://github.com/haverstack/core",
"directory": "packages/adapter-local"
},
"license": "CC0-1.0",
"keywords": [
"haverstack",
"sqlite",
"local",
"adapter",
"personal data",
"storage"
],
"scripts": {
"prepublishOnly": "pnpm run build",
"build": "tsc -p tsconfig.build.json",
"test": "vitest run",
"typecheck": "tsc --noEmit",
"lint": "eslint src tests"
},
"dependencies": {
"@haverstack/core": "workspace:*",
"@haverstack/record-adapter-sqljs": "workspace:*",
"@haverstack/blob-adapter-disk": "workspace:*"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.5.0",
"vitest": "^2.0.0"
}
}
Loading
Loading