@dotteamdev/sensdb 0.1.0 → 0.2.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.
package/README.md CHANGED
@@ -9,15 +9,18 @@ Node 18+, Bun, Deno, and any runtime with a global `fetch`.
9
9
 
10
10
  ## Setup
11
11
 
12
- Copy `sens.ts` into your project (an npm package with prebuilds is on the
13
- roadmap). Start a server:
12
+ ```bash
13
+ npm install @dotteamdev/sensdb
14
+ ```
15
+
16
+ Start a server:
14
17
 
15
18
  ```bash
16
19
  sens serve app.db --port 8080 --token "$(openssl rand -hex 16)"
17
20
  ```
18
21
 
19
22
  ```ts
20
- import { createClient } from "./sens";
23
+ import { createClient } from "@dotteamdev/sensdb";
21
24
 
22
25
  const db = createClient(process.env.SENS_URL!, { token: process.env.SENS_TOKEN });
23
26
 
@@ -79,11 +82,47 @@ export class SensService {
79
82
  }
80
83
  ```
81
84
 
85
+ ## Transactions
86
+
87
+ Group statements into one atomic unit. `transaction()` commits if your callback
88
+ resolves and rolls back if it throws:
89
+
90
+ ```ts
91
+ await db.transaction(async (tx) => {
92
+ await tx.query("UPDATE accounts SET cents = cents - ? WHERE id = ?", [100, 1]);
93
+ await tx.query("UPDATE accounts SET cents = cents + ? WHERE id = ?", [100, 2]);
94
+ // resolves → COMMIT; throws → ROLLBACK (both accounts, atomically)
95
+ });
96
+ ```
97
+
98
+ Manual control, savepoints, and **`PREVIEW`** — a dry-run that returns the exact
99
+ change-set the transaction *would* apply, so an AI agent can inspect before
100
+ committing (no other database offers this as a built-in):
101
+
102
+ ```ts
103
+ const tx = await db.begin();
104
+ await tx.query("DELETE FROM users WHERE last_seen < ?", [cutoff]);
105
+ await tx.savepoint("s1");
106
+ await tx.query("UPDATE orders SET status = 'archived' WHERE user_id IN (SELECT id FROM users)");
107
+
108
+ const change = await tx.preview(); // or tx.preview(true) for per-row old→new
109
+ // change.rows = what COMMIT would apply, WITHOUT applying it — inspect it, then:
110
+ if (looksRight(change)) await tx.commit();
111
+ else await tx.rollback();
112
+ ```
113
+
114
+ The server serializes write transactions (one at a time); a concurrent write
115
+ while a transaction is open throws `SensError` with `status === 409`. Atomicity
116
+ covers relational rows **and** the vector/semantic index together.
117
+
82
118
  ## API
83
119
 
84
120
  - `createClient(baseUrl, { token?, fetch? })` → `SensClient`
85
121
  - `client.query(sql, params?)` → `Promise<{ columns, rows }>` — throws `SensError` on failure
122
+ - `client.begin()` → `Promise<SensTransaction>` — open a transaction (you must `commit()`/`rollback()`)
123
+ - `client.transaction(fn)` → runs `fn(tx)`, commits on resolve, rolls back on throw
86
124
  - `client.health()` → `Promise<{ status, version }>`
125
+ - **`SensTransaction`**: `query`, `preview(full?)`, `savepoint(name)`, `rollbackTo(name)`, `release(name)`, `commit()`, `rollback()`
87
126
 
88
127
  Value mapping (JSON ↔ Sens): number ↔ INTEGER/REAL, string ↔ TEXT,
89
128
  boolean ↔ BOOLEAN, null ↔ NULL, `number[]` ↔ VECTOR.
package/dist/sens.d.ts CHANGED
@@ -17,9 +17,55 @@ export declare class SensError extends Error {
17
17
  readonly status?: number | undefined;
18
18
  constructor(message: string, status?: number | undefined);
19
19
  }
