diff --git a/README.md b/README.md index 5417c59..7197cec 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ The switches come across, including `bigIntMode` and `temporal`, because a pool ## What works today -`connect`, `query`, `exec`, `stream`, `close`, `dispose` and `await using`. `duplicate`, for a second connection made from the first. Named parameters both ways, including lists, records and nesting. Every scalar the engine has, plus nodes, edges and paths with their tables named rather than numbered, and `ZuDate`, `ZuTime`, `ZuTimestamp` and `ZuDuration`, with `{ temporal: true }` and `toTemporal()` for the runtimes that have `Temporal`. Read-only connections, databases in memory, memory and thread limits. `bigIntMode`, per statement or per connection. An `AbortSignal` on any statement. The full error surface above, and `isZuError` to recognize it. Streaming, as an async iterable, as batches and as a Web Stream. Transactions, with `inTransaction` on the connection. An appender, for loading rows a batch at a time, and `load` for building a whole database out of columns and an edge list. Registered frames, so an Arrow table or an object of typed arrays is something a statement can match on without the rows being copied. `columnar`, for a result read down its columns as the buffers themselves rather than across its rows as objects. Prepared statements, compiled at the line that asked and run as often as wanted, and `explain` and `profile`, as a tree a program walks and as the listing a person reads. Both module formats, typed separately. +`connect`, `query`, `exec`, `stream`, `close`, `dispose` and `await using`. `duplicate`, for a second connection made from the first. Named parameters both ways, including lists, records and nesting. Every scalar the engine has, plus nodes, edges and paths with their tables named rather than numbered, and `ZuDate`, `ZuTime`, `ZuTimestamp` and `ZuDuration`, with `{ temporal: true }` and `toTemporal()` for the runtimes that have `Temporal`. Read-only connections, databases in memory, memory and thread limits. `bigIntMode`, per statement or per connection. An `AbortSignal` on any statement, and `rowsRead` and `progress` for watching the one running now. The full error surface above, and `isZuError` to recognize it. Streaming, as an async iterable, as batches and as a Web Stream. Transactions, with `inTransaction` on the connection. An appender, for loading rows a batch at a time, and `load` for building a whole database out of columns and an edge list. Registered frames, so an Arrow table or an object of typed arrays is something a statement can match on without the rows being copied. `columnar`, for a result read down its columns as the buffers themselves rather than across its rows as objects. Prepared statements, compiled at the line that asked and run as often as wanted, and `explain` and `profile`, as a tree a program walks and as the listing a person reads. Both module formats, typed separately. Build it with `npm run build`, and run the suite with `npm test`. Nothing is published yet, so `npm i zudb` is not a thing you can type at anybody's terminal, but everything it will do is built and installed on every run of the release workflow. @@ -91,6 +91,19 @@ It is the signal JavaScript already has, so a timeout written like the one above What the promise rejects with is the signal's own reason, which is what `fetch` does: `AbortSignal.timeout(50)` rejects with the runtime's `TimeoutError`, `controller.abort(new RequestGone())` rejects with the `RequestGone` you made, and a bare `controller.abort()` rejects with the runtime's `AbortError`. A signal that has already fired stops the statement before the engine sees it at all. A signal that never fires costs one listener, taken off again when the statement ends, whether it answered, failed or was stopped. +## Watching one run + +A statement that takes a minute is one somebody is sitting in front of, so a connection says how far the one running now has got: + +```ts +using watch = conn.progress((rows) => process.stdout.write(`\r${rows} rows read`)); +const answer = await conn.query(statement); +``` + +`rowsRead` is the number underneath it, and it is a property rather than a call because reading it must never wait: the statement is on a threadpool thread holding the connection's lock, and this is an atomic beside the lock rather than a question through it. It counts rows read out of storage rather than rows answered, because the statement somebody is waiting on is exactly the one that reads a hundred million rows to answer one. It starts again at zero at each statement and holds its last value once one ends, so `conn.rowsRead` after a statement is what that statement cost. + +`progress` is a timer around that number, a tenth of a second apart unless you say otherwise with `{ everyMs }`. The callback runs only when the count has moved, which is what makes a watch on an idle connection quiet, and the timer does not hold the event loop open, so a watch nobody stopped is not a program that never exits. Stop it with `stop()` or by leaving the scope of the `using`. Nothing calls into JavaScript from the thread doing the scanning, which is the point: the statement being watched does not know it is being watched and does not slow down for it. + ## Reading a result a piece at a time `conn.stream(...)` runs the same statement and hands the rows over as they are made, instead of building the whole answer first: diff --git a/binding.d.cts b/binding.d.cts index fb9cba8..ed35b83 100644 --- a/binding.d.cts +++ b/binding.d.cts @@ -569,6 +569,33 @@ export interface ZuProfile { readonly text: string } +/** + * What a watch on a running statement takes. + */ +export interface ZuProgressOptions { + /** + * How long to wait between looks, in milliseconds. A tenth of a + * second by default, which is about where a person stops reading a + * number and starts seeing it move. + * + * What one look costs is an atomic read, so this is a question about + * how often the callback should run rather than about how much the + * watch costs the statement. + */ + readonly everyMs?: number +} + +/** + * A watch on a running statement, which is stopped by `stop()` or by + * leaving the scope of a `using`. + * + * Stopping twice does nothing, and so does stopping one that has + * already been left behind. + */ +export interface ZuProgress extends Disposable { + stop(): void +} + /** * What a streamed statement takes beside its parameters. */ @@ -827,6 +854,28 @@ export declare class Connection { * atomic. */ get inTransaction(): boolean + /** + * How many rows the statement running on this connection has read + * out of storage, for showing a person that something is + * happening. + * + * Rows read rather than rows answered, because the statement + * somebody is waiting on is exactly the one that reads a hundred + * million rows to answer one. It starts at zero at each statement + * and holds its last value once one ends. + * + * This is the one thing on a connection that is worth reading + * while a statement runs, and it is answered the way + * [`Connection::open`] is: an atomic beside the lock rather than a + * question through it. So the loop's thread gets its answer while + * the threadpool thread is still scanning, and `progress()` is the + * timer written around it. + * + * A number rather than a bigint, like every other count this + * client makes rather than reads out of a column: a statement that + * had read 2^53 rows would have been running for weeks. + */ + get rowsRead(): number /** * Starts a transaction and hands it back. * diff --git a/etc/zudb.api.md b/etc/zudb.api.md index 457d549..89f320c 100644 --- a/etc/zudb.api.md +++ b/etc/zudb.api.md @@ -44,6 +44,7 @@ export class Connection { get readOnly(): boolean register(name: string, data: ZuFrame): Promise registered(): Promise + get rowsRead(): number transaction(options?: ZuTransactionOptions | null): Promise unregister(name: string): Promise } @@ -370,6 +371,17 @@ export interface ZuProfile { readonly text: string } +// @public +export interface ZuProgress extends Disposable { + // (undocumented) + stop(): void +} + +// @public +export interface ZuProgressOptions { + readonly everyMs?: number +} + // @public export class ZuRel { // (undocumented) diff --git a/src/conn.rs b/src/conn.rs index 8bb085a..540c31a 100644 --- a/src/conn.rs +++ b/src/conn.rs @@ -479,6 +479,30 @@ impl Connection { self.in_txn.load(Ordering::Acquire) } + /// How many rows the statement running on this connection has read + /// out of storage, for showing a person that something is + /// happening. + /// + /// Rows read rather than rows answered, because the statement + /// somebody is waiting on is exactly the one that reads a hundred + /// million rows to answer one. It starts at zero at each statement + /// and holds its last value once one ends. + /// + /// This is the one thing on a connection that is worth reading + /// while a statement runs, and it is answered the way + /// [`Connection::open`] is: an atomic beside the lock rather than a + /// question through it. So the loop's thread gets its answer while + /// the threadpool thread is still scanning, and `progress()` is the + /// timer written around it. + /// + /// A number rather than a bigint, like every other count this + /// client makes rather than reads out of a column: a statement that + /// had read 2^53 rows would have been running for weeks. + #[napi(getter)] + pub fn rows_read(&self) -> f64 { + self.interrupt.rows() as f64 + } + /// Starts a transaction and hands it back. /// /// It starts here rather than at the first statement inside it, so a @@ -1150,6 +1174,24 @@ pub(crate) fn with( answered } +/// A statement is about to run, and the counter it reports its rows +/// through starts again at zero. +/// +/// Called where the connection becomes one statement's, which is the +/// only moment the count can be reset without racing the statement +/// reading it: the lock is held here and the reader is a getter that +/// takes no lock at all. Held rather than cleared afterwards, so that +/// `rowsRead` after a statement is what that statement cost. +/// +/// The word an interrupt is raised through is put down by the same +/// call, which is the reason this happens before the signal is entered +/// rather than after: a signal that fired while nothing was running +/// raised nothing to put down, and one that fires from here on is +/// answered by the watch instead. +pub(crate) fn began(conn: &mut zudb::Connection) { + conn.interrupt().clear(); +} + /// What a closed connection says, wherever it is noticed. pub(crate) const CLOSED: &str = "the connection is closed, so there is nothing left to run a statement on"; @@ -1470,6 +1512,7 @@ impl QueryTask { // being able to. A signal that fired first ends the // statement without the engine ever seeing it, which is the // whole point of asking. + began(conn); if let Some(watch) = watch && !watch.enter() { diff --git a/src/plan.rs b/src/plan.rs index 5d3a83c..9cb1e5e 100644 --- a/src/plan.rs +++ b/src/plan.rs @@ -47,7 +47,7 @@ use zudb::query::Value; use zudb::{OpProfile, PlanNode, Profile, QueryPlan, StageProfile, ZuError}; use crate::cancel::Watch; -use crate::conn::{Failure, Handles, failed, with}; +use crate::conn::{Failure, Handles, began, failed, with}; /// Compiling a statement to see what it would do. pub struct PlanTask { @@ -136,7 +136,9 @@ impl<'task> ScopedTask<'task> for ProfileTask { move |conn| { // A profile is a run, so it is stoppable exactly the way // a run is: the signal is entered when the connection - // becomes this call's and left when it stops being. + // becomes this call's and left when it stops being, and + // the row counter starts again the way it does for one. + began(conn); if let Some(watch) = watch && !watch.enter() { diff --git a/src/stream.rs b/src/stream.rs index a311d3a..b92c8f4 100644 --- a/src/stream.rs +++ b/src/stream.rs @@ -34,7 +34,7 @@ use zudb::query::Value; use zudb::{Batch, Flow, Streamed, ZuError}; use crate::cancel::{Guard, Watch}; -use crate::conn::{CLOSED, Failure, STREAMING, beside, failed, notices}; +use crate::conn::{CLOSED, Failure, STREAMING, began, beside, failed, notices}; use crate::value::{Shape, Spelling, to_js}; /// How many batches may sit between the statement and the reader. @@ -674,6 +674,7 @@ impl Started { // From here the connection is this statement's, so this is // where a signal can start stopping it. A signal that fired // first ends the statement without the engine ever seeing it. + began(conn); if let Some(guard) = &self.guard && !guard.enter() { diff --git a/test/progress.test.mjs b/test/progress.test.mjs new file mode 100644 index 0000000..0b59ca9 --- /dev/null +++ b/test/progress.test.mjs @@ -0,0 +1,228 @@ +// Watching a statement that is still running. +// +// The counter is the engine's, read from the loop's thread while the +// statement holds a threadpool thread, so what these ask is whether the +// number moves during the statement rather than after it, whether the +// callback is quiet when the number is not moving, and whether a watch +// nobody stopped costs the program anything. + +import assert from 'node:assert/strict' +import { execFile } from 'node:child_process' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import test from 'node:test' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' + +import { connect, load } from 'zudb' + +import { twoPeople } from './helper.mjs' + +// Every pair of people, filtered, which is a statement that spends real +// time in the executor and reads its rows out of storage while it does. +// The filter is what keeps it honest: without one the optimizer answers +// a cross product by arithmetic and never reads a second row. +const WORK = 'MATCH (a:person), (b:person) WHERE a.uid < b.uid RETURN count(a) AS n' + +// Enough people that the statement above takes a few hundred +// milliseconds and reads its rows in several pieces rather than one, so +// there is something for a watch to see more than once. The scan is +// claimed a morsel at a time and the counter moves a morsel at a time +// with it. +const PEOPLE = 3000 + +const run = promisify(execFile) +const root = fileURLToPath(new URL('../', import.meta.url)) +const sleep = (ms) => new Promise((wake) => setTimeout(wake, ms)) + +// A database with a crowd in it, built by a load rather than by an +// insert, because three thousand people written as one statement is a +// megabyte of GQL and most of this file's time would go on parsing it. +async function crowd(t) { + const dir = await mkdtemp(join(tmpdir(), 'zu-node-progress-')) + t.after(() => rm(dir, { recursive: true, force: true })) + const path = join(dir, 'crowd.zu1') + await load(path, { + nodes: 'person', + columns: { uid: Array.from({ length: PEOPLE }, (_, ix) => ix + 1) }, + }) + const conn = await connect(path, { readOnly: true }) + t.after(() => conn.close()) + return { conn, path } +} + +test('rows read moves while the statement reading them runs', async (t) => { + const { conn } = await crowd(t) + + const running = conn.query(WORK) + await sleep(50) + + // The read itself is half the claim: it happens on the loop's thread + // while the statement holds the connection's lock, so it answers here + // rather than after the statement has let the lock go. + const midway = conn.rowsRead + assert.ok(midway > 0, `a statement 50ms into its run had read ${midway} rows`) + + await running + assert.ok(conn.rowsRead >= midway, 'the count went backwards') +}) + +test('rows read holds what the last statement cost and starts again at the next', async (t) => { + const { conn } = await twoPeople(t) + + await conn.query('MATCH (p:person) RETURN p.name AS name') + const first = conn.rowsRead + assert.ok(first >= 2, `two people came back as ${first} rows read`) + + // Held rather than cleared, so a caller who wants to know what the + // statement they just ran cost can ask afterwards. + assert.equal(conn.rowsRead, first) + + // The same statement again reads the same rows, so a counter that + // started over reads the same number and one that kept counting reads + // twice it. + await conn.query('MATCH (p:person) RETURN p.name AS name') + assert.equal(conn.rowsRead, first) +}) + +test('rows read is a number rather than a bigint', async (t) => { + const { conn } = await twoPeople(t) + await conn.query('MATCH (p:person) RETURN p.name AS name') + + // Every value a statement gives back is a bigint and every count this + // client makes is a number. This is a count. + assert.equal(typeof conn.rowsRead, 'number') + assert.ok(Number.isInteger(conn.rowsRead)) +}) + +test('a watch is called with the rows as they are read', async (t) => { + const { conn } = await crowd(t) + + const seen = [] + const watch = conn.progress((rows) => seen.push(rows), { everyMs: 10 }) + await conn.query(WORK) + watch.stop() + + assert.ok(seen.length >= 2, `the watch was called ${seen.length} times during the statement`) + assert.ok( + seen.every((rows) => typeof rows === 'number' && rows > 0), + `a call carried something other than a count: ${seen.join(', ')}`, + ) + // Rows read only goes up inside a statement, so counts that went down + // would mean the watch was reading something else. + const climbing = seen.every((rows, ix) => ix === 0 || rows >= seen[ix - 1]) + assert.ok(climbing, `the counts went backwards: ${seen.join(', ')}`) +}) + +test('a watch says nothing while the connection is idle', async (t) => { + const { conn } = await twoPeople(t) + await conn.query('MATCH (p:person) RETURN p.name AS name') + + // The number a finished statement left behind is not news, and a + // progress bar redrawn five times a second on a connection nobody is + // using is what the callback is kept quiet for. + const seen = [] + const watch = conn.progress((rows) => seen.push(rows), { everyMs: 5 }) + await sleep(60) + watch.stop() + + assert.deepEqual(seen, []) +}) + +test('the interval is the one the caller named, and a long one never fires', async (t) => { + const { conn } = await crowd(t) + + const often = [] + const rarely = [] + const first = conn.progress((rows) => often.push(rows)) + await conn.query(WORK) + first.stop() + + const second = conn.progress((rows) => rarely.push(rows), { everyMs: 60_000 }) + await conn.query(WORK) + second.stop() + + // The default is short enough to see a statement of a few hundred + // milliseconds, which is the whole reason there is a default. + assert.ok(often.length >= 1, 'the default interval saw nothing') + assert.deepEqual(rarely, [], 'an interval longer than the statement fired anyway') +}) + +test('a stopped watch stops calling, and stopping twice is not an error', async (t) => { + const { conn } = await crowd(t) + + const seen = [] + const watch = conn.progress((rows) => seen.push(rows), { everyMs: 5 }) + const running = conn.query(WORK) + await sleep(80) + watch.stop() + watch.stop() + const before = seen.length + assert.ok(before > 0, 'the watch saw nothing before it was stopped') + + await running + await sleep(30) + assert.equal(seen.length, before, 'a stopped watch was called again') +}) + +test('leaving the scope of a using stops the watch', async (t) => { + const { conn } = await crowd(t) + const seen = [] + + async function watched() { + using watch = conn.progress((rows) => seen.push(rows), { everyMs: 5 }) + assert.equal(typeof watch.stop, 'function') + await conn.query(WORK) + } + + await watched() + const counted = seen.length + assert.ok(counted > 0, 'the watch saw nothing inside the block') + + // Nothing is left running past the block, which is the reason to + // write `using` rather than a `stop()` in a `finally`. + await conn.query(WORK) + assert.equal(seen.length, counted, 'the watch outlived its block') +}) + +test('a watch wants a function, and an interval that is one', async (t) => { + const { conn } = await twoPeople(t) + + for (const bad of [null, undefined, 42, 'later', {}]) { + assert.throws(() => conn.progress(bad), TypeError, `a ${typeof bad} was taken as a callback`) + } + + const noop = () => {} + for (const everyMs of [0, -1, Number.NaN, Number.POSITIVE_INFINITY, '100', {}]) { + assert.throws( + () => conn.progress(noop, { everyMs }), + RangeError, + `${String(everyMs)} was taken as an interval`, + ) + } + + // The ways of saying nothing, all of which take the default. + for (const options of [null, undefined, {}, { everyMs: undefined }]) { + conn.progress(noop, options).stop() + } +}) + +test('a watch does not hold the program open', async (t) => { + const { path } = await crowd(t) + + // A watch nobody stopped is a timer that would otherwise run for as + // long as the process does, which is a program that prints its answer + // and then hangs. Only another process can be asked whether it + // exited. + const program = ` + const { connect } = require(${JSON.stringify(root)}) + connect(${JSON.stringify(path)}, { readOnly: true }).then(async (conn) => { + conn.progress(() => {}, { everyMs: 5 }) + await conn.query('MATCH (p:person) RETURN count(*) AS n') + console.log('done') + }) + ` + const { stdout } = await run(process.execPath, ['-e', program], { timeout: 30_000 }) + assert.equal(stdout.trim(), 'done') +}) diff --git a/test/types/esm.mts b/test/types/esm.mts index a596f10..380298f 100644 --- a/test/types/esm.mts +++ b/test/types/esm.mts @@ -22,6 +22,7 @@ import { type ZuPlainDate, type ZuPlan, type ZuPlanNode, + type ZuProgress, type ZuRows, type ZuStream, type ZuSummary, @@ -284,3 +285,23 @@ export async function scans(path: string): Promise { node.op === 'ScanNodes' || node.children.some(walk) return plan.root === null ? false : walk(plan.root) } + +export async function watched(path: string): Promise { + await using conn = await connect(path, { readOnly: true }) + + // A watch is disposable, and the plain kind rather than the async + // kind, because stopping a timer is not something to wait for. + let seen = 0 + using watch: ZuProgress = conn.progress((rows: number) => { + seen = rows + }, { everyMs: 250 }) + + await conn.query('MATCH (p:person) RETURN p.id AS id') + + // Both halves of it are there for a caller who would rather stop it + // by hand, and the count on the connection is a number like the one + // the callback is handed. + watch.stop() + const rowsRead: number = conn.rowsRead + return Math.max(seen, rowsRead) +} diff --git a/types/header.d.ts b/types/header.d.ts index ac20380..56f34d0 100644 --- a/types/header.d.ts +++ b/types/header.d.ts @@ -569,6 +569,34 @@ export interface ZuProfile { readonly text: string } +/** + * What a watch on a running statement takes. + */ +export interface ZuProgressOptions { + /** + * How long to wait between looks, in milliseconds. A tenth of a + * second by default, which is about where a person stops reading a + * number and starts seeing it move. + * + * What one look costs is an atomic read, so this is a question about + * how often the callback should run rather than about how much the + * watch costs the statement. + */ + readonly everyMs?: number +} + +/** + * A watch on a running statement, which is stopped by `stop()` or by + * leaving the scope of a `using`. + * + * Stopping twice does nothing, and so does stopping one that has + * already been left behind. + */ +export interface ZuProgress extends Disposable { + /** Stops the watch. The callback is not called again. */ + stop(): void +} + /** * What a streamed statement takes beside its parameters. */ diff --git a/zudb.cjs b/zudb.cjs index c527f1a..9a85eaf 100644 --- a/zudb.cjs +++ b/zudb.cjs @@ -138,6 +138,72 @@ Object.defineProperty(binding.Connection.prototype, 'stream', { configurable: true, }) +/** + * How often a watch looks, in milliseconds, when the caller does not + * say. + * + * A tenth of a second is about where a person stops reading a number + * and starts seeing it move, and ten reads of an atomic a second is + * nothing beside a statement worth watching. + */ +const EVERY_MS = 100 + +/** + * `conn.progress(...)`, the other method of the native class written in + * JavaScript. + * + * A statement runs on a threadpool thread and the loop is free while it + * does, which is what makes a timer the whole of this: `rowsRead` is an + * atomic read beside the connection's lock, so asking ten times a + * second costs the statement nothing and never queues behind it. There + * is no callback into JavaScript from the thread doing the scanning + * anywhere in here, which is deliberate, since that would mean a thread + * safe function and a scan that stops to talk to the loop. + * + * On the prototype for the reason `stream` is: the class is registered + * by the addon, and this is JavaScript. + */ +Object.defineProperty(binding.Connection.prototype, 'progress', { + value: function progress(onRows, options) { + if (typeof onRows !== 'function') { + throw new TypeError(`progress wants a function to call, and ${typeof onRows} is not one`) + } + const everyMs = options?.everyMs ?? EVERY_MS + if (typeof everyMs !== 'number' || !Number.isFinite(everyMs) || everyMs <= 0) { + throw new RangeError( + `everyMs is how long to wait between looks, and ${everyMs} is not a number of milliseconds`, + ) + } + // A timer's delay is a signed 32 bit number of milliseconds, and a + // bigger one wraps round to a millisecond with a warning, which is + // the opposite of what somebody asking for a long wait asked for. + const wait = Math.min(everyMs, 2 ** 31 - 1) + + const conn = this + // Where the count already was, so that a watch on an idle + // connection says nothing at all: the callback is for a number that + // moved, and the number a finished statement left behind is not + // one. It is also what keeps a watch nobody stopped from calling + // ten times a second forever. + let last = conn.rowsRead + const timer = setInterval(() => { + const rows = conn.rowsRead + if (rows === last) return + last = rows + onRows(rows) + }, wait) + // A progress bar is not a reason for a program to stay running, and + // a watch that outlived what it was watching would otherwise be + // exactly that. + timer.unref?.() + + const stop = () => clearInterval(timer) + return { stop, [Symbol.dispose]: stop } + }, + writable: true, + configurable: true, +}) + /** * Whether a caught value is a failure from this client. * diff --git a/zudb.d.cts b/zudb.d.cts index 677c6bf..0d63db9 100644 --- a/zudb.d.cts +++ b/zudb.d.cts @@ -1,19 +1,29 @@ /* The types for `require('zudb')`. */ -import type { ZuBatch, ZuError, ZuParam, ZuStreamOptions, ZuSummary, ZuValue } from './binding.cjs' +import type { + ZuBatch, + ZuError, + ZuParam, + ZuProgress, + ZuProgressOptions, + ZuStreamOptions, + ZuSummary, + ZuValue, +} from './binding.cjs' export * from './binding.cjs' /** - * The two things about a connection that are not written in Rust. + * The three things about a connection that are not written in Rust. * * The disposal is put on every connection as it is made, under the key * `await using` looks up. It cannot be declared where the rest of the * class is, because the generator writes that file from the Rust and a * method's name there is a string while this key is a symbol. * - * `stream` is on the prototype for a plainer reason: its body is an - * async generator, and there is nowhere in Rust to write one. + * `stream` and `progress` are on the prototype for a plainer reason: + * one of them is an async generator and the other is a timer, and + * neither is a thing to write in Rust. */ declare module './binding.cjs' { interface Connection extends AsyncDisposable { @@ -30,6 +40,22 @@ declare module './binding.cjs' { params?: Record | null, options?: ZuStreamOptions | null, ): ZuStream + + /** + * Calls back with `rowsRead` while a statement runs, for drawing a + * progress bar with. + * + * A timer around the counter rather than a hook inside the + * executor, so the statement it is watching does not know it is + * being watched and does not slow down for it. The callback runs + * only when the number has moved, which is what makes a watch left + * on an idle connection quiet. + * + * It is stopped by `stop()`, by leaving the scope of a `using`, or + * by the program ending, since the timer does not hold the loop + * open on its own. + */ + progress(onRows: (rows: number) => void, options?: ZuProgressOptions | null): ZuProgress } /**