diff --git a/README.md b/README.md index 2a83b5a..d07efcd 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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); @@ -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. --- @@ -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/ ``` diff --git a/docs/spec.md b/docs/spec.md index a89776d..9285665 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -16,14 +16,14 @@ 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); @@ -31,7 +31,7 @@ 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. @@ -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. --- diff --git a/package.json b/package.json index 0126756..14c89f1 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/packages/adapter-api/package.json b/packages/adapter-api/package.json index fbc555a..f7bb108 100644 --- a/packages/adapter-api/package.json +++ b/packages/adapter-api/package.json @@ -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": { diff --git a/packages/adapter-local/package.json b/packages/adapter-local/package.json new file mode 100644 index 0000000..6fd9878 --- /dev/null +++ b/packages/adapter-local/package.json @@ -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" + } +} diff --git a/packages/adapter-local/src/index.ts b/packages/adapter-local/src/index.ts new file mode 100644 index 0000000..32a25cb --- /dev/null +++ b/packages/adapter-local/src/index.ts @@ -0,0 +1,219 @@ +/** + * Haverstack — Local Adapter + * ------------------------------------------------------- + * Convenience StackAdapter that combines SQLiteRecordAdapter + * (records, types, versions, associations, tokens) with + * DiskBlobAdapter (binary attachments stored next to the DB). + * + * For most local use cases this is the only package you need. + * If you want a different blob backend (e.g. S3), import + * SQLiteRecordAdapter and DiskBlobAdapter separately and + * compose them with combineAdapters() from @haverstack/core. + */ + +import { dirname, join } from 'path'; +import type { + StackAdapter, + StackRecord, + StackType, + TypeId, + RecordVersion, + StackQuery, + QueryResult, + Association, + AdapterCapabilities, + RecordId, + FileId, +} from '@haverstack/core'; +import { SQLiteRecordAdapter } from '@haverstack/record-adapter-sqljs'; +import { DiskBlobAdapter } from '@haverstack/blob-adapter-disk'; +import type { StackBlobAdapter } from '@haverstack/core'; + +export { SQLiteRecordAdapter } from '@haverstack/record-adapter-sqljs'; +export type { + SQLiteRecordInitializeOptions, + SQLiteRecordOpenOptions, + TokenInfo, +} from '@haverstack/record-adapter-sqljs'; +export { DiskBlobAdapter } from '@haverstack/blob-adapter-disk'; + +// ------------------------------------------------------- +// Option types +// ------------------------------------------------------- + +export type LocalInitializeOptions = { + /** Absolute path to the .db file. Must not already exist. */ + path: string; + /** IANA timezone string e.g. "America/New_York". */ + timezone: string; + /** Entity ID of the stack owner. */ + entityId: string; +}; + +export type LocalOpenOptions = { + /** Absolute path to an existing .db file. */ + path: string; +}; + +// ------------------------------------------------------- +// LocalAdapter +// ------------------------------------------------------- + +/** + * Full StackAdapter backed by SQLite (records) and the local filesystem (blobs). + * Also exposes token management methods for server implementations. + */ +export class LocalAdapter implements StackAdapter { + private constructor( + private readonly record: SQLiteRecordAdapter, + private readonly blob: StackBlobAdapter, + ) {} + + /** + * Initialize a new local stack. Fails if the database already exists — + * use open() for existing stacks. + */ + static async initialize(opts: LocalInitializeOptions): Promise { + const record = await SQLiteRecordAdapter.initialize({ + path: opts.path, + entityId: opts.entityId, + timezone: opts.timezone, + }); + const blob = new DiskBlobAdapter(join(dirname(opts.path), 'attachments')); + return new LocalAdapter(record, blob); + } + + /** + * Open an existing local stack. Fails if the database does not exist — + * use initialize() for new stacks. + */ + static async open(opts: LocalOpenOptions): Promise { + const record = await SQLiteRecordAdapter.open({ path: opts.path }); + const blob = new DiskBlobAdapter(join(dirname(opts.path), 'attachments')); + return new LocalAdapter(record, blob); + } + + // ------------------------------------------------------- + // StackRecordAdapter + // ------------------------------------------------------- + + get capabilities(): AdapterCapabilities { + return this.record.capabilities; + } + + get ownerEntityId(): string { + return this.record.ownerEntityId; + } + + get timezone(): string { + return this.record.timezone; + } + + async createRecord(record: StackRecord): Promise { + return this.record.createRecord(record); + } + + async getRecord(id: RecordId): Promise { + return this.record.getRecord(id); + } + + async updateRecord(id: RecordId, changes: Partial): Promise { + return this.record.updateRecord(id, changes); + } + + async deleteRecord(id: RecordId, opts?: { hard?: boolean }): Promise { + return this.record.deleteRecord(id, opts); + } + + async queryRecords(query: StackQuery): Promise { + return this.record.queryRecords(query); + } + + async associate(id: RecordId, association: Association): Promise { + return this.record.associate(id, association); + } + + async dissociate(id: RecordId, association: Association): Promise { + return this.record.dissociate(id, association); + } + + async getVersions(id: RecordId): Promise { + return this.record.getVersions(id); + } + + async getVersion(id: RecordId, version: number): Promise { + return this.record.getVersion(id, version); + } + + async saveVersion(id: RecordId, version: RecordVersion): Promise { + return this.record.saveVersion(id, version); + } + + async saveType(type: StackType): Promise { + return this.record.saveType(type); + } + + async getType(id: TypeId): Promise { + return this.record.getType(id); + } + + async listTypes(): Promise { + return this.record.listTypes(); + } + + // ------------------------------------------------------- + // StackBlobAdapter + // ------------------------------------------------------- + + async putAttachment(data: Uint8Array): Promise { + return this.blob.putAttachment(data); + } + + async getAttachment(fileId: FileId): Promise { + return this.blob.getAttachment(fileId); + } + + async deleteAttachment(fileId: FileId): Promise { + return this.blob.deleteAttachment(fileId); + } + + // ------------------------------------------------------- + // Tokens (SQLite-specific extras for server implementations) + // ------------------------------------------------------- + + async createToken( + entityId: string, + opts?: { label?: string; expiresAt?: Date }, + ): Promise<{ id: string; token: string }> { + return this.record.createToken(entityId, opts); + } + + async lookupToken(token: string): Promise<{ entityId: string } | null> { + return this.record.lookupToken(token); + } + + async listTokens(): Promise>> { + return this.record.listTokens(); + } + + async revokeToken(id: string): Promise { + return this.record.revokeToken(id); + } + + // ------------------------------------------------------- + // Lifecycle + // ------------------------------------------------------- + + async flush(): Promise { + await this.record.flush?.(); + await this.blob.flush?.(); + } + + async close(): Promise { + await this.record.close?.(); + await this.blob.close?.(); + } +} + +// Also export combineAdapters for users who want to compose their own adapters +export { combineAdapters } from '@haverstack/core'; diff --git a/packages/adapter-local/tests/local.test.ts b/packages/adapter-local/tests/local.test.ts new file mode 100644 index 0000000..ea98147 --- /dev/null +++ b/packages/adapter-local/tests/local.test.ts @@ -0,0 +1,164 @@ +import { describe, test, expect, beforeEach, afterEach } from 'vitest'; +import { mkdirSync, rmSync, existsSync, readdirSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { LocalAdapter } from '../src/index.js'; +import type { StackRecord } from '@haverstack/core'; + +let testDir: string; +let dbPath: string; + +beforeEach(() => { + testDir = join(tmpdir(), `local-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(testDir, { recursive: true }); + dbPath = join(testDir, 'test.db'); +}); + +afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); +}); + +const initAdapter = (opts?: { timezone?: string; entityId?: string }) => + LocalAdapter.initialize({ + path: dbPath, + entityId: opts?.entityId ?? 'entity-123', + timezone: opts?.timezone ?? 'America/New_York', + }); + +const makeRecord = (overrides: Partial = {}): StackRecord => ({ + id: `rec-${Math.random().toString(36).slice(2)}`, + typeId: 'com.example.test/note@1', + createdAt: new Date(), + updatedAt: new Date(), + content: { text: 'Hello world' }, + version: 1, + ...overrides, +}); + +// ------------------------------------------------------- +// initialize / open +// ------------------------------------------------------- + +describe('initialize', () => { + test('creates a new database file', async () => { + await initAdapter(); + expect(existsSync(dbPath)).toBe(true); + }); + + test('creates an attachments directory next to the database', async () => { + await initAdapter(); + expect(existsSync(join(testDir, 'attachments'))).toBe(true); + }); + + test('exposes ownerEntityId', async () => { + const adapter = await initAdapter({ entityId: 'owner-abc' }); + expect(adapter.ownerEntityId).toBe('owner-abc'); + }); + + test('exposes timezone', async () => { + const adapter = await initAdapter({ timezone: 'Europe/London' }); + expect(adapter.timezone).toBe('Europe/London'); + }); + + test('throws if database already exists', async () => { + await initAdapter(); + await expect(initAdapter()).rejects.toThrow(/already exists/); + }); +}); + +describe('open', () => { + test('opens an existing stack', async () => { + await initAdapter({ entityId: 'owner-abc' }); + const adapter = await LocalAdapter.open({ path: dbPath }); + expect(adapter.ownerEntityId).toBe('owner-abc'); + }); + + test('throws if database does not exist', async () => { + await expect(LocalAdapter.open({ path: join(testDir, 'nonexistent.db') })).rejects.toThrow( + /no database found/, + ); + }); + + test('data persists across adapter instances', async () => { + const adapter1 = await initAdapter(); + const record = makeRecord({ id: 'persist-test' }); + await adapter1.createRecord(record); + + const adapter2 = await LocalAdapter.open({ path: dbPath }); + expect(await adapter2.getRecord('persist-test')).not.toBeNull(); + }); + + test('preserves ownerEntityId and timezone across reopen', async () => { + await initAdapter({ entityId: 'owner-abc', timezone: 'Europe/London' }); + const adapter = await LocalAdapter.open({ path: dbPath }); + expect(adapter.ownerEntityId).toBe('owner-abc'); + expect(adapter.timezone).toBe('Europe/London'); + }); +}); + +// ------------------------------------------------------- +// Blob operations through LocalAdapter +// ------------------------------------------------------- + +describe('attachments', () => { + test('putAttachment returns a SHA-256 fileId', async () => { + const adapter = await initAdapter(); + const fileId = await adapter.putAttachment(Buffer.from('hello')); + expect(fileId).toMatch(/^[0-9a-f]{64}$/); + }); + + test('getAttachment returns stored data', async () => { + const adapter = await initAdapter(); + const data = Buffer.from('hello attachment'); + const fileId = await adapter.putAttachment(data); + const retrieved = await adapter.getAttachment(fileId); + expect((retrieved as Buffer).toString()).toBe('hello attachment'); + }); + + test('attachment file is stored in the attachments directory', async () => { + const adapter = await initAdapter(); + const fileId = await adapter.putAttachment(Buffer.from('test')); + const attachmentsDir = join(testDir, 'attachments'); + expect(readdirSync(attachmentsDir)).toContain(fileId); + }); + + test('deleteAttachment removes the file', async () => { + const adapter = await initAdapter(); + const fileId = await adapter.putAttachment(Buffer.from('gone')); + await adapter.deleteAttachment(fileId); + await expect(adapter.getAttachment(fileId)).rejects.toThrow(); + }); +}); + +// ------------------------------------------------------- +// Token operations through LocalAdapter +// ------------------------------------------------------- + +describe('tokens', () => { + test('createToken and lookupToken roundtrip', async () => { + const adapter = await initAdapter(); + const { token } = await adapter.createToken('entity-abc'); + const result = await adapter.lookupToken(token); + expect(result?.entityId).toBe('entity-abc'); + }); + + test('lookupToken returns null for invalid token', async () => { + const adapter = await initAdapter(); + expect(await adapter.lookupToken('bogus')).toBeNull(); + }); + + test('revokeToken invalidates the token', async () => { + const adapter = await initAdapter(); + const { id, token } = await adapter.createToken('entity-abc'); + await adapter.revokeToken(id); + expect(await adapter.lookupToken(token)).toBeNull(); + }); + + test('listTokens returns created tokens', async () => { + const adapter = await initAdapter(); + await adapter.createToken('entity-a', { label: 'Token A' }); + await adapter.createToken('entity-b', { label: 'Token B' }); + const tokens = await adapter.listTokens(); + expect(tokens.length).toBe(2); + }); +}); diff --git a/packages/adapter-sqlite/tsconfig.build.json b/packages/adapter-local/tsconfig.build.json similarity index 100% rename from packages/adapter-sqlite/tsconfig.build.json rename to packages/adapter-local/tsconfig.build.json diff --git a/packages/adapter-sqlite/tsconfig.json b/packages/adapter-local/tsconfig.json similarity index 100% rename from packages/adapter-sqlite/tsconfig.json rename to packages/adapter-local/tsconfig.json diff --git a/packages/adapter-sqlite/vitest.config.ts b/packages/adapter-local/vitest.config.ts similarity index 53% rename from packages/adapter-sqlite/vitest.config.ts rename to packages/adapter-local/vitest.config.ts index e0ec2fb..97fad83 100644 --- a/packages/adapter-sqlite/vitest.config.ts +++ b/packages/adapter-local/vitest.config.ts @@ -5,6 +5,11 @@ export default defineConfig({ resolve: { alias: { '@haverstack/core': resolve(__dirname, '../core/src/index.ts'), + '@haverstack/record-adapter-sqljs': resolve( + __dirname, + '../record-adapter-sqljs/src/index.ts', + ), + '@haverstack/blob-adapter-disk': resolve(__dirname, '../blob-adapter-disk/src/index.ts'), }, }, test: { diff --git a/packages/adapter-sqlite/README.md b/packages/adapter-sqlite/README.md deleted file mode 100644 index 97783ee..0000000 --- a/packages/adapter-sqlite/README.md +++ /dev/null @@ -1,77 +0,0 @@ -# @haverstack/adapter-sqlite - -SQLite storage adapter for [Haverstack](https://www.npmjs.com/package/@haverstack/core). - -Implements the `StackAdapter` interface using [sql.js](https://github.com/sql-js/sql.js). Runs in Node.js without native compilation. The database is held in memory and flushed to disk after every write. - -> **Status:** Early development. APIs are unstable. - -## Installation - -```sh -npm install @haverstack/adapter-sqlite @haverstack/core -``` - -## Usage - -```ts -import { Stack } from '@haverstack/core'; -import { SQLiteAdapter } from '@haverstack/adapter-sqlite'; - -// First run — create a new database -const adapter = await SQLiteAdapter.initialize({ - path: './my-stack.db', - entityId: 'my-entity-id', - timezone: 'America/New_York', -}); - -// Subsequent runs — open the existing database -// const adapter = await SQLiteAdapter.open({ path: './my-stack.db' }); - -const stack = await Stack.create(adapter); - -// ... use the stack (see @haverstack/core for the full API) - -await stack.flush(); -await stack.close(); -``` - -## API - -### `SQLiteAdapter.initialize(opts)` - -Creates a new database at `opts.path`. Throws if the file already exists. - -| Option | Type | Description | -| ---------- | -------- | ---------------------------------------------- | -| `path` | `string` | Absolute path to the `.db` file | -| `entityId` | `string` | Entity ID of the stack owner | -| `timezone` | `string` | IANA timezone string (e.g. `America/New_York`) | - -### `SQLiteAdapter.open(opts)` - -Opens an existing database at `opts.path`. Throws if the file does not exist. - -| Option | Type | Description | -| ------ | -------- | --------------------------------------- | -| `path` | `string` | Absolute path to an existing `.db` file | - -## Storage layout - -Given a database at `./my-stack.db`, attachments are stored in `./attachments/`. - -## Capabilities - -| Feature | Supported | -| ------------------- | ----------------------------------- | -| Full-text search | Yes (FTS4) | -| Content field query | Yes | -| Sortable fields | `createdAt`, `updatedAt`, `version` | - -## License - -[CC0 1.0 Universal](https://creativecommons.org/publicdomain/zero/1.0/) — public domain. - -## Monorepo - -Part of [haverstack/core](https://github.com/haverstack/core). diff --git a/packages/blob-adapter-disk/package.json b/packages/blob-adapter-disk/package.json new file mode 100644 index 0000000..2fcae25 --- /dev/null +++ b/packages/blob-adapter-disk/package.json @@ -0,0 +1,46 @@ +{ + "name": "@haverstack/blob-adapter-disk", + "version": "0.6.0", + "description": "Disk blob 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/blob-adapter-disk" + }, + "license": "CC0-1.0", + "keywords": [ + "haverstack", + "disk", + "blob", + "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:*" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.5.0", + "vitest": "^2.0.0" + } +} diff --git a/packages/blob-adapter-disk/src/index.ts b/packages/blob-adapter-disk/src/index.ts new file mode 100644 index 0000000..37cc7dc --- /dev/null +++ b/packages/blob-adapter-disk/src/index.ts @@ -0,0 +1,51 @@ +/** + * Haverstack — Disk Blob Adapter + * ------------------------------------------------------- + * Implements StackBlobAdapter by storing content-addressed + * blobs on the local filesystem. File IDs are SHA-256 hashes + * of the content, enabling deduplication: if a file with the + * same hash already exists it is not overwritten. + */ + +import { createHash } from 'node:crypto'; +import { mkdirSync, existsSync } from 'fs'; +import { readFile, writeFile, unlink } from 'fs/promises'; +import { join } from 'path'; +import type { StackBlobAdapter, FileId } from '@haverstack/core'; + +const SHA256_HEX_RE = /^[0-9a-f]{64}$/; + +const assertFileId = (fileId: string): void => { + if (!SHA256_HEX_RE.test(fileId)) { + throw new Error(`Invalid fileId: expected 64-character lowercase hex string`); + } +}; + +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. + } + } +} diff --git a/packages/blob-adapter-disk/tests/blob.test.ts b/packages/blob-adapter-disk/tests/blob.test.ts new file mode 100644 index 0000000..b3a7d99 --- /dev/null +++ b/packages/blob-adapter-disk/tests/blob.test.ts @@ -0,0 +1,93 @@ +import { describe, test, expect, beforeEach, afterEach } from 'vitest'; +import { mkdirSync, rmSync, existsSync, readdirSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { DiskBlobAdapter } from '../src/index.js'; + +let testDir: string; +let adapter: DiskBlobAdapter; + +beforeEach(() => { + testDir = join(tmpdir(), `blob-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(testDir, { recursive: true }); + adapter = new DiskBlobAdapter(testDir); +}); + +afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); +}); + +describe('DiskBlobAdapter', () => { + test('putAttachment returns a fileId', async () => { + const fileId = await adapter.putAttachment(Buffer.from('hello')); + expect(typeof fileId).toBe('string'); + expect(fileId.length).toBeGreaterThan(0); + }); + + test('putAttachment returns SHA-256 hex string', async () => { + const fileId = await adapter.putAttachment(Buffer.from('hello')); + expect(fileId).toMatch(/^[0-9a-f]{64}$/); + }); + + test('getAttachment returns the stored data', async () => { + const data = Buffer.from('hello attachment'); + const fileId = await adapter.putAttachment(data); + const retrieved = await adapter.getAttachment(fileId); + expect((retrieved as Buffer).toString()).toBe('hello attachment'); + }); + + test('attachment file exists on disk', async () => { + const fileId = await adapter.putAttachment(Buffer.from('test')); + const files = readdirSync(testDir); + expect(files).toContain(fileId); + }); + + test('getAttachment throws for unknown fileId', async () => { + const validHash = 'a'.repeat(64); + await expect(adapter.getAttachment(validHash)).rejects.toThrow(); + }); + + test('getAttachment throws for invalid fileId format', async () => { + await expect(adapter.getAttachment('nonexistent')).rejects.toThrow(/Invalid fileId/); + }); + + test('putAttachment stores binary data correctly', async () => { + const binary = Buffer.from([0x89, 0x50, 0x4e, 0x47]); // PNG magic bytes + const fileId = await adapter.putAttachment(binary); + const retrieved = await adapter.getAttachment(fileId); + expect(retrieved as Buffer).toEqual(binary); + }); + + test('putAttachment deduplicates identical content', async () => { + const data = Buffer.from('same content'); + const id1 = await adapter.putAttachment(data); + const id2 = await adapter.putAttachment(data); + expect(id1).toBe(id2); + const files = readdirSync(testDir); + expect(files.filter((f) => f === id1).length).toBe(1); + }); + + test('getAttachment throws after deleteAttachment', async () => { + const fileId = await adapter.putAttachment(Buffer.from('bye')); + await adapter.deleteAttachment(fileId); + await expect(adapter.getAttachment(fileId)).rejects.toThrow(); + }); + + test('deleteAttachment removes file from disk', async () => { + const fileId = await adapter.putAttachment(Buffer.from('gone')); + await adapter.deleteAttachment(fileId); + expect(existsSync(join(testDir, fileId))).toBe(false); + }); + + test('deleteAttachment is non-fatal for missing file', async () => { + const validHash = 'b'.repeat(64); + await expect(adapter.deleteAttachment(validHash)).resolves.toBeUndefined(); + }); + + test('constructor creates the directory if it does not exist', () => { + const newDir = join(testDir, 'nested', 'blobs'); + expect(existsSync(newDir)).toBe(false); + new DiskBlobAdapter(newDir); + expect(existsSync(newDir)).toBe(true); + }); +}); diff --git a/packages/blob-adapter-disk/tsconfig.build.json b/packages/blob-adapter-disk/tsconfig.build.json new file mode 100644 index 0000000..57d0596 --- /dev/null +++ b/packages/blob-adapter-disk/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "noEmit": false + }, + "exclude": ["tests/**/*.ts"] +} diff --git a/packages/blob-adapter-disk/tsconfig.json b/packages/blob-adapter-disk/tsconfig.json new file mode 100644 index 0000000..dbd41f4 --- /dev/null +++ b/packages/blob-adapter-disk/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "noEmit": true + }, + "include": ["src/**/*.ts", "tests/**/*.ts"] +} diff --git a/packages/core/package.json b/packages/core/package.json index 474bba1..d92f644 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@haverstack/core", - "version": "0.5.0", + "version": "0.6.0", "description": "Core library for Haverstack — portable personal data stack", "type": "module", "exports": { diff --git a/packages/adapter-sqlite/package.json b/packages/record-adapter-sqljs/package.json similarity index 79% rename from packages/adapter-sqlite/package.json rename to packages/record-adapter-sqljs/package.json index 0e2e19d..da72057 100644 --- a/packages/adapter-sqlite/package.json +++ b/packages/record-adapter-sqljs/package.json @@ -1,7 +1,7 @@ { - "name": "@haverstack/adapter-sqlite", - "version": "0.5.0", - "description": "SQLite storage adapter for Haverstack", + "name": "@haverstack/record-adapter-sqljs", + "version": "0.6.0", + "description": "sql.js record adapter for Haverstack", "type": "module", "exports": { ".": { @@ -17,12 +17,15 @@ "repository": { "type": "git", "url": "https://github.com/haverstack/core", - "directory": "packages/adapter-sqlite" + "directory": "packages/record-adapter-sqljs" }, "license": "CC0-1.0", "keywords": [ "haverstack", + "sqljs", + "sql.js", "sqlite", + "record", "adapter", "personal data", "storage" diff --git a/packages/adapter-sqlite/src/index.ts b/packages/record-adapter-sqljs/src/index.ts similarity index 88% rename from packages/adapter-sqlite/src/index.ts rename to packages/record-adapter-sqljs/src/index.ts index 074983d..78ed22d 100644 --- a/packages/adapter-sqlite/src/index.ts +++ b/packages/record-adapter-sqljs/src/index.ts @@ -1,29 +1,26 @@ /** - * Stack — SQLite Adapter + * Haverstack — SQLite Record Adapter * ------------------------------------------------------- - * Implements StackAdapter using sql.js (SQLite compiled to - * WebAssembly). Runs in Node, browsers, and other runtimes + * Implements StackRecordAdapter using sql.js (SQLite compiled + * to WebAssembly). Runs in Node, browsers, and other runtimes * without native compilation. * * 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; the filesystem - * is the authoritative store — no separate DB table is needed. + * write. Stack config (ownerEntityId, timezone) is stored as + * a singleton _config@1 record with id='_config' in the + * records table. * - * Stack config (ownerEntityId, timezone) is stored as a singleton - * _config@1 record with id='_config' in the records table. + * Also exposes token management methods (createToken, + * lookupToken, listTokens, revokeToken) used by server + * implementations to issue and validate bearer tokens. */ import initSqlJs from 'sql.js'; import type { Database, SqlJsStatic } from 'sql.js'; import { createHash, randomBytes } from 'node:crypto'; -import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs'; -import { readFile, writeFile, unlink } from 'fs/promises'; -import { join, dirname } from 'path'; +import { readFileSync, writeFileSync, existsSync } from 'fs'; import type { - StackAdapter, - StackBlobAdapter, + StackRecordAdapter, StackRecord, StackType, TypeId, @@ -32,14 +29,13 @@ import type { QueryResult, Association, AdapterCapabilities, - FileId, } from '@haverstack/core'; // ------------------------------------------------------- // Types // ------------------------------------------------------- -export type SQLiteInitializeOptions = { +export type SQLiteRecordInitializeOptions = { /** Absolute path to the .db file. Must not already exist. */ path: string; /** IANA timezone string e.g. "America/New_York". */ @@ -48,7 +44,7 @@ export type SQLiteInitializeOptions = { entityId: string; }; -export type SQLiteOpenOptions = { +export type SQLiteRecordOpenOptions = { /** Absolute path to an existing .db file. */ path: string; }; @@ -144,56 +140,6 @@ const SCHEMA_SQL = ` // Helpers // ------------------------------------------------------- -const SHA256_HEX_RE = /^[0-9a-f]{64}$/; - -const assertFileId = (fileId: string): void => { - if (!SHA256_HEX_RE.test(fileId)) { - throw new Error(`Invalid fileId: expected 64-character lowercase hex string`); - } -}; - -// ------------------------------------------------------- -// 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 -// ------------------------------------------------------- - const toMs = (d: Date): number => d.getTime(); const fromMs = (ms: number): Date => new Date(ms); @@ -485,10 +431,10 @@ const makeCursor = (record: StackRecord, field: SortField): string => { }; // ------------------------------------------------------- -// SQLite adapter +// SQLiteRecordAdapter // ------------------------------------------------------- -export class SQLiteAdapter implements StackAdapter { +export class SQLiteRecordAdapter implements StackRecordAdapter { readonly capabilities: AdapterCapabilities = { fullTextSearch: true, contentFieldQuery: true, @@ -499,7 +445,6 @@ export class SQLiteAdapter implements StackAdapter { timezone!: string; private db!: Database; - private blob!: DiskBlobAdapter; private constructor( private readonly SQL: SqlJsStatic, @@ -510,16 +455,15 @@ export class SQLiteAdapter implements StackAdapter { * Initialize a new stack database. Fails if the file already exists — * use open() for existing databases. */ - static async initialize(opts: SQLiteInitializeOptions): Promise { + static async initialize(opts: SQLiteRecordInitializeOptions): Promise { if (existsSync(opts.path)) { throw new Error( `Cannot initialize: database already exists at "${opts.path}". ` + - `Use SQLiteAdapter.open() instead.`, + `Use SQLiteRecordAdapter.open() instead.`, ); } const SQL = await initSqlJs(); - const adapter = new SQLiteAdapter(SQL, opts.path); - adapter.blob = new DiskBlobAdapter(join(dirname(opts.path), 'attachments')); + const adapter = new SQLiteRecordAdapter(SQL, opts.path); adapter.db = new SQL.Database(); adapter.db.run(SCHEMA_SQL); const now = Date.now(); @@ -538,16 +482,15 @@ export class SQLiteAdapter implements StackAdapter { * Open an existing stack database. Fails if the file does not exist — * use initialize() for new databases. */ - static async open(opts: SQLiteOpenOptions): Promise { + static async open(opts: SQLiteRecordOpenOptions): Promise { if (!existsSync(opts.path)) { throw new Error( `Cannot open: no database found at "${opts.path}". ` + - `Use SQLiteAdapter.initialize() to create one.`, + `Use SQLiteRecordAdapter.initialize() to create one.`, ); } const SQL = await initSqlJs(); - const adapter = new SQLiteAdapter(SQL, opts.path); - adapter.blob = new DiskBlobAdapter(join(dirname(opts.path), 'attachments')); + const adapter = new SQLiteRecordAdapter(SQL, opts.path); const fileBuffer = readFileSync(opts.path); adapter.db = new SQL.Database(fileBuffer); adapter.db.run(SCHEMA_SQL); @@ -795,22 +738,6 @@ export class SQLiteAdapter implements StackAdapter { return rows.map(rowToType); } - // ------------------------------------------------------- - // Attachments — delegated to DiskBlobAdapter - // ------------------------------------------------------- - - async putAttachment(data: Uint8Array): Promise { - return this.blob.putAttachment(data); - } - - async getAttachment(fileId: FileId): Promise { - return this.blob.getAttachment(fileId); - } - - async deleteAttachment(fileId: FileId): Promise { - return this.blob.deleteAttachment(fileId); - } - // ------------------------------------------------------- // Tokens // ------------------------------------------------------- diff --git a/packages/adapter-sqlite/tests/sqlite.test.ts b/packages/record-adapter-sqljs/tests/record.test.ts similarity index 89% rename from packages/adapter-sqlite/tests/sqlite.test.ts rename to packages/record-adapter-sqljs/tests/record.test.ts index 7bf7838..7afba38 100644 --- a/packages/adapter-sqlite/tests/sqlite.test.ts +++ b/packages/record-adapter-sqljs/tests/record.test.ts @@ -1,8 +1,8 @@ import { describe, test, expect, beforeEach, afterEach } from 'vitest'; -import { mkdirSync, rmSync, existsSync, readdirSync } from 'fs'; +import { mkdirSync, rmSync, existsSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; -import { SQLiteAdapter } from '../src/index.js'; +import { SQLiteRecordAdapter } from '../src/index.js'; import type { StackRecord } from '@haverstack/core'; // ------------------------------------------------------- @@ -23,7 +23,7 @@ afterEach(() => { }); const initAdapter = (opts?: { timezone?: string; entityId?: string }) => - SQLiteAdapter.initialize({ + SQLiteRecordAdapter.initialize({ path: dbPath, entityId: opts?.entityId ?? 'entity-123', timezone: opts?.timezone ?? 'America/New_York', @@ -59,11 +59,6 @@ describe('initialize', () => { expect(existsSync(dbPath)).toBe(true); }); - test('creates an attachments directory', async () => { - await initAdapter(); - expect(existsSync(join(testDir, 'attachments'))).toBe(true); - }); - test('sets ownerEntityId', async () => { const adapter = await initAdapter({ entityId: 'owner-abc' }); expect(adapter.ownerEntityId).toBe('owner-abc'); @@ -83,14 +78,14 @@ describe('initialize', () => { describe('open', () => { test('opens an existing database', async () => { await initAdapter(); - const adapter = await SQLiteAdapter.open({ path: dbPath }); + const adapter = await SQLiteRecordAdapter.open({ path: dbPath }); expect(adapter.ownerEntityId).toBe('entity-123'); }); test('throws if database does not exist', async () => { - await expect(SQLiteAdapter.open({ path: join(testDir, 'nonexistent.db') })).rejects.toThrow( - /no database found/, - ); + await expect( + SQLiteRecordAdapter.open({ path: join(testDir, 'nonexistent.db') }), + ).rejects.toThrow(/no database found/); }); test('data persists across adapter instances', async () => { @@ -99,8 +94,7 @@ describe('open', () => { const record = makeRecord(); await adapter1.createRecord(record); - // Open fresh instance — data should still be there - const adapter2 = await SQLiteAdapter.open({ path: dbPath }); + const adapter2 = await SQLiteRecordAdapter.open({ path: dbPath }); expect(await adapter2.getType(NOTE_TYPE.id)).not.toBeNull(); expect(await adapter2.getRecord(record.id)).not.toBeNull(); }); @@ -108,7 +102,7 @@ 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 }); + const adapter = await SQLiteRecordAdapter.open({ path: dbPath }); expect(adapter.ownerEntityId).toBe('owner-abc'); expect(adapter.timezone).toBe('Europe/London'); }); @@ -279,7 +273,7 @@ describe('records — queries', () => { await adapter.createRecord(r1); await adapter.createRecord(r2); await adapter.createRecord(r3); - await adapter.deleteRecord(r3.id); // soft delete + await adapter.deleteRecord(r3.id); const result = await adapter.queryRecords({}); expect(result.records.length).toBe(2); expect(result.total).toBe(2); @@ -533,7 +527,6 @@ describe('records — queries', () => { expect(page2.records.length).toBe(2); expect(page2.cursor).toBeNull(); - // No overlap const page1Ids = new Set(page1.records.map((r) => r.id)); const page2Ids = new Set(page2.records.map((r) => r.id)); expect([...page1Ids].filter((id) => page2Ids.has(id)).length).toBe(0); @@ -765,84 +758,12 @@ describe('versions', () => { await adapter.createRecord(record); const v = { version: 1, content: { text: 'original' }, updatedAt: new Date() }; await adapter.saveVersion(record.id, v); - await adapter.saveVersion(record.id, v); // should not throw or duplicate + await adapter.saveVersion(record.id, v); const versions = await adapter.getVersions(record.id); expect(versions.length).toBe(1); }); }); -// ------------------------------------------------------- -// Attachments -// ------------------------------------------------------- - -describe('attachments', () => { - test('putAttachment returns a fileId', async () => { - const adapter = await initAdapter(); - const fileId = await adapter.putAttachment(Buffer.from('hello')); - expect(typeof fileId).toBe('string'); - expect(fileId.length).toBeGreaterThan(0); - }); - - test('putAttachment returns SHA-256 hex string', async () => { - const adapter = await initAdapter(); - const fileId = await adapter.putAttachment(Buffer.from('hello')); - expect(fileId).toMatch(/^[0-9a-f]{64}$/); - }); - - test('getAttachment returns the stored data', async () => { - const adapter = await initAdapter(); - const data = Buffer.from('hello attachment'); - const fileId = await adapter.putAttachment(data); - const retrieved = await adapter.getAttachment(fileId); - expect((retrieved as Buffer).toString()).toBe('hello attachment'); - }); - - test('attachment file exists on disk without extension', async () => { - const adapter = await initAdapter(); - const fileId = await adapter.putAttachment(Buffer.from('test')); - const attachmentsDir = join(testDir, 'attachments'); - const files = readdirSync(attachmentsDir); - expect(files).toContain(fileId); - }); - - test('getAttachment throws for unknown fileId', async () => { - const adapter = await initAdapter(); - await expect(adapter.getAttachment('nonexistent')).rejects.toThrow(); - }); - - test('putAttachment stores binary data correctly', async () => { - const adapter = await initAdapter(); - const binary = Buffer.from([0x89, 0x50, 0x4e, 0x47]); // PNG magic bytes - const fileId = await adapter.putAttachment(binary); - const retrieved = await adapter.getAttachment(fileId); - expect(retrieved as Buffer).toEqual(binary); - }); - - test('putAttachment deduplicates identical content', async () => { - const adapter = await initAdapter(); - const data = Buffer.from('same content'); - const id1 = await adapter.putAttachment(data); - const id2 = await adapter.putAttachment(data); - expect(id1).toBe(id2); - const files = readdirSync(join(testDir, 'attachments')); - expect(files.filter((f) => f === id1).length).toBe(1); - }); - - test('getAttachment throws after deleteAttachment', async () => { - const adapter = await initAdapter(); - const fileId = await adapter.putAttachment(Buffer.from('bye')); - await adapter.deleteAttachment(fileId); - await expect(adapter.getAttachment(fileId)).rejects.toThrow(); - }); - - test('deleteAttachment removes file from disk', async () => { - const adapter = await initAdapter(); - const fileId = await adapter.putAttachment(Buffer.from('gone')); - await adapter.deleteAttachment(fileId); - expect(existsSync(join(testDir, 'attachments', fileId))).toBe(false); - }); -}); - // ------------------------------------------------------- // Tokens // ------------------------------------------------------- diff --git a/packages/record-adapter-sqljs/tsconfig.build.json b/packages/record-adapter-sqljs/tsconfig.build.json new file mode 100644 index 0000000..57d0596 --- /dev/null +++ b/packages/record-adapter-sqljs/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "noEmit": false + }, + "exclude": ["tests/**/*.ts"] +} diff --git a/packages/record-adapter-sqljs/tsconfig.json b/packages/record-adapter-sqljs/tsconfig.json new file mode 100644 index 0000000..dbd41f4 --- /dev/null +++ b/packages/record-adapter-sqljs/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "noEmit": true + }, + "include": ["src/**/*.ts", "tests/**/*.ts"] +} diff --git a/packages/wire-types/package.json b/packages/wire-types/package.json index 60d76cd..5b883f5 100644 --- a/packages/wire-types/package.json +++ b/packages/wire-types/package.json @@ -1,6 +1,6 @@ { "name": "@haverstack/wire-types", - "version": "0.5.0", + "version": "0.6.0", "description": "HTTP wire types and serialization for Haverstack", "type": "module", "exports": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4c954be..2fd54d4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -52,21 +52,37 @@ importers: specifier: ^2.0.0 version: 2.1.9(@types/node@22.19.17) - packages/adapter-sqlite: + packages/adapter-local: + dependencies: + '@haverstack/blob-adapter-disk': + specifier: workspace:* + version: link:../blob-adapter-disk + '@haverstack/core': + specifier: workspace:* + version: link:../core + '@haverstack/record-adapter-sqljs': + specifier: workspace:* + version: link:../record-adapter-sqljs + devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.19.17 + typescript: + specifier: ^5.5.0 + version: 5.9.3 + vitest: + specifier: ^2.0.0 + version: 2.1.9(@types/node@22.19.17) + + packages/blob-adapter-disk: dependencies: '@haverstack/core': specifier: workspace:* version: link:../core - sql.js: - specifier: ^1.14.0 - version: 1.14.1 devDependencies: '@types/node': specifier: ^22.0.0 version: 22.19.17 - '@types/sql.js': - specifier: ^1.4.9 - version: 1.4.11 typescript: specifier: ^5.5.0 version: 5.9.3 @@ -86,6 +102,28 @@ importers: specifier: ^2.0.0 version: 2.1.9(@types/node@22.19.17) + packages/record-adapter-sqljs: + dependencies: + '@haverstack/core': + specifier: workspace:* + version: link:../core + sql.js: + specifier: ^1.14.0 + version: 1.14.1 + devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.19.17 + '@types/sql.js': + specifier: ^1.4.9 + version: 1.4.11 + typescript: + specifier: ^5.5.0 + version: 5.9.3 + vitest: + specifier: ^2.0.0 + version: 2.1.9(@types/node@22.19.17) + packages/wire-types: dependencies: '@haverstack/core':