@uniflowed/sql 0.19.0 → 0.21.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.
@@ -7,11 +7,14 @@
7
7
  //
8
8
  // const db = fromBetterSqlite3(new Database("app.db"));
9
9
  //
10
+ // The handle has `query` and `querySync`. `querySync` and `transactionSync`
11
+ // run on this thread, which is what sqlc's `sync: true` output calls.
12
+ //
10
13
  // Tested with its prebuilt native addon in `tests/sqlc/external-adapters.mjs`,
11
14
  // including exact integers, nulls, transactions and savepoints.
12
15
 
13
- import type { Queryable, SqlParam } from "../index.js";
14
- import { SqlError, singleConnection } from "../index.js";
16
+ import type { Queryable, RunSync, SqlParam, SyncQueryable } from "../index.js";
17
+ import { SqlError, singleConnectionSync } from "../index.js";
15
18
  import { DEFAULT_STATEMENT_CACHE, count, rowId, statementCache } from "../internal/statements.js";
16
19
 
17
20
  /** The part of better-sqlite3's `Statement` this uses. */
@@ -38,11 +41,11 @@ export type BetterSqlite3Options = {|
38
41
  readonly maxParams?: number,
39
42
  |};
40
43
 
41
- /** A [`Queryable`] over one better-sqlite3 `Database`. */
44
+ /** A [`Queryable`] and a [`SyncQueryable`] over one better-sqlite3 `Database`. */
42
45
  export function fromBetterSqlite3(
43
46
  database: BetterSqlite3Database,
44
47
  options: BetterSqlite3Options = {},
45
- ): Queryable {
48
+ ): Queryable & SyncQueryable {
46
49
  const statement = statementCache((text) => {
47
50
  const prepared = database.prepare(text);
48
51
  prepared.safeIntegers(true);
@@ -53,28 +56,30 @@ export function fromBetterSqlite3(
53
56
  }
54
57
  return prepared;
55
58
  }, options.statementCache ?? DEFAULT_STATEMENT_CACHE);
56
- return singleConnection({
59
+ const runSync: RunSync = (text, params, mode) => {
60
+ const prepared = statement(text);
61
+ if (mode === "rows" && prepared.reader) {
62
+ const rows: Array<$ReadOnlyArray<mixed>> = [];
63
+ for (const row of prepared.all(...params)) {
64
+ if (!Array.isArray(row)) {
65
+ throw new SqlError({ kind: "unsupported", feature: "better-sqlite3 rows as objects" });
66
+ }
67
+ rows.push(row);
68
+ }
69
+ return { rows, rowsAffected: 0, lastInsertId: null };
70
+ }
71
+ const result = prepared.run(...params);
72
+ return {
73
+ rows: [],
74
+ rowsAffected: count(result.changes),
75
+ lastInsertId: rowId(result.lastInsertRowid),
76
+ };
77
+ };
78
+ return singleConnectionSync({
57
79
  engine: "sqlite",
58
80
  maxParams: options.maxParams ?? 32766,
59
81
  begin: options.begin,
60
- run: async (text, params, mode) => {
61
- const prepared = statement(text);
62
- if (mode === "rows" && prepared.reader) {
63
- const rows: Array<$ReadOnlyArray<mixed>> = [];
64
- for (const row of prepared.all(...params)) {
65
- if (!Array.isArray(row)) {
66
- throw new SqlError({ kind: "unsupported", feature: "better-sqlite3 rows as objects" });
67
- }
68
- rows.push(row);
69
- }
70
- return { rows, rowsAffected: 0, lastInsertId: null };
71
- }
72
- const result = prepared.run(...params);
73
- return {
74
- rows: [],
75
- rowsAffected: count(result.changes),
76
- lastInsertId: rowId(result.lastInsertRowid),
77
- };
78
- },
82
+ run: async (text, params, mode) => runSync(text, params, mode),
83
+ runSync,
79
84
  });
80
85
  }
@@ -7,11 +7,14 @@
7
7
  //
8
8
  // const db = fromBunSqlite(new Database("app.db"));
9
9
  //
10
+ // The handle has `query` and `querySync`. `querySync` and `transactionSync`
11
+ // run on this thread, which is what sqlc's `sync: true` output calls.
12
+ //
10
13
  // Safe integers are turned on per statement, so the `Database` does not have
11
14
  // to be opened with `safeIntegers: true` — though it does no harm.
12
15
 
13
- import type { Queryable, SqlParam } from "../index.js";
14
- import { SqlError, singleConnection } from "../index.js";
16
+ import type { Queryable, RunSync, SqlParam, SyncQueryable } from "../index.js";
17
+ import { SqlError, singleConnectionSync } from "../index.js";
15
18
  import { DEFAULT_STATEMENT_CACHE, count, rowId, statementCache } from "../internal/statements.js";
16
19
 
17
20
  /** The part of `bun:sqlite`'s `Statement` this uses. */
@@ -36,38 +39,40 @@ export type BunSqliteOptions = {|
36
39
  readonly maxParams?: number,
37
40
  |};
38
41
 
39
- /** A [`Queryable`] over one `bun:sqlite` `Database`. */
42
+ /** A [`Queryable`] and a [`SyncQueryable`] over one `bun:sqlite` `Database`. */
40
43
  export function fromBunSqlite(
41
44
  database: BunSqliteDatabase,
42
45
  options: BunSqliteOptions = {},
43
- ): Queryable {
46
+ ): Queryable & SyncQueryable {
44
47
  const statement = statementCache((text) => {
45
48
  const prepared = database.prepare(text);
46
49
  prepared.safeIntegers(true);
47
50
  return prepared;
48
51
  }, options.statementCache ?? DEFAULT_STATEMENT_CACHE);
49
- return singleConnection({
52
+ const runSync: RunSync = (text, params, mode) => {
53
+ const prepared = statement(text);
54
+ if (mode === "rows") {
55
+ const rows: Array<$ReadOnlyArray<mixed>> = [];
56
+ for (const row of prepared.values(...params)) {
57
+ if (!Array.isArray(row)) {
58
+ throw new SqlError({ kind: "unsupported", feature: "bun:sqlite rows as objects" });
59
+ }
60
+ rows.push(row);
61
+ }
62
+ return { rows, rowsAffected: 0, lastInsertId: null };
63
+ }
64
+ const result = prepared.run(...params);
65
+ return {
66
+ rows: [],
67
+ rowsAffected: count(result.changes),
68
+ lastInsertId: rowId(result.lastInsertRowid),
69
+ };
70
+ };
71
+ return singleConnectionSync({
50
72
  engine: "sqlite",
51
73
  maxParams: options.maxParams ?? 32766,
52
74
  begin: options.begin,
53
- run: async (text, params, mode) => {
54
- const prepared = statement(text);
55
- if (mode === "rows") {
56
- const rows: Array<$ReadOnlyArray<mixed>> = [];
57
- for (const row of prepared.values(...params)) {
58
- if (!Array.isArray(row)) {
59
- throw new SqlError({ kind: "unsupported", feature: "bun:sqlite rows as objects" });
60
- }
61
- rows.push(row);
62
- }
63
- return { rows, rowsAffected: 0, lastInsertId: null };
64
- }
65
- const result = prepared.run(...params);
66
- return {
67
- rows: [],
68
- rowsAffected: count(result.changes),
69
- lastInsertId: rowId(result.lastInsertRowid),
70
- };
71
- },
75
+ run: async (text, params, mode) => runSync(text, params, mode),
76
+ runSync,
72
77
  });
73
78
  }
@@ -7,12 +7,15 @@
7
7
  //
8
8
  // const db = fromNodeSqlite(new DatabaseSync("app.db"));
9
9
  //
10
+ // The handle has `query` and `querySync`. `querySync` and `transactionSync`
11
+ // run on this thread, which is what sqlc's `sync: true` output calls.
12
+ //
10
13
  // Needs `StatementSync#setReturnArrays`, which Node added in 24.0 and 22.16.
11
14
  // An older Node gets an error that says so, rather than rows keyed by column
12
15
  // name — which would silently merge two columns that share one.
13
16
 
14
- import type { Queryable, SqlParam } from "../index.js";
15
- import { SqlError, singleConnection } from "../index.js";
17
+ import type { Queryable, RunSync, SqlParam, SyncQueryable } from "../index.js";
18
+ import { SqlError, singleConnectionSync } from "../index.js";
16
19
  import { DEFAULT_STATEMENT_CACHE, count, rowId, statementCache } from "../internal/statements.js";
17
20
 
18
21
  /** The part of `node:sqlite`'s `StatementSync` this uses. */
@@ -56,38 +59,40 @@ function prepared(database: NodeSqliteDatabase, text: string): NodeSqliteStateme
56
59
  return statement;
57
60
  }
58
61
 
59
- /** A [`Queryable`] over one `DatabaseSync`. */
62
+ /** A [`Queryable`] and a [`SyncQueryable`] over one `DatabaseSync`. */
60
63
  export function fromNodeSqlite(
61
64
  database: NodeSqliteDatabase,
62
65
  options: NodeSqliteOptions = {},
63
- ): Queryable {
66
+ ): Queryable & SyncQueryable {
64
67
  const statement = statementCache(
65
68
  (text) => prepared(database, text),
66
69
  options.statementCache ?? DEFAULT_STATEMENT_CACHE,
67
70
  );
68
- return singleConnection({
71
+ const runSync: RunSync = (text, params, mode) => {
72
+ const prepared = statement(text);
73
+ if (mode === "rows") {
74
+ const rows = prepared.all(...params);
75
+ const arrays: Array<$ReadOnlyArray<mixed>> = [];
76
+ for (const row of rows) {
77
+ if (!Array.isArray(row)) {
78
+ throw new SqlError({ kind: "unsupported", feature: "node:sqlite rows as objects" });
79
+ }
80
+ arrays.push(row);
81
+ }
82
+ return { rows: arrays, rowsAffected: 0, lastInsertId: null };
83
+ }
84
+ const result = prepared.run(...params);
85
+ return {
86
+ rows: [],
87
+ rowsAffected: count(result.changes),
88
+ lastInsertId: rowId(result.lastInsertRowid),
89
+ };
90
+ };
91
+ return singleConnectionSync({
69
92
  engine: "sqlite",
70
93
  maxParams: options.maxParams ?? 32766,
71
94
  begin: options.begin,
72
- run: async (text, params, mode) => {
73
- const prepared = statement(text);
74
- if (mode === "rows") {
75
- const rows = prepared.all(...params);
76
- const arrays: Array<$ReadOnlyArray<mixed>> = [];
77
- for (const row of rows) {
78
- if (!Array.isArray(row)) {
79
- throw new SqlError({ kind: "unsupported", feature: "node:sqlite rows as objects" });
80
- }
81
- arrays.push(row);
82
- }
83
- return { rows: arrays, rowsAffected: 0, lastInsertId: null };
84
- }
85
- const result = prepared.run(...params);
86
- return {
87
- rows: [],
88
- rowsAffected: count(result.changes),
89
- lastInsertId: rowId(result.lastInsertRowid),
90
- };
91
- },
95
+ run: async (text, params, mode) => runSync(text, params, mode),
96
+ runSync,
92
97
  });
93
98
  }
package/index.js CHANGED
@@ -18,6 +18,10 @@
18
18
  // * **The parts of sqlc's semantics a driver does not have**: `sqlc.slice`
19
19
  // expansion, `:copyfrom` as chunked multi-row inserts, `:batch*` in one
20
20
  // transaction, and nested transactions as savepoints.
21
+ // * **A synchronous path** for drivers that run a statement on the calling
22
+ // thread (`node:sqlite`, `better-sqlite3`, `bun:sqlite`). Generated code
23
+ // with `sync: true` calls it, so a transaction can stay inside `BEGIN`
24
+ // through `COMMIT` without yielding.
21
25
  //
22
26
  // docs/sqlc.md is the design record.
23
27
 
@@ -70,6 +74,30 @@ export type Queryable = {
70
74
  ...
71
75
  };
72
76
 
77
+ /**
78
+ * A connection whose statements run on the calling thread and return before
79
+ * the call does.
80
+ *
81
+ * This is the contract `sync: true` generated code needs. `node:sqlite`,
82
+ * `better-sqlite3` and `bun:sqlite` implement it on the same object as
83
+ * [`Queryable`]. Drivers that are asynchronous all the way down (D1, `pg`,
84
+ * `postgres`, `mysql2`) do not have it, and a synchronous function does not
85
+ * typecheck against them.
86
+ *
87
+ * `transactionSync` does not yield between `BEGIN` and `COMMIT`. Nested
88
+ * `transactionSync` opens a savepoint. Calling the outer handle while a
89
+ * transaction is open throws: an asynchronous transaction cannot be waited
90
+ * out on this thread, and reaching back to the outer handle from inside the
91
+ * body would deadlock the same way `await db.query` does inside `transaction`.
92
+ */
93
+ export type SyncQueryable = {
94
+ readonly engine: Engine,
95
+ readonly maxParams: number,
96
+ querySync(text: string, params: $ReadOnlyArray<SqlParam>, mode: QueryMode): QueryResult,
97
+ readonly transactionSync?: <T>(body: (tx: SyncQueryable) => T) => T,
98
+ ...
99
+ };
100
+
73
101
  /** A JSON document, as `json` and `jsonb` columns decode to. */
74
102
  export type JsonValue =
75
103
  | null
@@ -169,6 +197,29 @@ function checkWidth(row: Row, width: number): void {
169
197
  }
170
198
  }
171
199
 
200
+ function unsupported(feature: string): empty {
201
+ throw new SqlError({ kind: "unsupported", feature });
202
+ }
203
+
204
+ function firstRow<T>(rows: $ReadOnlyArray<Row>, width: number, decode: (row: Row) => T): T | null {
205
+ if (rows.length === 0) {
206
+ return null;
207
+ }
208
+ const row = rows[0];
209
+ checkWidth(row, width);
210
+ return decode(row);
211
+ }
212
+
213
+ function allRows<T>(rows: $ReadOnlyArray<Row>, width: number, decode: (row: Row) => T): Array<T> {
214
+ const out: Array<T> = new Array(rows.length);
215
+ for (let i = 0; i < rows.length; i += 1) {
216
+ const row = rows[i];
217
+ checkWidth(row, width);
218
+ out[i] = decode(row);
219
+ }
220
+ return out;
221
+ }
222
+
172
223
  // ---------------------------------------------------------------------------
173
224
  // Running a generated query.
174
225
  //
@@ -187,12 +238,24 @@ export async function one<T>(
187
238
  ): Promise<T | null> {
188
239
  try {
189
240
  const { rows } = await db.query(text, params, "rows");
190
- if (rows.length === 0) {
191
- return null;
192
- }
193
- const row = rows[0];
194
- checkWidth(row, width);
195
- return decode(row);
241
+ return firstRow(rows, width, decode);
242
+ } catch (error) {
243
+ throw attribute(error, name);
244
+ }
245
+ }
246
+
247
+ /** `:one` on a [`SyncQueryable`]. */
248
+ export function oneSync<T>(
249
+ db: SyncQueryable,
250
+ name: string,
251
+ text: string,
252
+ params: $ReadOnlyArray<SqlParam>,
253
+ width: number,
254
+ decode: (row: Row) => T,
255
+ ): T | null {
256
+ try {
257
+ const { rows } = db.querySync(text, params, "rows");
258
+ return firstRow(rows, width, decode);
196
259
  } catch (error) {
197
260
  throw attribute(error, name);
198
261
  }
@@ -209,13 +272,24 @@ export async function many<T>(
209
272
  ): Promise<Array<T>> {
210
273
  try {
211
274
  const { rows } = await db.query(text, params, "rows");
212
- const out: Array<T> = new Array(rows.length);
213
- for (let i = 0; i < rows.length; i += 1) {
214
- const row = rows[i];
215
- checkWidth(row, width);
216
- out[i] = decode(row);
217
- }
218
- return out;
275
+ return allRows(rows, width, decode);
276
+ } catch (error) {
277
+ throw attribute(error, name);
278
+ }
279
+ }
280
+
281
+ /** `:many` on a [`SyncQueryable`]. */
282
+ export function manySync<T>(
283
+ db: SyncQueryable,
284
+ name: string,
285
+ text: string,
286
+ params: $ReadOnlyArray<SqlParam>,
287
+ width: number,
288
+ decode: (row: Row) => T,
289
+ ): Array<T> {
290
+ try {
291
+ const { rows } = db.querySync(text, params, "rows");
292
+ return allRows(rows, width, decode);
219
293
  } catch (error) {
220
294
  throw attribute(error, name);
221
295
  }
@@ -234,6 +308,19 @@ async function run(
234
308
  }
235
309
  }
236
310
 
311
+ function executed(
312
+ db: SyncQueryable,
313
+ name: string,
314
+ text: string,
315
+ params: $ReadOnlyArray<SqlParam>,
316
+ ): QueryResult {
317
+ try {
318
+ return db.querySync(text, params, "exec");
319
+ } catch (error) {
320
+ throw attribute(error, name);
321
+ }
322
+ }
323
+
237
324
  /** `:exec`. */
238
325
  export async function exec(
239
326
  db: Queryable,
@@ -244,6 +331,16 @@ export async function exec(
244
331
  await run(db, name, text, params);
245
332
  }
246
333
 
334
+ /** `:exec` on a [`SyncQueryable`]. */
335
+ export function execSync(
336
+ db: SyncQueryable,
337
+ name: string,
338
+ text: string,
339
+ params: $ReadOnlyArray<SqlParam>,
340
+ ): void {
341
+ executed(db, name, text, params);
342
+ }
343
+
247
344
  /** `:execrows` — how many rows the statement changed. */
248
345
  export async function execRows(
249
346
  db: Queryable,
@@ -254,6 +351,16 @@ export async function execRows(
254
351
  return (await run(db, name, text, params)).rowsAffected;
255
352
  }
256
353
 
354
+ /** `:execrows` on a [`SyncQueryable`]. */
355
+ export function execRowsSync(
356
+ db: SyncQueryable,
357
+ name: string,
358
+ text: string,
359
+ params: $ReadOnlyArray<SqlParam>,
360
+ ): number {
361
+ return executed(db, name, text, params).rowsAffected;
362
+ }
363
+
257
364
  /** What `:execresult` resolves to. */
258
365
  export type ExecResult = {|
259
366
  readonly rowsAffected: number,
@@ -271,6 +378,24 @@ export async function execResult(
271
378
  return { rowsAffected, lastInsertId };
272
379
  }
273
380
 
381
+ /** `:execresult` on a [`SyncQueryable`]. */
382
+ export function execResultSync(
383
+ db: SyncQueryable,
384
+ name: string,
385
+ text: string,
386
+ params: $ReadOnlyArray<SqlParam>,
387
+ ): ExecResult {
388
+ const { rowsAffected, lastInsertId } = executed(db, name, text, params);
389
+ return { rowsAffected, lastInsertId };
390
+ }
391
+
392
+ function insertedId(result: QueryResult, name: string): bigint {
393
+ if (result.lastInsertId === null) {
394
+ throw new SqlError({ kind: "unsupported", feature: ":execlastid on PostgreSQL" }, name);
395
+ }
396
+ return result.lastInsertId;
397
+ }
398
+
274
399
  /**
275
400
  * `:execlastid` — the id the statement inserted.
276
401
  *
@@ -283,11 +408,17 @@ export async function execLastId(
283
408
  text: string,
284
409
  params: $ReadOnlyArray<SqlParam>,
285
410
  ): Promise<bigint> {
286
- const { lastInsertId } = await run(db, name, text, params);
287
- if (lastInsertId === null) {
288
- throw new SqlError({ kind: "unsupported", feature: ":execlastid on PostgreSQL" }, name);
289
- }
290
- return lastInsertId;
411
+ return insertedId(await run(db, name, text, params), name);
412
+ }
413
+
414
+ /** `:execlastid` on a [`SyncQueryable`]. */
415
+ export function execLastIdSync(
416
+ db: SyncQueryable,
417
+ name: string,
418
+ text: string,
419
+ params: $ReadOnlyArray<SqlParam>,
420
+ ): bigint {
421
+ return insertedId(executed(db, name, text, params), name);
291
422
  }
292
423
 
293
424
  // ---------------------------------------------------------------------------
@@ -363,6 +494,39 @@ export type CopyPlan = {|
363
494
  /** The most rows one statement carries when a row has no parameters at all. */
364
495
  const COPY_ROWS_WITHOUT_PARAMS = 1000;
365
496
 
497
+ function copyChunk(
498
+ engine: Engine,
499
+ plan: CopyPlan,
500
+ chunk: $ReadOnlyArray<$ReadOnlyArray<SqlParam>>,
501
+ name: string,
502
+ ): {| readonly text: string, readonly params: $ReadOnlyArray<SqlParam> |} {
503
+ const width = plan.refs.length;
504
+ const params: Array<SqlParam> = [];
505
+ const tuples: Array<string> = [];
506
+ for (const row of chunk) {
507
+ let tuple = plan.tuple[0];
508
+ for (let i = 0; i < width; i += 1) {
509
+ const index = plan.refs[i];
510
+ if (index >= row.length) {
511
+ throw new SqlError({ kind: "row", index, length: row.length }, name);
512
+ }
513
+ params.push(row[index]);
514
+ tuple += (engine === "postgresql" ? `$${params.length}` : "?") + plan.tuple[i + 1];
515
+ }
516
+ tuples.push(tuple);
517
+ }
518
+ return { text: plan.head + tuples.join(", ") + plan.tail, params };
519
+ }
520
+
521
+ function copyWidth(db: { readonly maxParams: number, ... }, plan: CopyPlan, name: string): number {
522
+ const width = plan.refs.length;
523
+ // One row is the smallest statement. Wider than `maxParams`, it cannot be sent.
524
+ if (width > db.maxParams) {
525
+ throw new SqlError({ kind: "params", width, max: db.maxParams }, name);
526
+ }
527
+ return width === 0 ? COPY_ROWS_WITHOUT_PARAMS : Math.max(1, Math.floor(db.maxParams / width));
528
+ }
529
+
366
530
  /**
367
531
  * `:copyfrom` — insert every row, as few statements as `maxParams` allows.
368
532
  *
@@ -380,33 +544,12 @@ export async function copyFrom(
380
544
  if (rows.length === 0) {
381
545
  return 0;
382
546
  }
383
- const width = plan.refs.length;
384
- // One row is the smallest statement. Wider than `maxParams`, it cannot be sent.
385
- if (width > db.maxParams) {
386
- throw new SqlError({ kind: "params", width, max: db.maxParams }, name);
387
- }
388
- const perStatement =
389
- width === 0 ? COPY_ROWS_WITHOUT_PARAMS : Math.max(1, Math.floor(db.maxParams / width));
547
+ const perStatement = copyWidth(db, plan, name);
390
548
  const insert = async (q: Queryable): Promise<number> => {
391
549
  let total = 0;
392
550
  for (let start = 0; start < rows.length; start += perStatement) {
393
- const chunk = rows.slice(start, start + perStatement);
394
- const params: Array<SqlParam> = [];
395
- const tuples: Array<string> = [];
396
- for (const row of chunk) {
397
- let tuple = plan.tuple[0];
398
- for (let i = 0; i < width; i += 1) {
399
- const index = plan.refs[i];
400
- if (index >= row.length) {
401
- throw new SqlError({ kind: "row", index, length: row.length }, name);
402
- }
403
- params.push(row[index]);
404
- tuple += (q.engine === "postgresql" ? `$${params.length}` : "?") + plan.tuple[i + 1];
405
- }
406
- tuples.push(tuple);
407
- }
408
- const text = plan.head + tuples.join(", ") + plan.tail;
409
- total += (await run(q, name, text, params)).rowsAffected;
551
+ const statement = copyChunk(q.engine, plan, rows.slice(start, start + perStatement), name);
552
+ total += (await run(q, name, statement.text, statement.params)).rowsAffected;
410
553
  }
411
554
  return total;
412
555
  };
@@ -417,6 +560,32 @@ export async function copyFrom(
417
560
  return transaction(insert);
418
561
  }
419
562
 
563
+ /** `:copyfrom` on a [`SyncQueryable`]. */
564
+ export function copyFromSync(
565
+ db: SyncQueryable,
566
+ name: string,
567
+ plan: CopyPlan,
568
+ rows: $ReadOnlyArray<$ReadOnlyArray<SqlParam>>,
569
+ ): number {
570
+ if (rows.length === 0) {
571
+ return 0;
572
+ }
573
+ const perStatement = copyWidth(db, plan, name);
574
+ const insert = (q: SyncQueryable): number => {
575
+ let total = 0;
576
+ for (let start = 0; start < rows.length; start += perStatement) {
577
+ const statement = copyChunk(q.engine, plan, rows.slice(start, start + perStatement), name);
578
+ total += executed(q, name, statement.text, statement.params).rowsAffected;
579
+ }
580
+ return total;
581
+ };
582
+ const transaction = db.transactionSync;
583
+ if (transaction === undefined || rows.length <= perStatement) {
584
+ return insert(db);
585
+ }
586
+ return transaction(insert);
587
+ }
588
+
420
589
  // ---------------------------------------------------------------------------
421
590
  // `:batchexec`, `:batchmany`, `:batchone`.
422
591
 
@@ -447,6 +616,31 @@ export async function batch<A, R>(
447
616
  return transaction(each);
448
617
  }
449
618
 
619
+ /**
620
+ * [`batch`] on a [`SyncQueryable`].
621
+ *
622
+ * The body returns its value directly. Where `transactionSync` is absent the
623
+ * items still run in order, on this thread.
624
+ */
625
+ export function batchSync<A, R>(
626
+ db: SyncQueryable,
627
+ items: $ReadOnlyArray<A>,
628
+ body: (q: SyncQueryable, item: A) => R,
629
+ ): Array<R> {
630
+ const each = (q: SyncQueryable): Array<R> => {
631
+ const out: Array<R> = [];
632
+ for (const item of items) {
633
+ out.push(body(q, item));
634
+ }
635
+ return out;
636
+ };
637
+ const transaction = db.transactionSync;
638
+ if (transaction === undefined || items.length <= 1) {
639
+ return each(db);
640
+ }
641
+ return transaction(each);
642
+ }
643
+
450
644
  // ---------------------------------------------------------------------------
451
645
  // Transactions, for adapters.
452
646
 
@@ -457,11 +651,20 @@ export type Run = (
457
651
  mode: QueryMode,
458
652
  ) => Promise<QueryResult>;
459
653
 
654
+ /** [`Run`] for a driver that finishes the statement before returning. */
655
+ export type RunSync = (
656
+ text: string,
657
+ params: $ReadOnlyArray<SqlParam>,
658
+ mode: QueryMode,
659
+ ) => QueryResult;
660
+
460
661
  /** What [`transactionOn`] needs from an adapter. */
461
662
  export type Connection = {|
462
663
  readonly engine: Engine,
463
664
  readonly maxParams: number,
464
665
  readonly run: Run,
666
+ /** Present when the driver can also run a statement on the calling thread. */
667
+ readonly runSync?: RunSync,
465
668
  /** The statement that opens a transaction; `BEGIN` unless the adapter knows better. */
466
669
  readonly begin?: string,
467
670
  |};
@@ -532,32 +735,150 @@ async function scoped<T>(
532
735
  }
533
736
  throw error;
534
737
  }
738
+ // A failed COMMIT leaves the transaction open. Roll it back before the
739
+ // error leaves, or the next statement runs inside a transaction this
740
+ // handle has already forgotten. A nested RELEASE fails the same way;
741
+ // `ROLLBACK TO` then `RELEASE` is the body-error path.
535
742
  open = false;
536
- await connection.run(commit, [], "exec");
743
+ try {
744
+ await connection.run(commit, [], "exec");
745
+ } catch (error) {
746
+ try {
747
+ await connection.run(rollback, [], "exec");
748
+ if (depth > 0) {
749
+ await connection.run(commit, [], "exec");
750
+ }
751
+ } catch {
752
+ // The rollback failed too — usually because the connection is gone.
753
+ // The error worth reporting is the commit's.
754
+ }
755
+ throw error;
756
+ }
537
757
  return value;
538
758
  }
539
759
 
540
760
  /**
541
- * A [`Queryable`] over one connection that serves a whole application: a
542
- * SQLite database, or an embedded PostgreSQL.
761
+ * [`transactionOn`] for a driver with [`RunSync`].
543
762
  *
544
- * While a transaction is open every statement run on the returned value waits
545
- * for it to finish, so a request that is not in the transaction never lands
546
- * inside it. Statements run on the transaction's own [`Queryable`] do not wait.
763
+ * The body runs to completion before this returns. `ROLLBACK` runs when it
764
+ * throws, and the original error is rethrown; a failed rollback is swallowed
765
+ * so it cannot hide that error. The [`SyncQueryable`] the body receives
766
+ * refuses statements after this returns.
547
767
  */
548
- export function singleConnection(connection: Connection): Queryable {
768
+ export function transactionOnSync<T>(connection: Connection, body: (tx: SyncQueryable) => T): T {
769
+ const runSync =
770
+ connection.runSync ?? unsupported("a synchronous transaction on an asynchronous connection");
771
+ runSync(connection.begin ?? "BEGIN", [], "exec");
772
+ return scopedSync(runSync, connection, 0, body, "COMMIT", "ROLLBACK");
773
+ }
774
+
775
+ function scopedSync<T>(
776
+ runSync: RunSync,
777
+ connection: Connection,
778
+ depth: number,
779
+ body: (tx: SyncQueryable) => T,
780
+ commit: string,
781
+ rollback: string,
782
+ ): T {
783
+ let open = true;
784
+ const guarded: RunSync = (text, params, mode) => {
785
+ if (!open) {
786
+ throw new SqlError({ kind: "closed" });
787
+ }
788
+ return runSync(text, params, mode);
789
+ };
790
+ const tx: SyncQueryable = {
791
+ engine: connection.engine,
792
+ maxParams: connection.maxParams,
793
+ querySync: guarded,
794
+ transactionSync: <U>(inner: (tx: SyncQueryable) => U): U => {
795
+ const savepoint = `uf_sp_${depth + 1}`;
796
+ guarded(`SAVEPOINT ${savepoint}`, [], "exec");
797
+ return scopedSync(
798
+ guarded,
799
+ connection,
800
+ depth + 1,
801
+ inner,
802
+ `RELEASE SAVEPOINT ${savepoint}`,
803
+ `ROLLBACK TO SAVEPOINT ${savepoint}`,
804
+ );
805
+ },
806
+ };
807
+ let value: T;
808
+ try {
809
+ value = body(tx);
810
+ } catch (error) {
811
+ open = false;
812
+ try {
813
+ runSync(rollback, [], "exec");
814
+ if (depth > 0) {
815
+ // `ROLLBACK TO` leaves the savepoint in place; release it so the
816
+ // enclosing transaction can go on as if the inner one never started.
817
+ runSync(commit, [], "exec");
818
+ }
819
+ } catch {
820
+ // The rollback failed too — usually because the connection is gone,
821
+ // which rolls back on its own. The error worth reporting is the first.
822
+ }
823
+ throw error;
824
+ }
825
+ // Same as `scoped`: a failed COMMIT or RELEASE must not clear the
826
+ // transaction while the database still holds it.
827
+ open = false;
828
+ try {
829
+ runSync(commit, [], "exec");
830
+ } catch (error) {
831
+ try {
832
+ runSync(rollback, [], "exec");
833
+ if (depth > 0) {
834
+ runSync(commit, [], "exec");
835
+ }
836
+ } catch {
837
+ // The rollback failed too. The error worth reporting is the commit's.
838
+ }
839
+ throw error;
840
+ }
841
+ return value;
842
+ }
843
+
844
+ type Handle = Queryable & SyncQueryable;
845
+
846
+ /**
847
+ * One connection serving a whole application: a SQLite database, or an
848
+ * embedded PostgreSQL.
849
+ *
850
+ * While an asynchronous transaction is open, every asynchronous statement on
851
+ * the returned value waits for it to finish, so a request that is not in the
852
+ * transaction never lands inside it. Statements on the transaction's own
853
+ * handle do not wait. A synchronous call during that wait throws, because
854
+ * this thread cannot block until the promise settles.
855
+ *
856
+ * A synchronous transaction does not yield, so nothing else runs until it
857
+ * returns. A call on this outer handle from inside that body throws: waiting
858
+ * for the body to finish is the body itself.
859
+ */
860
+ function connect(connection: Connection): Handle {
549
861
  let gate: Promise<void> | null = null;
862
+ let syncOpen = false;
863
+ const runSync = connection.runSync;
864
+
550
865
  const query = async (
551
866
  text: string,
552
867
  params: $ReadOnlyArray<SqlParam>,
553
868
  mode: QueryMode,
554
869
  ): Promise<QueryResult> => {
870
+ if (syncOpen) {
871
+ unsupported("an asynchronous query while a synchronous transaction is open");
872
+ }
555
873
  while (gate !== null) {
556
874
  await gate;
557
875
  }
558
876
  return connection.run(text, params, mode);
559
877
  };
560
878
  const transaction = async <T>(body: (tx: Queryable) => Promise<T>): Promise<T> => {
879
+ if (syncOpen) {
880
+ unsupported("an asynchronous transaction while a synchronous transaction is open");
881
+ }
561
882
  while (gate !== null) {
562
883
  await gate;
563
884
  }
@@ -572,5 +893,72 @@ export function singleConnection(connection: Connection): Queryable {
572
893
  release();
573
894
  }
574
895
  };
575
- return { engine: connection.engine, maxParams: connection.maxParams, query, transaction };
896
+ const querySync = (
897
+ text: string,
898
+ params: $ReadOnlyArray<SqlParam>,
899
+ mode: QueryMode,
900
+ ): QueryResult => {
901
+ const run =
902
+ connection.runSync ?? unsupported("a synchronous query on an asynchronous connection");
903
+ if (gate !== null) {
904
+ unsupported("a synchronous query while an asynchronous transaction is open");
905
+ }
906
+ if (syncOpen) {
907
+ unsupported(
908
+ "a synchronous query on the outer connection while a synchronous transaction is open",
909
+ );
910
+ }
911
+ return run(text, params, mode);
912
+ };
913
+ const transactionSync = <T>(body: (tx: SyncQueryable) => T): T => {
914
+ if (runSync == null) {
915
+ unsupported("a synchronous transaction on an asynchronous connection");
916
+ }
917
+ if (gate !== null) {
918
+ unsupported("a synchronous transaction while an asynchronous transaction is open");
919
+ }
920
+ if (syncOpen) {
921
+ unsupported(
922
+ "a synchronous transaction on the outer connection while a synchronous transaction is open",
923
+ );
924
+ }
925
+ syncOpen = true;
926
+ try {
927
+ return transactionOnSync(connection, body);
928
+ } finally {
929
+ syncOpen = false;
930
+ }
931
+ };
932
+ return {
933
+ engine: connection.engine,
934
+ maxParams: connection.maxParams,
935
+ query,
936
+ transaction,
937
+ querySync,
938
+ transactionSync,
939
+ };
940
+ }
941
+
942
+ /**
943
+ * A [`Queryable`] over one connection.
944
+ *
945
+ * The returned object also has `querySync` and `transactionSync` when
946
+ * `connection.runSync` is set. [`singleConnectionSync`] is that fact in the
947
+ * type, for the SQLite adapters.
948
+ */
949
+ export function singleConnection(connection: Connection): Queryable {
950
+ return connect(connection);
951
+ }
952
+
953
+ /**
954
+ * [`singleConnection`] typed as both [`Queryable`] and [`SyncQueryable`].
955
+ *
956
+ * Requires `connection.runSync`. Without it this throws, rather than handing
957
+ * back a handle whose synchronous methods fail on first use.
958
+ */
959
+ export function singleConnectionSync(connection: Connection): Queryable & SyncQueryable {
960
+ if (connection.runSync == null) {
961
+ unsupported("a synchronous connection");
962
+ }
963
+ return connect(connection);
576
964
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniflowed/sql",
3
- "version": "0.19.0",
3
+ "version": "0.21.0",
4
4
  "description": "The runtime for sqlc's Flow target: exact codecs and driver adapters, part of the Unified Toolchain for Flow.",
5
5
  "type": "module",
6
6
  "license": "MIT",