20
+ /**
21
+ * An open multi-statement transaction. Every call is routed to the server's
22
+ * transaction session via the `X-Sens-Txn` token, so the statements form one
23
+ * atomic unit. Finish with `commit()` or `rollback()`. Prefer `transaction()`,
24
+ * which does that for you.
25
+ *
26
+ * The server allows one write transaction at a time; a concurrent write while a
27
+ * transaction is open rejects with `SensError` (HTTP 409). An idle transaction
28
+ * is rolled back by the server after its idle timeout.
29
+ */
30
+ export interface SensTransaction {
31
+ /** Run a (parameterized) statement inside the transaction. */
32
+ query(sql: string, params?: SensValue[]): Promise<SensResult>;
33
+ /**
34
+ * Dry-run: return the change-set this transaction WOULD apply, without
35
+ * committing. `full` (→ `PREVIEW FULL`) adds per-row old→new detail.
36
+ * Lets an agent inspect the pending changes before deciding to commit.
37
+ */
38
+ preview(full?: boolean): Promise<SensResult>;
39
+ /** Establish a savepoint. */
40
+ savepoint(name: string): Promise<void>;
41
+ /** Roll back to a savepoint, discarding work after it (the savepoint stays active). */
42
+ rollbackTo(name: string): Promise<void>;
43
+ /** Release a savepoint, merging its work into the parent. */
44
+ release(name: string): Promise<void>;
45
+ /** Commit the whole transaction. Durable on success. */
46
+ commit(): Promise<void>;
47
+ /** Roll back the whole transaction, discarding everything since it began. */
48
+ rollback(): Promise<void>;
49
+ }
20
50
  export interface SensClient {
21
- /** Run a (parameterized) SQL statement. `?` placeholders are filled from `params`. */
51
+ /** Run a (parameterized) SQL statement (autocommit). `?` placeholders are filled from `params`. */
22
52
  query(sql: string, params?: SensValue[]): Promise<SensResult>;
53
+ /**
54
+ * Open a multi-statement transaction. Returns a handle whose statements run
55
+ * atomically; you MUST `commit()` or `rollback()` it. Prefer `transaction()`.
56
+ */
57
+ begin(): Promise<SensTransaction>;
58
+ /**
59
+ * Run `fn` inside a transaction: commits if it resolves, rolls back if it
60
+ * throws (then rethrows). The idiomatic way to use transactions.
61
+ *
62
+ * const inserted = await db.transaction(async (tx) => {
63
+ * await tx.query("INSERT INTO accounts (id, cents) VALUES (?, ?)", [1, 100]);
64
+ * await tx.query("UPDATE ledger SET total = total + ? WHERE id = ?", [100, 1]);
65
+ * return 1;
66
+ * });
67
+ */
68
+ transaction<T>(fn: (tx: SensTransaction) => Promise<T>): Promise<T>;
23
69
  /** Server health + version. */
24
70
  health(): Promise<{
25
71
  status: string;
package/dist/sens.js CHANGED
@@ -13,6 +13,12 @@ export class SensError extends Error {
13
13
  this.name = "SensError";
14
14
  }
15
15
  }
16
+ /** Savepoint names are SQL identifiers and can't be parameterized, so validate them. */
17
+ function assertIdentifier(name) {
18
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
19
+ throw new SensError(`invalid savepoint name: ${JSON.stringify(name)}`);
20
+ }
21
+ }
16
22
  /**
17
23
  * Create a Sens client bound to a `sens serve` base URL, e.g.
18
24
  * const db = createClient("http://127.0.0.1:8080", { token: process.env.SENS_TOKEN });
@@ -20,30 +26,94 @@ export class SensError extends Error {
20
26
  export function createClient(baseUrl, opts = {}) {
21
27
  const url = baseUrl.replace(/\/+$/, "");
22
28
  const doFetch = opts.fetch ?? fetch;
23
- const headers = { "content-type": "application/json" };
29
+ const baseHeaders = { "content-type": "application/json" };
24
30
  if (opts.token) {
25
- headers["authorization"] = `Bearer ${opts.token}`;
31
+ baseHeaders["authorization"] = `Bearer ${opts.token}`;
32
+ }
33
+ /** POST /query, optionally within a transaction session (txnToken). Returns the parsed body. */
34
+ async function request(sql, params, txnToken) {
35
+ const headers = { ...baseHeaders };
36
+ if (txnToken) {
37
+ headers["x-sens-txn"] = txnToken;
38
+ }
39
+ const res = await doFetch(`${url}/query`, {
40
+ method: "POST",
41
+ headers,
42
+ body: JSON.stringify({ sql, params }),
43
+ });
44
+ const text = await res.text();
45
+ let data;
46
+ try {
47
+ data = JSON.parse(text);
48
+ }
49
+ catch {
50
+ throw new SensError(`invalid response from server: ${text.slice(0, 200)}`, res.status);
51
+ }
52
+ const obj = data;
53
+ if (!res.ok || obj.error) {
54
+ throw new SensError(obj.error ?? `HTTP ${res.status}`, res.status);
55
+ }
56
+ return obj;
57
+ }
58
+ function toResult(obj) {
59
+ return { columns: obj.columns ?? [], rows: obj.rows ?? [] };
60
+ }
61
+ function makeTransaction(token) {
62
+ return {
63
+ async query(sql, params = []) {
64
+ return toResult(await request(sql, params, token));
65
+ },
66
+ async preview(full = false) {
67
+ return toResult(await request(full ? "PREVIEW FULL" : "PREVIEW", [], token));
68
+ },
69
+ async savepoint(name) {
70
+ assertIdentifier(name);
71
+ await request(`SAVEPOINT ${name}`, [], token);
72
+ },
73
+ async rollbackTo(name) {
74
+ assertIdentifier(name);
75
+ await request(`ROLLBACK TO ${name}`, [], token);
76
+ },
77
+ async release(name) {
78
+ assertIdentifier(name);
79
+ await request(`RELEASE ${name}`, [], token);
80
+ },
81
+ async commit() {
82
+ await request("COMMIT", [], token);
83
+ },
84
+ async rollback() {
85
+ await request("ROLLBACK", [], token);
86
+ },
87
+ };
26
88
  }
