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
1 change: 1 addition & 0 deletions .github/workflows/changeset.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ on:
- '@haverstack/commons'
- '@haverstack/conformance-fixtures'
- '@haverstack/blob-adapter-disk'
- '@haverstack/blob-adapter-s3'
- '@haverstack/record-adapter-sqlite'
- '@haverstack/adapter-local'
- '@haverstack/adapter-api'
Expand Down
3 changes: 2 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ Renaming the workflow file breaks every trusted publisher at once; each matches

Under **Settings → Actions → General**, _Allow GitHub Actions to create and approve pull requests_ must stay on, or the version PR is never opened.

**A ninth package** needs one manual publish (trusted publishing configures only on a package that exists), then its own trusted publisher and an entry in the `package` dropdown in `.github/workflows/changeset.yml`.
**A tenth package** needs one manual publish (trusted publishing configures only on a package that exists), then its own trusted publisher and an entry in the `package` dropdown in `.github/workflows/changeset.yml`.

**To gate the publish on a human:** create a GitHub environment with required reviewers, add `environment:` to the release job, and name it in every trusted publisher. All three must agree.

Expand Down Expand Up @@ -268,6 +268,7 @@ packages/
sqlite-shared/ # Internal: shared SQL logic, bundled into consumers, not published
record-adapter-sqlite/ # Node native SQLite (node:sqlite), FTS5, WAL
blob-adapter-disk/ # Content-addressed blobs on disk
blob-adapter-s3/ # Content-addressed blobs on S3 / S3-compatible stores
adapter-local/ # Convenience: SQLite records + disk blobs
adapter-api/ # HTTP client for stack servers
wire-types/ # Wire serialization shapes and error mapping
Expand Down
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ This is a monorepo. Packages are published to npm under the `@haverstack` scope.
| [`@haverstack/adapter-local`](./packages/adapter-local) | Local adapter (native SQLite + disk) — single-app/embedded or server use |
| [`@haverstack/record-adapter-sqlite`](./packages/record-adapter-sqlite) | Node native SQLite (`node:sqlite`) `StackRecordAdapter` — used by `adapter-local` |
| [`@haverstack/blob-adapter-disk`](./packages/blob-adapter-disk) | Disk filesystem `StackBlobAdapter` |
| [`@haverstack/blob-adapter-s3`](./packages/blob-adapter-s3) | S3 (and S3-compatible, e.g. Cloudflare R2) `StackBlobAdapter` |
| [`@haverstack/adapter-api`](./packages/adapter-api) | HTTP adapter for remote stack servers |
| [`@haverstack/commons`](./packages/commons) | Canonical Schema Commons type definitions (`note`, `task`, `contact`, ...) |

Expand Down Expand Up @@ -248,10 +249,11 @@ The adapter interface is split into `StackRecordAdapter` (structured records) an
| `adapter-local` | full | Single-app/embedded or server use — native SQLite records + disk blobs |
| `record-adapter-sqlite` | record | Node native SQLite (`node:sqlite`) records, FTS5, WAL — used by `adapter-local` |
| `blob-adapter-disk` | blob | Content-addressed blobs on the local filesystem |
| `blob-adapter-s3` | blob | Content-addressed blobs on S3 or an S3-compatible store (e.g. Cloudflare R2) |
| `adapter-api` | full | Hosted/shared stacks via HTTP |
| `adapter-json` | full | Portable JSON files _(planned)_ |

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

---

Expand Down Expand Up @@ -316,6 +318,10 @@ packages/
src/
index.ts # DiskBlobAdapter (StackBlobAdapter)
tests/
blob-adapter-s3/ # @haverstack/blob-adapter-s3
src/
index.ts # S3BlobAdapter (StackBlobAdapter)
tests/
adapter-api/ # @haverstack/adapter-api
src/
index.ts # APIAdapter (StackAdapter)
Expand Down
21 changes: 12 additions & 9 deletions docs/spec/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,14 @@ Packages follow a naming convention that makes the adapter type discoverable:

## Adapter backends

