@saacms/storage-d1 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,86 @@
1
+ /**
2
+ * D1 row-storage adapter — implements the saacms `RowStorageAdapter`
3
+ * interface over a Cloudflare Workers `D1Database` binding.
4
+ *
5
+ * Per ADR 0024 D1 is the only row-store adapter shipped at v1.0; postgres /
6
+ * sqlite-local follow in v1.x.
7
+ *
8
+ * Workers API reference:
9
+ * https://developers.cloudflare.com/d1/build-with-d1/d1-client-api/
10
+ *
11
+ * Interface defined locally for now; will move to `@saacms/core` when the
12
+ * Storage bounded context surface lands (ADR 0020).
13
+ *
14
+ * Anti-corruption boundary (ADR 0020): callers always speak **camelCase**
15
+ * domain keys; the physical D1/SQLite schema is conventional **snake_case**
16
+ * (ADR 0005 — the DB is a mechanical projection of the Schema). This adapter
17
+ * is the only place the two conventions meet — it maps camelCase→snake_case
18
+ * on every write/query path and snake_case→camelCase on every read path,
19
+ * reusing `@saacms/core`'s canonical `camelToSnakeCase` so the convention has
20
+ * a single source of truth (no re-implemented regex). Server-injected
21
+ * timestamp columns (`created_at`/`updated_at`) surface to callers as
22
+ * `createdAt`/`updatedAt`, restoring the weak-ETag field the runtime reads
23
+ * (ADR 0021 §6 / RFC 9110).
24
+ */
25
+ /** Inverse of `camelToSnakeCase`: `updated_at` → `updatedAt`, `id` → `id`. */
26
+ export declare function snakeToCamel(name: string): string;
27
+ export interface RowStorageAdapter {
28
+ getById<T>(table: string, id: string): Promise<T | null>;
29
+ list<T>(table: string, opts?: ListOpts): Promise<ListResult<T>>;
30
+ insert<T>(table: string, row: T): Promise<{
31
+ id: string;
32
+ }>;
33
+ update<T>(table: string, id: string, patch: Partial<T>): Promise<void>;
34
+ delete(table: string, id: string): Promise<void>;
35
+ }
36
+ export interface ListOpts {
37
+ readonly limit?: number;
38
+ readonly cursor?: string;
39
+ readonly where?: Readonly<Record<string, unknown>>;
40
+ readonly orderBy?: ReadonlyArray<{
41
+ readonly col: string;
42
+ readonly dir: "asc" | "desc";
43
+ }>;
44
+ }
45
+ export interface ListResult<T> {
46
+ readonly rows: ReadonlyArray<T>;
47
+ readonly nextCursor: string | null;
48
+ }
49
+ export declare class RowStorageError extends Error {
50
+ readonly code: "NOT_FOUND" | "INSERT_FAILED" | "UPDATE_FAILED" | "DELETE_FAILED" | "QUERY_FAILED" | "NOT_IMPLEMENTED" | "MISCONFIGURED";
51
+ readonly cause?: unknown | undefined;
52
+ readonly name = "RowStorageError";
53
+ constructor(code: "NOT_FOUND" | "INSERT_FAILED" | "UPDATE_FAILED" | "DELETE_FAILED" | "QUERY_FAILED" | "NOT_IMPLEMENTED" | "MISCONFIGURED", message: string, cause?: unknown | undefined);
54
+ }
55
+ /** Subset of `D1Database` used by this adapter. Mirrors @cloudflare/workers-types shape. */
56
+ export interface D1DatabaseLike {
57
+ prepare(query: string): D1PreparedStatementLike;
58
+ batch<T = unknown>(statements: D1PreparedStatementLike[]): Promise<D1ResultLike<T>[]>;
59
+ }
60
+ export interface D1PreparedStatementLike {
61
+ bind(...values: unknown[]): D1PreparedStatementLike;
62
+ first<T = unknown>(): Promise<T | null>;
63
+ all<T = unknown>(): Promise<D1ResultLike<T>>;
64
+ run(): Promise<D1ResultLike<unknown>>;
65
+ }
66
+ export interface D1ResultLike<T = unknown> {
67
+ readonly results?: T[];
68
+ readonly success: boolean;
69
+ readonly meta?: {
70
+ readonly last_row_id?: number;
71
+ readonly changes?: number;
72
+ };
73
+ }
74
+ export interface D1AdapterOpts {
75
+ /** The Workers `D1Database` binding; supplied by the host. */
76
+ readonly db: D1DatabaseLike;
77
+ }
78
+ type FilterOp = "eq" | "ne" | "gt" | "gte" | "lt" | "lte";
79
+ /** Discriminated operator filter produced by list-route and consumed here. */
80
+ export interface FilterOpValue {
81
+ readonly op: FilterOp;
82
+ readonly value: unknown;
83
+ }
84
+ export declare function d1Adapter(opts: D1AdapterOpts): RowStorageAdapter;
85
+ export {};
86
+ //# sourceMappingURL=d1-adapter.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"d1-adapter.d.ts","sourceRoot":"","sources":["../src/d1-adapter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAeH,8EAA8E;AAC9E,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAEjD;AAsBD,MAAM,WAAW,iBAAiB;IAChC,OAAO,CAAC,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,CAAA;IACxD,IAAI,CAAC,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,QAAQ,GAAG,OAAO,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,CAAA;IAC/D,MAAM,CAAC,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,GAAG,OAAO,CAAC;QAAE,EAAE,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;IACzD,MAAM,CAAC,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACtE,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;CACjD;AAED,MAAM,WAAW,QAAQ;IACvB,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,KAAK,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAA;IAClD,QAAQ,CAAC,OAAO,CAAC,EAAE,aAAa,CAAC;QAAE,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,GAAG,EAAE,KAAK,GAAG,MAAM,CAAA;KAAE,CAAC,CAAA;CACzF;AAED,MAAM,WAAW,UAAU,CAAC,CAAC;IAC3B,QAAQ,CAAC,IAAI,EAAE,aAAa,CAAC,CAAC,CAAC,CAAA;IAC/B,QAAQ,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;CACnC;AAED,qBAAa,eAAgB,SAAQ,KAAK;IAGtC,QAAQ,CAAC,IAAI,EACT,WAAW,GACX,eAAe,GACf,eAAe,GACf,eAAe,GACf,cAAc,GACd,iBAAiB,GACjB,eAAe;IAEnB,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO;IAX1B,SAAkB,IAAI,qBAAoB;gBAE/B,IAAI,EACT,WAAW,GACX,eAAe,GACf,eAAe,GACf,eAAe,GACf,cAAc,GACd,iBAAiB,GACjB,eAAe,EACnB,OAAO,EAAE,MAAM,EACN,KAAK,CAAC,EAAE,OAAO,YAAA;CAI3B;AAYD,4FAA4F;AAC5F,MAAM,WAAW,cAAc;IAC7B,OAAO,CAAC,KAAK,EAAE,MAAM,GAAG,uBAAuB,CAAA;IAC/C,KAAK,CAAC,CAAC,GAAG,OAAO,EAAE,UAAU,EAAE,uBAAuB,EAAE,GAAG,OAAO,CAAC,YAAY,CAAC,CAAC,CAAC,EAAE,CAAC,CAAA;CACtF;AACD,MAAM,WAAW,uBAAuB;IACtC,IAAI,CAAC,GAAG,MAAM,EAAE,OAAO,EAAE,GAAG,uBAAuB,CAAA;IACnD,KAAK,CAAC,CAAC,GAAG,OAAO,KAAK,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,CAAA;IACvC,GAAG,CAAC,CAAC,GAAG,OAAO,KAAK,OAAO,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC,CAAA;IAC5C,GAAG,IAAI,OAAO,CAAC,YAAY,CAAC,OAAO,CAAC,CAAC,CAAA;CACtC;AACD,MAAM,WAAW,YAAY,CAAC,CAAC,GAAG,OAAO;IACvC,QAAQ,CAAC,OAAO,CAAC,EAAE,CAAC,EAAE,CAAA;IACtB,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;IACzB,QAAQ,CAAC,IAAI,CAAC,EAAE;QAAE,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;CAC7E;AAED,MAAM,WAAW,aAAa;IAC5B,8DAA8D;IAC9D,QAAQ,CAAC,EAAE,EAAE,cAAc,CAAA;CAC5B;AAyBD,KAAK,QAAQ,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,GAAG,KAAK,GAAG,IAAI,GAAG,KAAK,CAAA;AAEzD,8EAA8E;AAC9E,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,EAAE,EAAE,QAAQ,CAAA;IACrB,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAA;CACxB;AA2CD,wBAAgB,SAAS,CAAC,IAAI,EAAE,aAAa,GAAG,iBAAiB,CAsKhE"}
@@ -0,0 +1,13 @@
1
+ /**
2
+ * @saacms/storage-d1 — public surface.
3
+ *
4
+ * Cloudflare D1 (SQLite at the edge) implementation of the saacms
5
+ * `RowStorageAdapter` interface. Per ADR 0024 D1 is the only row-store
6
+ * adapter shipped at v1.0; postgres / sqlite-local follow in v1.x.
7
+ *
8
+ * v1 alpha status: scaffold. Real implementation lands via the
9
+ * `agentFleet dispatch A` task.
10
+ */
11
+ export { d1Adapter } from "./d1-adapter.ts";
12
+ export type { D1AdapterOpts, RowStorageAdapter, RowStorageError, ListOpts, ListResult, } from "./d1-adapter.ts";
13
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAA;AAC3C,YAAY,EACV,aAAa,EACb,iBAAiB,EACjB,eAAe,EACf,QAAQ,EACR,UAAU,GACX,MAAM,iBAAiB,CAAA"}
package/dist/index.js ADDED
@@ -0,0 +1,183 @@
1
+ // src/d1-adapter.ts
2
+ import { camelToSnakeCase, UniqueConstraintError } from "@saacms/core";
3
+ function snakeToCamel(name) {
4
+ return name.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
5
+ }
6
+ function keysToSnake(row) {
7
+ const out = {};
8
+ for (const [k, v] of Object.entries(row))
9
+ out[camelToSnakeCase(k)] = v;
10
+ return out;
11
+ }
12
+ function rowToCamel(row) {
13
+ const out = {};
14
+ for (const [k, v] of Object.entries(row)) {
15
+ if (v !== null)
16
+ out[snakeToCamel(k)] = v;
17
+ }
18
+ return out;
19
+ }
20
+
21
+ class RowStorageError extends Error {
22
+ code;
23
+ cause;
24
+ name = "RowStorageError";
25
+ constructor(code, message, cause) {
26
+ super(message);
27
+ this.code = code;
28
+ this.cause = cause;
29
+ }
30
+ }
31
+ function isUniqueViolation(cause) {
32
+ if (cause == null)
33
+ return false;
34
+ const msg = (cause instanceof Error ? cause.message : String(cause)).toLowerCase();
35
+ return msg.includes("unique constraint failed");
36
+ }
37
+ var IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
38
+ var FILTER_OP_SQL = {
39
+ eq: "=",
40
+ ne: "<>",
41
+ gt: ">",
42
+ gte: ">=",
43
+ lt: "<",
44
+ lte: "<="
45
+ };
46
+ function isFilterOpValue(v) {
47
+ if (typeof v !== "object" || v === null)
48
+ return false;
49
+ const candidate = v;
50
+ const op = candidate["op"];
51
+ return typeof op === "string" && op in FILTER_OP_SQL && "value" in candidate;
52
+ }
53
+ function assertSafeIdent(name) {
54
+ if (typeof name !== "string" || !IDENT_RE.test(name)) {
55
+ throw new RowStorageError("MISCONFIGURED", `unsafe identifier: ${JSON.stringify(name)}`);
56
+ }
57
+ }
58
+ function quoteIdent(name) {
59
+ return `"${name}"`;
60
+ }
61
+ var DEFAULT_LIMIT = 50;
62
+ var MAX_LIMIT = 1000;
63
+ function d1Adapter(opts) {
64
+ const { db } = opts;
65
+ return {
66
+ async getById(table, id) {
67
+ assertSafeIdent(table);
68
+ const sql = `SELECT * FROM ${quoteIdent(table)} WHERE id = ? LIMIT 1`;
69
+ try {
70
+ const row = await db.prepare(sql).bind(id).first();
71
+ return row ? rowToCamel(row) : null;
72
+ } catch (cause) {
73
+ throw new RowStorageError("QUERY_FAILED", `d1Adapter.getById failed for table "${table}"`, cause);
74
+ }
75
+ },
76
+ async list(table, listOpts) {
77
+ assertSafeIdent(table);
78
+ const whereClauses = [];
79
+ const binds = [];
80
+ if (listOpts?.where) {
81
+ for (const [col, val] of Object.entries(listOpts.where)) {
82
+ const snakeCol = camelToSnakeCase(col);
83
+ assertSafeIdent(snakeCol);
84
+ if (isFilterOpValue(val)) {
85
+ whereClauses.push(`${quoteIdent(snakeCol)} ${FILTER_OP_SQL[val.op]} ?`);
86
+ binds.push(val.value);
87
+ } else {
88
+ whereClauses.push(`${quoteIdent(snakeCol)} = ?`);
89
+ binds.push(val);
90
+ }
91
+ }
92
+ }
93
+ if (listOpts?.cursor !== undefined) {
94
+ whereClauses.push(`id > ?`);
95
+ binds.push(listOpts.cursor);
96
+ }
97
+ let orderBy = `ORDER BY id ASC`;
98
+ if (listOpts?.orderBy && listOpts.orderBy.length > 0) {
99
+ const parts = [];
100
+ for (const { col, dir } of listOpts.orderBy) {
101
+ const snakeCol = camelToSnakeCase(col);
102
+ assertSafeIdent(snakeCol);
103
+ parts.push(`${quoteIdent(snakeCol)} ${dir === "desc" ? "DESC" : "ASC"}`);
104
+ }
105
+ orderBy = `ORDER BY ${parts.join(", ")}`;
106
+ }
107
+ const rawLimit = listOpts?.limit ?? DEFAULT_LIMIT;
108
+ const limit = Math.max(1, Math.min(MAX_LIMIT, rawLimit | 0));
109
+ const whereSql = whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";
110
+ const sql = `SELECT * FROM ${quoteIdent(table)} ${whereSql} ${orderBy} LIMIT ?`.replace(/\s+/g, " ").trim();
111
+ binds.push(limit);
112
+ try {
113
+ const result = await db.prepare(sql).bind(...binds).all();
114
+ const rows = (result.results ?? []).map((r) => rowToCamel(r));
115
+ const nextCursor = rows.length === limit ? rows[rows.length - 1]?.id ?? null : null;
116
+ return {
117
+ rows,
118
+ nextCursor: nextCursor === null ? null : String(nextCursor)
119
+ };
120
+ } catch (cause) {
121
+ throw new RowStorageError("QUERY_FAILED", `d1Adapter.list failed for table "${table}"`, cause);
122
+ }
123
+ },
124
+ async insert(table, row) {
125
+ assertSafeIdent(table);
126
+ const snakeRow = keysToSnake(row);
127
+ const entries = Object.entries(snakeRow);
128
+ const hasId = "id" in snakeRow;
129
+ const id = hasId ? String(snakeRow.id) : crypto.randomUUID();
130
+ const finalEntries = hasId ? entries : [["id", id], ...entries];
131
+ const cols = [];
132
+ const binds = [];
133
+ for (const [k, v] of finalEntries) {
134
+ assertSafeIdent(k);
135
+ cols.push(quoteIdent(k));
136
+ binds.push(v);
137
+ }
138
+ const placeholders = cols.map(() => "?").join(", ");
139
+ const sql = `INSERT INTO ${quoteIdent(table)} (${cols.join(", ")}) VALUES (${placeholders})`;
140
+ try {
141
+ await db.prepare(sql).bind(...binds).run();
142
+ return { id };
143
+ } catch (cause) {
144
+ if (isUniqueViolation(cause))
145
+ throw new UniqueConstraintError(table, cause);
146
+ throw new RowStorageError("INSERT_FAILED", `d1Adapter.insert failed for table "${table}"`, cause);
147
+ }
148
+ },
149
+ async update(table, id, patch) {
150
+ assertSafeIdent(table);
151
+ const snakePatch = keysToSnake(patch);
152
+ snakePatch["updated_at"] = new Date().toISOString();
153
+ const sets = [];
154
+ const binds = [];
155
+ for (const [k, v] of Object.entries(snakePatch)) {
156
+ assertSafeIdent(k);
157
+ sets.push(`${quoteIdent(k)} = ?`);
158
+ binds.push(v);
159
+ }
160
+ binds.push(id);
161
+ const sql = `UPDATE ${quoteIdent(table)} SET ${sets.join(", ")} WHERE id = ?`;
162
+ try {
163
+ await db.prepare(sql).bind(...binds).run();
164
+ } catch (cause) {
165
+ if (isUniqueViolation(cause))
166
+ throw new UniqueConstraintError(table, cause);
167
+ throw new RowStorageError("UPDATE_FAILED", `d1Adapter.update failed for table "${table}"`, cause);
168
+ }
169
+ },
170
+ async delete(table, id) {
171
+ assertSafeIdent(table);
172
+ const sql = `DELETE FROM ${quoteIdent(table)} WHERE id = ?`;
173
+ try {
174
+ await db.prepare(sql).bind(id).run();
175
+ } catch (cause) {
176
+ throw new RowStorageError("DELETE_FAILED", `d1Adapter.delete failed for table "${table}"`, cause);
177
+ }
178
+ }
179
+ };
180
+ }
181
+ export {
182
+ d1Adapter
183
+ };
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@saacms/storage-d1",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": {
7
+ "import": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "default": "./dist/index.js"
10
+ }
11
+ },
12
+ "files": [
13
+ "dist",
14
+ "README.md"
15
+ ],
16
+ "scripts": {
17
+ "build": "tsc --build",
18
+ "typecheck": "tsc --build --noEmit",
19
+ "prepack": "cp package.json package.json.pack-bak && bun run ../../scripts/prepack-pkg.ts",
20
+ "postpack": "mv package.json.pack-bak package.json"
21
+ },
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "dependencies": {
26
+ "@saacms/core": "workspace:*",
27
+ "@cloudflare/workers-types": "^4.20240925.0"
28
+ },
29
+ "devDependencies": {
30
+ "@types/bun": "latest",
31
+ "effect": "^3.10.0",
32
+ "typescript": "^5.7.0"
33
+ },
34
+ "main": "./dist/index.js",
35
+ "types": "./dist/index.d.ts"
36
+ }