@uniflowed/sql 0.19.0 → 0.20.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
  |};
@@ -538,26 +741,113 @@ async function scoped<T>(
538
741
  }
539
742
 
540
743
  /**
541
- * A [`Queryable`] over one connection that serves a whole application: a
542
- * SQLite database, or an embedded PostgreSQL.
744
+ * [`transactionOn`] for a driver with [`RunSync`].
543
745
  *
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.
746
+ * The body runs to completion before this returns. `ROLLBACK` runs when it
747
+ * throws, and the original error is rethrown; a failed rollback is swallowed
748
+ * so it cannot hide that error. The [`SyncQueryable`] the body receives
749
+ * refuses statements after this returns.
547
750
  */
548
- export function singleConnection(connection: Connection): Queryable {
751
+ export function transactionOnSync<T>(connection: Connection, body: (tx: SyncQueryable) => T): T {
752
+ const runSync =
753
+ connection.runSync ?? unsupported("a synchronous transaction on an asynchronous connection");
754
+ runSync(connection.begin ?? "BEGIN", [], "exec");
755
+ return scopedSync(runSync, connection, 0, body, "COMMIT", "ROLLBACK");
756
+ }
757
+
758
+ function scopedSync<T>(
759
+ runSync: RunSync,
760
+ connection: Connection,
761
+ depth: number,
762
+ body: (tx: SyncQueryable) => T,
763
+ commit: string,
764
+ rollback: string,
765
+ ): T {
766
+ let open = true;
767
+ const guarded: RunSync = (text, params, mode) => {
768
+ if (!open) {
769
+ throw new SqlError({ kind: "closed" });
770
+ }
771
+ return runSync(text, params, mode);
772
+ };
773
+ const tx: SyncQueryable = {
774
+ engine: connection.engine,
775
+ maxParams: connection.maxParams,
776
+ querySync: guarded,
777
+ transactionSync: <U>(inner: (tx: SyncQueryable) => U): U => {
778
+ const savepoint = `uf_sp_${depth + 1}`;
779
+ guarded(`SAVEPOINT ${savepoint}`, [], "exec");
780
+ return scopedSync(
781
+ guarded,
782
+ connection,
783
+ depth + 1,
784
+ inner,
785
+ `RELEASE SAVEPOINT ${savepoint}`,
786
+ `ROLLBACK TO SAVEPOINT ${savepoint}`,
787
+ );
788
+ },
789
+ };
790
+ let value: T;
791
+ try {
792
+ value = body(tx);
793
+ } catch (error) {
794
+ open = false;
795
+ try {
796
+ runSync(rollback, [], "exec");
797
+ if (depth > 0) {
798
+ // `ROLLBACK TO` leaves the savepoint in place; release it so the
799
+ // enclosing transaction can go on as if the inner one never started.
800
+ runSync(commit, [], "exec");
801
+ }
802
+ } catch {
803
+ // The rollback failed too — usually because the connection is gone,
804
+ // which rolls back on its own. The error worth reporting is the first.
805
+ }
806
+ throw error;
807
+ }
808
+ open = false;
809
+ runSync(commit, [], "exec");
810
+ return value;
811
+ }
812
+
813
+ type Handle = Queryable & SyncQueryable;
814
+
815
+ /**
816
+ * One connection serving a whole application: a SQLite database, or an
817
+ * embedded PostgreSQL.
818
+ *
819
+ * While an asynchronous transaction is open, every asynchronous statement on
820
+ * the returned value waits for it to finish, so a request that is not in the
821
+ * transaction never lands inside it. Statements on the transaction's own
822
+ * handle do not wait. A synchronous call during that wait throws, because
823
+ * this thread cannot block until the promise settles.
824
+ *
825
+ * A synchronous transaction does not yield, so nothing else runs until it
826
+ * returns. A call on this outer handle from inside that body throws: waiting
827
+ * for the body to finish is the body itself.
828
+ */
829
+ function connect(connection: Connection): Handle {
549
830
  let gate: Promise<void> | null = null;
831
+ let syncOpen = false;
832
+ const runSync = connection.runSync;
833
+
550
834
  const query = async (
551
835
  text: string,
552
836
  params: $ReadOnlyArray<SqlParam>,
553
837
  mode: QueryMode,
554
838
  ): Promise<QueryResult> => {
839
+ if (syncOpen) {
840
+ unsupported("an asynchronous query while a synchronous transaction is open");
841
+ }
555
842
  while (gate !== null) {
556
843
  await gate;
557
844
  }
558
845
  return connection.run(text, params, mode);
559
846
  };
560
847
  const transaction = async <T>(body: (tx: Queryable) => Promise<T>): Promise<T> => {
848
+ if (syncOpen) {
849
+ unsupported("an asynchronous transaction while a synchronous transaction is open");
850
+ }
561
851
  while (gate !== null) {
562
852
  await gate;
563
853
  }
@@ -572,5 +862,72 @@ export function singleConnection(connection: Connection): Queryable {
572
862
  release();
573
863
  }
574
864
  };
575
- return { engine: connection.engine, maxParams: connection.maxParams, query, transaction };
865
+ const querySync = (
866
+ text: string,
867
+ params: $ReadOnlyArray<SqlParam>,
868
+ mode: QueryMode,
869
+ ): QueryResult => {
870
+ const run =
871
+ connection.runSync ?? unsupported("a synchronous query on an asynchronous connection");
872
+ if (gate !== null) {
873
+ unsupported("a synchronous query while an asynchronous transaction is open");
874
+ }
875
+ if (syncOpen) {
876
+ unsupported(
877
+ "a synchronous query on the outer connection while a synchronous transaction is open",
878
+ );
879
+ }
880
+ return run(text, params, mode);
881
+ };
882
+ const transactionSync = <T>(body: (tx: SyncQueryable) => T): T => {
883
+ if (runSync == null) {
884
+ unsupported("a synchronous transaction on an asynchronous connection");
885
+ }
886
+ if (gate !== null) {
887
+ unsupported("a synchronous transaction while an asynchronous transaction is open");
888
+ }
889
+ if (syncOpen) {
890
+ unsupported(
891
+ "a synchronous transaction on the outer connection while a synchronous transaction is open",
892
+ );
893
+ }
894
+ syncOpen = true;
895
+ try {
896
+ return transactionOnSync(connection, body);
897
+ } finally {
898
+ syncOpen = false;
899
+ }
900
+ };
901
+ return {
902
+ engine: connection.engine,
903
+ maxParams: connection.maxParams,
904
+ query,
905
+ transaction,
906
+ querySync,
907
+ transactionSync,
908
+ };
909
+ }
910
+
911
+ /**
912
+ * A [`Queryable`] over one connection.
913
+ *
914
+ * The returned object also has `querySync` and `transactionSync` when
915
+ * `connection.runSync` is set. [`singleConnectionSync`] is that fact in the
916
+ * type, for the SQLite adapters.
917
+ */
918
+ export function singleConnection(connection: Connection): Queryable {
919
+ return connect(connection);
920
+ }
921
+
922
+ /**
923
+ * [`singleConnection`] typed as both [`Queryable`] and [`SyncQueryable`].
924
+ *
925
+ * Requires `connection.runSync`. Without it this throws, rather than handing
926
+ * back a handle whose synchronous methods fail on first use.
927
+ */
928
+ export function singleConnectionSync(connection: Connection): Queryable & SyncQueryable {
929
+ if (connection.runSync == null) {
930
+ unsupported("a synchronous connection");
931
+ }
932
+ return connect(connection);
576
933
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniflowed/sql",
3
- "version": "0.19.0",
3
+ "version": "0.20.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",