| Package | Type | Use case |
| ----------------------- | ------ | ----------------------------------------------------- |
| `adapter-local` | full | Local app storage — native SQLite + disk blobs |
| `record-adapter-sqlite` | record | Node native SQLite (`node:sqlite`) records, FTS5, WAL |
| `blob-adapter-disk` | blob | Content-addressed blobs on disk |
| `adapter-api` | full | Hosted/shared stacks via HTTP |
| `adapter-json` | full | Portable JSON files _(planned)_ |
| Package | Type | Use case |
| ----------------------- | ------ | ---------------------------------------------------------------------------- |
| `adapter-local` | full | Local app storage — native SQLite + disk blobs |
| `record-adapter-sqlite` | record | Node native SQLite (`node:sqlite`) records, FTS5, WAL |
| `blob-adapter-disk` | blob | Content-addressed blobs on disk |
| `blob-adapter-s3` | blob | Content-addressed blobs on S3 or an S3-compatible store (e.g. Cloudflare R2) |
| `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 `NativeSQLiteRecordAdapter` and `DiskBlobAdapter` and stores attachments in an `attachments/` subdirectory next to the database file. Bearer tokens, when used, live in a separate sibling file (`<path>.tokens`, via `NativeTokenStore`) — never inside the portable stack database.

Expand All @@ -51,14 +52,16 @@ Use `combineAdapters()` from `@haverstack/core/adapter` when you want different
```ts
import { combineAdapters } from '@haverstack/core/adapter';
import { NativeSQLiteRecordAdapter } from '@haverstack/record-adapter-sqlite';
import { S3BlobAdapter } from '@haverstack/blob-adapter-s3'; // hypothetical
import { S3BlobAdapter } from '@haverstack/blob-adapter-s3';