27
- return {
89
+ const client = {
28
90
  async query(sql, params = []) {
29
- const res = await doFetch(`${url}/query`, {
30
- method: "POST",
31
- headers,
32
- body: JSON.stringify({ sql, params }),
33
- });
34
- const text = await res.text();
35
- let data;
36
- try {
37
- data = JSON.parse(text);
91
+ return toResult(await request(sql, params));
92
+ },
93
+ async begin() {
94
+ const obj = await request("BEGIN", []);
95
+ if (!obj.txn) {
96
+ throw new SensError("server did not return a transaction token for BEGIN");
38
97
  }
39
- catch {
40
- throw new SensError(`invalid response from server: ${text.slice(0, 200)}`, res.status);
98
+ return makeTransaction(obj.txn);
99
+ },
100
+ async transaction(fn) {
101
+ const tx = await client.begin();
102
+ let result;
103
+ try {
104
+ result = await fn(tx);
41
105
  }
42
- const obj = data;
43
- if (!res.ok || obj.error) {
44
- throw new SensError(obj.error ?? `HTTP ${res.status}`, res.status);
106
+ catch (err) {
107
+ try {
108
+ await tx.rollback();
109
+ }
110
+ catch {
111
+ /* keep the original error; the server also idle-reaps an abandoned txn */
112
+ }
113
+ throw err;
45
114
  }
46
- return { columns: obj.columns ?? [], rows: obj.rows ?? [] };
115
+ await tx.commit();
116
+ return result;
47
117
  },
48
118
  async health() {
49
119
  const res = await doFetch(`${url}/health`);
@@ -53,4 +123,5 @@ export function createClient(baseUrl, opts = {}) {
53
123
  return (await res.json());
54
124
  },
55
125
  };
126
+ return client;
56
127
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotteamdev/sensdb",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Zero-dependency TypeScript client for a Sens (sens serve) HTTP/JSON endpoint — relational + AI-native (vector/semantic search) database.",
5
5
  "type": "module",
6
6
  "main": "./dist/sens.js",
package/sens.ts CHANGED
@@ -23,9 +23,56 @@ export class SensError extends Error {
23
23
  }
24
24
  }
25
25
 
26
+ /**
27
+ * An open multi-statement transaction. Every call is routed to the server's
28
+ * transaction session via the `X-Sens-Txn` token, so the statements form one
29
+ * atomic unit. Finish with `commit()` or `rollback()`. Prefer `transaction()`,
30
+ * which does that for you.
31
+ *
32
+ * The server allows one write transaction at a time; a concurrent write while a
33
+ * transaction is open rejects with `SensError` (HTTP 409). An idle transaction
34
+ * is rolled back by the server after its idle timeout.
35
+ */
36
+ export interface SensTransaction {
37
+ /** Run a (parameterized) statement inside the transaction. */
38
+ query(sql: string, params?: SensValue[]): Promise<SensResult>;
39
+ /**
40
+ * Dry-run: return the change-set this transaction WOULD apply, without
41
+ * committing. `full` (→ `PREVIEW FULL`) adds per-row old→new detail.
42
+ * Lets an agent inspect the pending changes before deciding to commit.
43
+ */
44
+ preview(full?: boolean): Promise<SensResult>;
45
+ /** Establish a savepoint. */
46
+ savepoint(name: string): Promise<void>;
47
+ /** Roll back to a savepoint, discarding work after it (the savepoint stays active). */
48
+ rollbackTo(name: string): Promise<void>;
49
+ /** Release a savepoint, merging its work into the parent. */
50
+ release(name: string): Promise<void>;
51
+ /** Commit the whole transaction. Durable on success. */
52
+ commit(): Promise<void>;
53
+ /** Roll back the whole transaction, discarding everything since it began. */
54
+ rollback(): Promise<void>;
55
+ }
56
+
26
57
  export interface SensClient {
27
- /** Run a (parameterized) SQL statement. `?` placeholders are filled from `params`. */
58
+ /** Run a (parameterized) SQL statement (autocommit). `?` placeholders are filled from `params`. */
28
59
  query(sql: string, params?: SensValue[]): Promise<SensResult>;
60
+ /**
61
+ * Open a multi-statement transaction. Returns a handle whose statements run
62
+ * atomically; you MUST `commit()` or `rollback()` it. Prefer `transaction()`.
63
+ */
64
+ begin(): Promise<SensTransaction>;
65
+ /**
66
+ * Run `fn` inside a transaction: commits if it resolves, rolls back if it
67
+ * throws (then rethrows). The idiomatic way to use transactions.
68
+ *
69
+ * const inserted = await db.transaction(async (tx) => {
70
+ * await tx.query("INSERT INTO accounts (id, cents) VALUES (?, ?)", [1, 100]);
71
+ * await tx.query("UPDATE ledger SET total = total + ? WHERE id = ?", [100, 1]);
72
+ * return 1;
73
+ * });
74
+ */
75
+ transaction<T>(fn: (tx: SensTransaction) => Promise<T>): Promise<T>;
29
76
  /** Server health + version. */
30
77
  health(): Promise<{ status: string; version: string }>;
31
78
  }
@@ -37,6 +84,13 @@ export interface SensClientOptions {
37
84
  fetch?: typeof fetch;
38
85
  }
39
86
 
87
+ /** Savepoint names are SQL identifiers and can't be parameterized, so validate them. */
88
+ function assertIdentifier(name: string): void {
89
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
90
+ throw new SensError(`invalid savepoint name: ${JSON.stringify(name)}`);
91
+ }
92
+ }
93
+
40
94
  /**
41
95
  * Create a Sens client bound to a `sens serve` base URL, e.g.
42
96
  * const db = createClient("http://127.0.0.1:8080", { token: process.env.SENS_TOKEN });
@@ -44,30 +98,101 @@ export interface SensClientOptions {
44
98
  export function createClient(baseUrl: string, opts: SensClientOptions = {}): SensClient {
45
99
  const url = baseUrl.replace(/\/+$/, "");
46
100
  const doFetch = opts.fetch ?? fetch;
47
- const headers: Record<string, string> = { "content-type": "application/json" };
101
+ const baseHeaders: Record<string, string> = { "content-type": "application/json" };
48
102
  if (opts.token) {
49
- headers["authorization"] = `Bearer ${opts.token}`;
103
+ baseHeaders["authorization"] = `Bearer ${opts.token}`;
104
+ }
105
+
106
+ /** POST /query, optionally within a transaction session (txnToken). Returns the parsed body. */
107
+ async function request(
108
+ sql: string,
109
+ params: SensValue[],
110
+ txnToken?: string,
111
+ ): Promise<{ error?: string; columns?: string[]; rows?: SensValue[][]; txn?: string }> {
112
+ const headers: Record<string, string> = { ...baseHeaders };
113
+ if (txnToken) {
114
+ headers["x-sens-txn"] = txnToken;
115
+ }
116
+ const res = await doFetch(`${url}/query`, {
117
+ method: "POST",
118
+ headers,
119
+ body: JSON.stringify({ sql, params }),
120
+ });
121
+ const text = await res.text();
122
+ let data: unknown;
123
+ try {
124
+ data = JSON.parse(text);
125
+ } catch {
126
+ throw new SensError(`invalid response from server: ${text.slice(0, 200)}`, res.status);
127
+ }
128
+ const obj = data as { error?: string; columns?: string[]; rows?: SensValue[][]; txn?: string };
129
+ if (!res.ok || obj.error) {
130
+ throw new SensError(obj.error ?? `HTTP ${res.status}`, res.status);
131
+ }
132
+ return obj;
50
133
  }
51
134
 
52
- return {
135
+ function toResult(obj: { columns?: string[]; rows?: SensValue[][] }): SensResult {
136
+ return { columns: obj.columns ?? [], rows: obj.rows ?? [] };
137
+ }
138
+
139
+ function makeTransaction(token: string): SensTransaction {
140
+ return {
141
+ async query(sql: string, params: SensValue[] = []): Promise<SensResult> {
142
+ return toResult(await request(sql, params, token));
143
+ },
144
+ async preview(full = false): Promise<SensResult> {
145
+ return toResult(await request(full ? "PREVIEW FULL" : "PREVIEW", [], token));
146
+ },
147
+ async savepoint(name: string): Promise<void> {
148
+ assertIdentifier(name);
149
+ await request(`SAVEPOINT ${name}`, [], token);
150
+ },
151
+ async rollbackTo(name: string): Promise<void> {
152
+ assertIdentifier(name);
153
+ await request(`ROLLBACK TO ${name}`, [], token);
154
+ },
155
+ async release(name: string): Promise<void> {
156
+ assertIdentifier(name);
157
+ await request(`RELEASE ${name}`, [], token);
158
+ },
159
+ async commit(): Promise<void> {
160
+ await request("COMMIT", [], token);
161
+ },
162
+ async rollback(): Promise<void> {
163
+ await request("ROLLBACK", [], token);
164
+ },
165
+ };
166
+ }
167
+
168
+ const client: SensClient = {
53
169
  async query(sql: string, params: SensValue[] = []): Promise<SensResult> {
54
- const res = await doFetch(`${url}/query`, {
55
- method: "POST",
56
- headers,
57
- body: JSON.stringify({ sql, params }),
58
- });
59
- const text = await res.text();
60
- let data: unknown;
61
- try {
62
- data = JSON.parse(text);
63
- } catch {
64
- throw new SensError(`invalid response from server: ${text.slice(0, 200)}`, res.status);
170
+ return toResult(await request(sql, params));
171
+ },
172
+
173
+ async begin(): Promise<SensTransaction> {
174
+ const obj = await request("BEGIN", []);
175
+ if (!obj.txn) {
176
+ throw new SensError("server did not return a transaction token for BEGIN");
65
177
  }
66
- const obj = data as { error?: string; columns?: string[]; rows?: SensValue[][] };
67
- if (!res.ok || obj.error) {
68
- throw new SensError(obj.error ?? `HTTP ${res.status}`, res.status);
178
+ return makeTransaction(obj.txn);
179
+ },
180
+
181
+ async transaction<T>(fn: (tx: SensTransaction) => Promise<T>): Promise<T> {
182
+ const tx = await client.begin();
183
+ let result: T;
184
+ try {
185
+ result = await fn(tx);
186
+ } catch (err) {
187
+ try {
188
+ await tx.rollback();
189
+ } catch {
190
+ /* keep the original error; the server also idle-reaps an abandoned txn */
191
+ }
192
+ throw err;
69
193
  }
70
- return { columns: obj.columns ?? [], rows: obj.rows ?? [] };
194
+ await tx.commit();
195
+ return result;
71
196
  },
72
197
 
73
198
  async health(): Promise<{ status: string; version: string }> {
@@ -78,4 +203,6 @@ export function createClient(baseUrl: string, opts: SensClientOptions = {}): Sen
78
203
  return (await res.json()) as { status: string; version: string };
79
204
  },
80
205
  };
206
+
207
+ return client;
81
208
  }