const record = await NativeSQLiteRecordAdapter.initialize({ path, entityId, timezone });
const blob = new S3BlobAdapter(bucketConfig);
const blob = new S3BlobAdapter({ bucket: 'my-bucket' });
const adapter = combineAdapters({ record, blob });
const stack = await Stack.create(adapter);
```

`maxAttachmentBytes` lives on `AdapterCapabilities` (below), which `combineAdapters()` always reads from the `record` half — a blob-only package like `blob-adapter-s3` has no ceiling of its own to declare. Whichever `StackRecordAdapter` it's paired with should keep declaring `maxAttachmentBytes: null`, per the local-adapter rule above: a blob adapter isn't the wire boundary that would justify one. Point `S3BlobAdapter` at Cloudflare R2 or another S3-compatible store by passing `endpoint` and `forcePathStyle: true`.

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

**`@haverstack/sqlite-shared`** is an internal, non-public package holding everything a SQLite-backed record adapter needs that isn't specific to one binding — schema DDL, `WHERE`/`ORDER` building, the cursor codec, row mappers, the FTS5 sanitizer and indexing strategy, the storage-ownership lock, and (via a small `SqlExecutor` interface normalizing a binding's call convention) the actual CRUD/query/version/type/association/token logic itself. An adapter implements only what's genuinely engine-specific: database construction, pragma/WAL setup, and lifecycle. `record-adapter-sqlite` is its only consumer today; the split exists so a second SQLite engine inherits the behavior rather than reimplementing it, and so a cursor minted by one is decodable by another.
Expand Down
49 changes: 49 additions & 0 deletions packages/blob-adapter-s3/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
{
"name": "@haverstack/blob-adapter-s3",
"version": "0.1.0",
"description": "S3 (and S3-compatible) 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": "git+https://github.com/haverstack/core.git",
"directory": "packages/blob-adapter-s3"
},
"license": "CC0-1.0",
"keywords": [
"haverstack",
"s3",
"r2",
"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": {
"@aws-sdk/client-s3": "^3.700.0",
"@haverstack/core": "workspace:^"
},
"devDependencies": {
"@types/node": "^22.0.0",
"aws-sdk-client-mock": "^4.0.0",
"typescript": "^5.5.0",
"vitest": "^2.0.0"
}
}
138 changes: 138 additions & 0 deletions packages/blob-adapter-s3/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/**
* Haverstack — S3 Blob Adapter
* -------------------------------------------------------
* Implements StackBlobAdapter over the S3 API, storing
* content-addressed blobs keyed by the SHA-256 hash of their
* bytes. Pass `endpoint` + `forcePathStyle` to target an
* S3-compatible store (e.g. Cloudflare R2) instead of AWS S3
* itself — the rest of the adapter is identical either way.
*
* Unlike blob-adapter-disk, no temp-file-plus-rename dance is
* needed: S3's PutObject is atomic per key, so two callers
* writing the same content-addressed key concurrently just
* write the same bytes twice — never a torn object.
*/

import { createHash } from 'node:crypto';
import {
S3Client,
PutObjectCommand,
GetObjectCommand,
HeadObjectCommand,
DeleteObjectCommand,
ListObjectsV2Command,
NoSuchKey,
NotFound,
type S3ClientConfig,
} from '@aws-sdk/client-s3';
import { StackNotFoundError, StackQueryError } from '@haverstack/core';
import type { FileId } from '@haverstack/core';
import type { StackBlobAdapter, BlobFileInfo } from '@haverstack/core/adapter';

const SHA256_HEX_RE = /^[0-9a-f]{64}$/;

const assertFileId = (fileId: string): void => {
if (!SHA256_HEX_RE.test(fileId)) {
throw new StackQueryError(`Invalid fileId: expected 64-character lowercase hex string`);
}
};

export type S3BlobAdapterOptions = {
/** Bucket to store blobs in. */
bucket: string;
/**
* A pre-configured S3Client, for callers who need control over retry
* policy, credential providers, request middleware, etc. When omitted,
* a client is built from the remaining options.
*/
client?: S3Client;
/** AWS region. Ignored when `client` is provided. */
region?: string;
/**
* Custom endpoint — set this together with `forcePathStyle` to target an
* S3-compatible store (e.g. Cloudflare R2's S3 endpoint) instead of AWS
* S3. Ignored when `client` is provided.
*/
endpoint?: string;
/** Ignored when `client` is provided. */
forcePathStyle?: boolean;
/** Ignored when `client` is provided. */
credentials?: S3ClientConfig['credentials'];
};

export class S3BlobAdapter implements StackBlobAdapter {
private readonly client: S3Client;
private readonly bucket: string;

constructor(options: S3BlobAdapterOptions) {
this.bucket = options.bucket;
this.client =
options.client ??
new S3Client({
region: options.region,
endpoint: options.endpoint,
forcePathStyle: options.forcePathStyle,
credentials: options.credentials,
});
}

private async objectExists(fileId: FileId): Promise<boolean> {
try {
await this.client.send(new HeadObjectCommand({ Bucket: this.bucket, Key: fileId }));
return true;
} catch (err) {
if (err instanceof NotFound) return false;
throw err;
}
}

async putAttachment(data: Uint8Array): Promise<FileId> {
const fileId = createHash('sha256').update(data).digest('hex');
if (!(await this.objectExists(fileId))) {
await this.client.send(
new PutObjectCommand({ Bucket: this.bucket, Key: fileId, Body: data }),
);
}
return fileId;
}

async getAttachment(fileId: FileId): Promise<Uint8Array> {
assertFileId(fileId);
try {
const response = await this.client.send(
new GetObjectCommand({ Bucket: this.bucket, Key: fileId }),
);
return await response.Body!.transformToByteArray();
} catch (err) {
if (err instanceof NoSuchKey) {
throw new StackNotFoundError(`Attachment not found: "${fileId}"`);
}
throw err;
}
}

async deleteAttachment(fileId: FileId): Promise<void> {
assertFileId(fileId);
// S3's DeleteObject is idempotent — a missing key is treated as
// already-deleted rather than an error, so no non-fatal catch is
// needed here the way blob-adapter-disk needs one for fs semantics.
await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: fileId }));
}

async listFiles(): Promise<BlobFileInfo[]> {
const files: BlobFileInfo[] = [];
let continuationToken: string | undefined;
do {
const response = await this.client.send(
new ListObjectsV2Command({ Bucket: this.bucket, ContinuationToken: continuationToken }),
);
for (const obj of response.Contents ?? []) {
if (obj.Key && SHA256_HEX_RE.test(obj.Key) && obj.Size !== undefined && obj.LastModified) {
files.push({ fileId: obj.Key, size: obj.Size, modifiedAt: obj.LastModified });
}
}
continuationToken = response.IsTruncated ? response.NextContinuationToken : undefined;
} while (continuationToken);
return files;
}
}
Loading
Loading