tablewalk 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +553 -0
- package/dist/adapters/adapter.js +372 -0
- package/dist/adapters/connect.js +33 -0
- package/dist/adapters/mysql.js +951 -0
- package/dist/adapters/postgres.js +1000 -0
- package/dist/adapters/sqlite.js +781 -0
- package/dist/client/agent.js +262 -0
- package/dist/client/app.js +973 -0
- package/dist/client/arrange.js +254 -0
- package/dist/client/ask.js +133 -0
- package/dist/client/breakdown.js +317 -0
- package/dist/client/clauses.js +390 -0
- package/dist/client/columns.js +98 -0
- package/dist/client/complete.js +437 -0
- package/dist/client/compose.js +166 -0
- package/dist/client/composer.css +495 -0
- package/dist/client/composer.js +1972 -0
- package/dist/client/connections.js +234 -0
- package/dist/client/connmanager.js +962 -0
- package/dist/client/connurl.js +188 -0
- package/dist/client/core.js +893 -0
- package/dist/client/deeplink.js +270 -0
- package/dist/client/delete.js +144 -0
- package/dist/client/diagram.js +885 -0
- package/dist/client/dropdown.js +279 -0
- package/dist/client/export.js +456 -0
- package/dist/client/features.css +524 -0
- package/dist/client/findvalue.js +169 -0
- package/dist/client/grid.js +205 -0
- package/dist/client/handoff.js +153 -0
- package/dist/client/help.css +145 -0
- package/dist/client/help.js +881 -0
- package/dist/client/history.js +222 -0
- package/dist/client/index.html +116 -0
- package/dist/client/insert.js +151 -0
- package/dist/client/menu.js +160 -0
- package/dist/client/nested.js +255 -0
- package/dist/client/page.css +713 -0
- package/dist/client/page.js +1345 -0
- package/dist/client/pagebuilder.js +1222 -0
- package/dist/client/pagemarks.js +95 -0
- package/dist/client/palette.js +374 -0
- package/dist/client/peek.js +254 -0
- package/dist/client/picker.js +139 -0
- package/dist/client/pins.js +140 -0
- package/dist/client/prompt.js +129 -0
- package/dist/client/record.js +707 -0
- package/dist/client/schemaexport.js +242 -0
- package/dist/client/schematext.js +125 -0
- package/dist/client/shape.js +178 -0
- package/dist/client/shapecheck.js +129 -0
- package/dist/client/skeleton.js +139 -0
- package/dist/client/sql.css +126 -0
- package/dist/client/sql.js +398 -0
- package/dist/client/sqlcomplete.js +163 -0
- package/dist/client/sqlsaved.js +107 -0
- package/dist/client/style.css +2711 -0
- package/dist/client/summary.js +259 -0
- package/dist/client/table.js +1035 -0
- package/dist/client/template.js +539 -0
- package/dist/client/theme.js +74 -0
- package/dist/client/tour.js +324 -0
- package/dist/client/undo.js +105 -0
- package/dist/client/url.js +166 -0
- package/dist/client/value.js +223 -0
- package/dist/client/views.js +215 -0
- package/dist/client/virtual.js +176 -0
- package/dist/client/welcome.js +170 -0
- package/dist/client/write.js +414 -0
- package/dist/server/changeimpact.js +195 -0
- package/dist/server/connections.js +615 -0
- package/dist/server/constraints.js +62 -0
- package/dist/server/credentials.js +230 -0
- package/dist/server/fixture.js +199 -0
- package/dist/server/graph.js +194 -0
- package/dist/server/impact.js +48 -0
- package/dist/server/index.js +2204 -0
- package/dist/server/journal.js +173 -0
- package/dist/server/layouts.js +128 -0
- package/dist/server/mcp.js +2840 -0
- package/dist/server/shapeonly.js +91 -0
- package/dist/shared/breakdown.js +231 -0
- package/dist/shared/breakdowntext.js +257 -0
- package/dist/shared/diff.js +130 -0
- package/dist/shared/like.js +29 -0
- package/dist/shared/lint.js +149 -0
- package/dist/shared/order.js +133 -0
- package/dist/shared/page.js +932 -0
- package/dist/shared/query.js +831 -0
- package/dist/shared/recordview.js +343 -0
- package/dist/shared/schema.js +377 -0
- package/dist/shared/sqlsaved.js +67 -0
- package/dist/shared/view.js +981 -0
- package/dist/shared/viewtext.js +273 -0
- package/dist/shared/vocabulary.js +164 -0
- package/package.json +57 -0
|
@@ -0,0 +1,781 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQLite adapter, on Node's built-in `node:sqlite`.
|
|
3
|
+
*
|
|
4
|
+
* SQLite comes first because it needs no server, which means the tool has a
|
|
5
|
+
* demo you can run in one command and a test target that does not depend on
|
|
6
|
+
* anything being installed. It is also the dialect that proves the model is
|
|
7
|
+
* general: its catalog is nothing like `information_schema`, so anything that
|
|
8
|
+
* works for both is unlikely to be accidentally Postgres-shaped.
|
|
9
|
+
*/
|
|
10
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
11
|
+
import { ANSI_STYLE, buildWhere, clampLimit, clampOffset, boundStatement, looksWrappable, dedupe, refuseStacked, trimStatement, Refusal, Serial, } from './adapter.js';
|
|
12
|
+
import { compileView, compileViewCount } from '../shared/view.js';
|
|
13
|
+
import { compileBreakdown, compileBreakdownCount } from '../shared/breakdown.js';
|
|
14
|
+
import { vocabularyFromDDL } from '../shared/vocabulary.js';
|
|
15
|
+
import { coerceForColumn, deleteRuleOf, } from '../shared/schema.js';
|
|
16
|
+
/**
|
|
17
|
+
* Wait rather than fail when someone else holds the file.
|
|
18
|
+
*
|
|
19
|
+
* A SQLite database is usually a file another process is also using — the
|
|
20
|
+
* application whose data this is, a migration, a test — and one writer at a
|
|
21
|
+
* time is the whole locking model. Without a busy timeout the very next read
|
|
22
|
+
* during someone else's write comes back "database is locked", which reads
|
|
23
|
+
* as a broken tool rather than as a lock that would have cleared in
|
|
24
|
+
* milliseconds. Five seconds is long enough to outlast an ordinary write and
|
|
25
|
+
* short enough that a genuinely stuck writer still surfaces.
|
|
26
|
+
*/
|
|
27
|
+
function waitForLocks(db) {
|
|
28
|
+
try {
|
|
29
|
+
db.exec('PRAGMA busy_timeout = 5000');
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
/* An old build without the pragma is not a reason to refuse the
|
|
33
|
+
database; it is a reason to be no worse than before. */
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export class SqliteAdapter {
|
|
37
|
+
file;
|
|
38
|
+
label;
|
|
39
|
+
dialect = 'sqlite';
|
|
40
|
+
db;
|
|
41
|
+
/** Opened lazily, only when an edit is actually applied. */
|
|
42
|
+
writeDb = null;
|
|
43
|
+
/* Every write on that handle, one at a time. See `Serial`. */
|
|
44
|
+
writes = new Serial();
|
|
45
|
+
constructor(file, label = file) {
|
|
46
|
+
this.file = file;
|
|
47
|
+
this.label = label;
|
|
48
|
+
// Read-only by intent as well as by API: this is a browsing tool, and a
|
|
49
|
+
// browsing tool that can write is a browsing tool that can destroy.
|
|
50
|
+
this.db = new DatabaseSync(file, { readOnly: true });
|
|
51
|
+
waitForLocks(this.db);
|
|
52
|
+
}
|
|
53
|
+
async introspect() {
|
|
54
|
+
const tableRows = this.db
|
|
55
|
+
.prepare(`SELECT name, type, sql FROM sqlite_master
|
|
56
|
+
WHERE type IN ('table','view') AND name NOT LIKE 'sqlite_%'
|
|
57
|
+
ORDER BY name`)
|
|
58
|
+
.all();
|
|
59
|
+
const tables = [];
|
|
60
|
+
const foreignKeys = [];
|
|
61
|
+
/* Approximate rows, the way SQLite can afford them: sqlite_stat1 where
|
|
62
|
+
ANALYZE has run — free, and as current as the last ANALYZE — and a
|
|
63
|
+
COUNT(*) otherwise, which on a local file is a b-tree scan the once-
|
|
64
|
+
per-open introspection can absorb. Views stay uncounted: counting one
|
|
65
|
+
runs its query, and a view is allowed to be expensive. */
|
|
66
|
+
const analyzed = new Map();
|
|
67
|
+
try {
|
|
68
|
+
const stats = this.db
|
|
69
|
+
.prepare('SELECT tbl, stat FROM sqlite_stat1 WHERE stat IS NOT NULL')
|
|
70
|
+
.all();
|
|
71
|
+
for (const s of stats) {
|
|
72
|
+
// The first number of `stat` is the row estimate; a partial index
|
|
73
|
+
// reports fewer, so the largest claim across indexes wins.
|
|
74
|
+
const n = Number(String(s.stat).split(' ')[0]);
|
|
75
|
+
if (Number.isFinite(n))
|
|
76
|
+
analyzed.set(s.tbl, Math.max(analyzed.get(s.tbl) ?? 0, n));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
/* No sqlite_stat1 means no ANALYZE yet, not an error. */
|
|
81
|
+
}
|
|
82
|
+
const approxRowsOf = (name, type) => {
|
|
83
|
+
if (type !== 'table')
|
|
84
|
+
return undefined;
|
|
85
|
+
const known = analyzed.get(name);
|
|
86
|
+
if (known !== undefined)
|
|
87
|
+
return known;
|
|
88
|
+
try {
|
|
89
|
+
const row = this.db.prepare(`SELECT COUNT(*) AS n FROM ${ANSI_STYLE.quote(name)}`).get();
|
|
90
|
+
return row.n;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// A table the catalog lists can still refuse a scan (a broken
|
|
94
|
+
// virtual table, say); no count beats no schema.
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
for (const t of tableRows) {
|
|
99
|
+
const cols = this.db
|
|
100
|
+
.prepare(`PRAGMA table_info(${ANSI_STYLE.quote(t.name)})`)
|
|
101
|
+
.all();
|
|
102
|
+
const fks = this.db
|
|
103
|
+
.prepare(`PRAGMA foreign_key_list(${ANSI_STYLE.quote(t.name)})`)
|
|
104
|
+
.all();
|
|
105
|
+
/* PRAGMA returns one row per column of a composite key, grouped by `id`.
|
|
106
|
+
Collapsing them here means the model never has to know that. */
|
|
107
|
+
const byId = new Map();
|
|
108
|
+
for (const fk of fks) {
|
|
109
|
+
const list = byId.get(fk.id) ?? [];
|
|
110
|
+
list.push(fk);
|
|
111
|
+
byId.set(fk.id, list);
|
|
112
|
+
}
|
|
113
|
+
for (const [id, group] of byId) {
|
|
114
|
+
const ordered = [...group].sort((a, b) => a.seq - b.seq);
|
|
115
|
+
foreignKeys.push({
|
|
116
|
+
name: `${t.name}_fk_${id}`,
|
|
117
|
+
onDelete: deleteRuleOf(ordered[0].on_delete),
|
|
118
|
+
from: { table: t.name, columns: ordered.map((f) => f.from) },
|
|
119
|
+
// A null `to` means the FK targets the referenced table's primary
|
|
120
|
+
// key implicitly, which SQLite allows and reports as null.
|
|
121
|
+
to: {
|
|
122
|
+
table: ordered[0].table,
|
|
123
|
+
columns: ordered.map((f) => f.to ?? 'rowid'),
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
const refByColumn = new Map();
|
|
128
|
+
for (const [, group] of byId) {
|
|
129
|
+
for (const f of group) {
|
|
130
|
+
refByColumn.set(f.from, { table: f.table, column: f.to ?? 'rowid' });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/* SQLite keeps the `CREATE TABLE` text it was given, checks and all, so
|
|
134
|
+
the vocabulary is right there in the statement — no second query. */
|
|
135
|
+
const vocabulary = t.sql
|
|
136
|
+
? vocabularyFromDDL(t.sql, cols.map((c) => c.name))
|
|
137
|
+
: new Map();
|
|
138
|
+
/* An untyped column of a *table* really does have BLOB affinity —
|
|
139
|
+
`CREATE TABLE t (a)` is a column SQLite will happily store anything
|
|
140
|
+
in, and saying BLOB is saying what the database will do with it.
|
|
141
|
+
|
|
142
|
+
A *view* is a different question. `PRAGMA table_info` reports no type
|
|
143
|
+
for a computed column because there is no declaration to report, not
|
|
144
|
+
because the values are blobs: `COUNT(*)` came back as BLOB and so did
|
|
145
|
+
`SUM(total)`, which is a wrong answer to "what does this column hold"
|
|
146
|
+
rather than an unhelpful one. Left empty instead — `columnKind`
|
|
147
|
+
already reads that as unknown, exactly as it read BLOB, so nothing
|
|
148
|
+
renders differently; the tool simply stops claiming a type it was
|
|
149
|
+
never told. */
|
|
150
|
+
const untyped = t.type === 'view' ? '' : 'BLOB';
|
|
151
|
+
const columns = cols.map((c) => ({
|
|
152
|
+
name: c.name,
|
|
153
|
+
type: c.type || untyped,
|
|
154
|
+
nullable: c.notnull === 0,
|
|
155
|
+
primaryKey: c.pk > 0,
|
|
156
|
+
references: refByColumn.get(c.name),
|
|
157
|
+
default: c.dflt_value ?? undefined,
|
|
158
|
+
allowed: vocabulary.get(c.name),
|
|
159
|
+
}));
|
|
160
|
+
tables.push({
|
|
161
|
+
name: t.name,
|
|
162
|
+
schema: '',
|
|
163
|
+
id: t.name,
|
|
164
|
+
columns,
|
|
165
|
+
approxRows: approxRowsOf(t.name, t.type),
|
|
166
|
+
isView: t.type === 'view',
|
|
167
|
+
indexed: t.type === 'view' ? [] : this.leadingIndexColumns(t.name, columns),
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
label: this.label,
|
|
172
|
+
dialect: this.dialect,
|
|
173
|
+
tables,
|
|
174
|
+
foreignKeys,
|
|
175
|
+
readAt: new Date().toISOString(),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* The columns an ORDER BY can lean on.
|
|
180
|
+
*
|
|
181
|
+
* `PRAGMA index_list` reports every index including the implicit ones
|
|
182
|
+
* SQLite creates for UNIQUE and for a non-integer primary key; `index_info`
|
|
183
|
+
* gives their columns in order and only the first of each is useful for
|
|
184
|
+
* sorting.
|
|
185
|
+
*
|
|
186
|
+
* `INTEGER PRIMARY KEY` is special and has no index at all — it *is* the
|
|
187
|
+
* rowid, so ordering by it is free — which is exactly the case a naive
|
|
188
|
+
* index scan would report as unindexed and then refuse to sort by.
|
|
189
|
+
*/
|
|
190
|
+
leadingIndexColumns(table, columns) {
|
|
191
|
+
const leading = new Set();
|
|
192
|
+
const rowidAlias = columns.find((c) => c.primaryKey && /^int(eger)?$/i.test(c.type.trim()));
|
|
193
|
+
if (rowidAlias && columns.filter((c) => c.primaryKey).length === 1)
|
|
194
|
+
leading.add(rowidAlias.name);
|
|
195
|
+
try {
|
|
196
|
+
const indexes = this.db
|
|
197
|
+
.prepare(`PRAGMA index_list(${ANSI_STYLE.quote(table)})`)
|
|
198
|
+
.all();
|
|
199
|
+
for (const index of indexes) {
|
|
200
|
+
const info = this.db
|
|
201
|
+
.prepare(`PRAGMA index_info(${ANSI_STYLE.quote(index.name)})`)
|
|
202
|
+
.all();
|
|
203
|
+
const first = info.find((c) => c.seqno === 0)?.name;
|
|
204
|
+
if (first)
|
|
205
|
+
leading.add(first);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
/* A virtual table, or one that vanished between listing and reading.
|
|
210
|
+
Reporting no indexes is the safe answer: it only ever costs a
|
|
211
|
+
default sort falling back to the key. */
|
|
212
|
+
}
|
|
213
|
+
return [...leading];
|
|
214
|
+
}
|
|
215
|
+
async query(request) {
|
|
216
|
+
const table = this.assertTable(request.table);
|
|
217
|
+
const where = buildWhere(request.filter, ANSI_STYLE);
|
|
218
|
+
const cols = request.columns?.length
|
|
219
|
+
? request.columns.map((c) => ANSI_STYLE.quote(c)).join(', ')
|
|
220
|
+
: '*';
|
|
221
|
+
const order = request.orderBy?.length
|
|
222
|
+
? ' ORDER BY ' +
|
|
223
|
+
request.orderBy
|
|
224
|
+
.map((o) => `${ANSI_STYLE.quote(o.column)} ${o.direction === 'desc' ? 'DESC' : 'ASC'}`)
|
|
225
|
+
.join(', ')
|
|
226
|
+
: '';
|
|
227
|
+
/* Every read is bounded. There is no code path that reaches the database
|
|
228
|
+
without a LIMIT, including one that forgot to ask for one. */
|
|
229
|
+
const limit = clampLimit(request.limit);
|
|
230
|
+
const text = `SELECT ${cols} FROM ${ANSI_STYLE.quote(table)}${where.text}${order} LIMIT ? OFFSET ?`;
|
|
231
|
+
const params = [...where.params, limit, clampOffset(request.offset)];
|
|
232
|
+
const started = performance.now();
|
|
233
|
+
const statement = this.db.prepare(text);
|
|
234
|
+
/* Integers past 2^53 are read as BigInt rather than throwing; valueSafe
|
|
235
|
+
turns the safe ones back into numbers and keeps the rest as strings. */
|
|
236
|
+
statement.setReadBigInts(true);
|
|
237
|
+
const rows = statement.all(...params);
|
|
238
|
+
const ms = Math.round(performance.now() - started);
|
|
239
|
+
return {
|
|
240
|
+
columns: rows.length ? Object.keys(rows[0]) : (request.columns ?? []),
|
|
241
|
+
rows: rows.map(toJsonSafe),
|
|
242
|
+
ms,
|
|
243
|
+
sql: { text, params },
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Run a compiled view.
|
|
248
|
+
*
|
|
249
|
+
* Everything hard happened in `compileView`: paths resolved against the
|
|
250
|
+
* catalog, joins planned with collision-free aliases, values reduced to
|
|
251
|
+
* bound parameters. What is left here is picking the dialect's style and
|
|
252
|
+
* handing the statement to the driver — which is the point. An adapter that
|
|
253
|
+
* built its own joins would be an adapter that could get LEFT wrong on its
|
|
254
|
+
* own.
|
|
255
|
+
*/
|
|
256
|
+
async runView(view, offset = 0) {
|
|
257
|
+
const schema = await this.schema();
|
|
258
|
+
/* Every read is bounded, views included: `compileView` runs the limit
|
|
259
|
+
through `clampLimit` and emits LIMIT unconditionally, so there is no
|
|
260
|
+
shape of ViewDef that reaches the database without one. */
|
|
261
|
+
const compiled = compileView(schema, view, ANSI_STYLE, offset);
|
|
262
|
+
const started = performance.now();
|
|
263
|
+
const viewStmt = this.db.prepare(compiled.text);
|
|
264
|
+
viewStmt.setReadBigInts(true);
|
|
265
|
+
const rows = viewStmt.all(...compiled.params);
|
|
266
|
+
const ms = Math.round(performance.now() - started);
|
|
267
|
+
return {
|
|
268
|
+
// From the compiled column list, not the first row: an empty result
|
|
269
|
+
// still has to render its headers rather than collapsing to nothing.
|
|
270
|
+
columns: compiled.columns.map((c) => c.name),
|
|
271
|
+
rows: rows.map(toJsonSafe),
|
|
272
|
+
ms,
|
|
273
|
+
sql: { text: compiled.text, params: compiled.params },
|
|
274
|
+
resolved: compiled.columns,
|
|
275
|
+
joins: compiled.joins,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
async runBreakdown(breakdown, offset = 0) {
|
|
279
|
+
const schema = await this.schema();
|
|
280
|
+
const compiled = compileBreakdown(schema, breakdown, ANSI_STYLE, offset);
|
|
281
|
+
const started = performance.now();
|
|
282
|
+
const breakdownStmt = this.db.prepare(compiled.text);
|
|
283
|
+
breakdownStmt.setReadBigInts(true);
|
|
284
|
+
const rows = breakdownStmt.all(...compiled.params);
|
|
285
|
+
return {
|
|
286
|
+
columns: compiled.columns.map((c) => c.name),
|
|
287
|
+
rows: rows.map(toJsonSafe),
|
|
288
|
+
ms: Math.round(performance.now() - started),
|
|
289
|
+
sql: { text: compiled.text, params: compiled.params },
|
|
290
|
+
resolved: compiled.columns,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
async countBreakdown(breakdown) {
|
|
294
|
+
const compiled = compileBreakdownCount(await this.schema(), breakdown, ANSI_STYLE);
|
|
295
|
+
const row = this.db.prepare(compiled.text).get(...compiled.params);
|
|
296
|
+
return Number(row?.n ?? 0);
|
|
297
|
+
}
|
|
298
|
+
async countView(view) {
|
|
299
|
+
const compiled = compileViewCount(await this.schema(), view, ANSI_STYLE);
|
|
300
|
+
const row = this.db.prepare(compiled.text).get(...compiled.params);
|
|
301
|
+
return Number(row?.n ?? 0);
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* EXPLAIN QUERY PLAN, indented the way the tree nests.
|
|
305
|
+
*
|
|
306
|
+
* Prepared and read, never executed: the rows come from the EXPLAIN
|
|
307
|
+
* statement itself, so even a write handed here is only ever planned — on
|
|
308
|
+
* the read-only handle, at that.
|
|
309
|
+
*/
|
|
310
|
+
async explainSql(request) {
|
|
311
|
+
const stacked = refuseStacked(request.text);
|
|
312
|
+
if (stacked)
|
|
313
|
+
throw new Error(stacked);
|
|
314
|
+
const started = performance.now();
|
|
315
|
+
const rows = this.db
|
|
316
|
+
.prepare(`EXPLAIN QUERY PLAN ${trimStatement(request.text)}`)
|
|
317
|
+
.all(...(request.params ?? []));
|
|
318
|
+
/* The driver hands back a flat list with parent pointers; the indentation
|
|
319
|
+
is the tree, said in text. */
|
|
320
|
+
const depth = new Map();
|
|
321
|
+
const plan = rows.map((r) => {
|
|
322
|
+
const d = (depth.get(r.parent) ?? 0) + 1;
|
|
323
|
+
depth.set(r.id, d);
|
|
324
|
+
return `${' '.repeat(d - 1)}${r.detail}`;
|
|
325
|
+
});
|
|
326
|
+
return { plan, ms: Math.round(performance.now() - started) };
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* SQLite keeps no column statistics worth reading — sqlite_stat1 knows
|
|
330
|
+
* index selectivity, not null shares — so this looks: one aggregate pass
|
|
331
|
+
* for counts and edges, then one small GROUP BY per column that provably
|
|
332
|
+
* repeats. `source` tells the caller what the look cost.
|
|
333
|
+
*
|
|
334
|
+
* Above `scanAtMost` rows it stops scanning and samples, because
|
|
335
|
+
* node:sqlite is synchronous and a full pass over a ten-million-row table
|
|
336
|
+
* holds the whole process for seconds. Every k-th rowid rather than the
|
|
337
|
+
* first N: insertion order is not a sample, it is a history, and a min
|
|
338
|
+
* taken from the first month of data would be presented as the table's.
|
|
339
|
+
* A view — or a WITHOUT ROWID table, which refuses the arithmetic — falls
|
|
340
|
+
* back to the first N with the same honest label, because running a view
|
|
341
|
+
* twice to randomise it would cost what sampling exists to avoid.
|
|
342
|
+
*/
|
|
343
|
+
async profile(table, limits) {
|
|
344
|
+
const name = this.assertTable(table);
|
|
345
|
+
const meta = (await this.schema()).tables.find((t) => t.id === name);
|
|
346
|
+
if (!meta)
|
|
347
|
+
throw new Error(`No table or view called "${name}" in this database.`);
|
|
348
|
+
const q = (c) => ANSI_STYLE.quote(c);
|
|
349
|
+
const scanAtMost = limits?.scanAtMost ?? 200_000;
|
|
350
|
+
const sampleSize = limits?.sampleSize ?? 100_000;
|
|
351
|
+
const started = performance.now();
|
|
352
|
+
/* COUNT(*) walks an index, not the data pages — cheap enough to decide
|
|
353
|
+
with, and the number the answer reports either way. */
|
|
354
|
+
const total = Number(this.db.prepare(`SELECT COUNT(*) AS n FROM ${q(name)}`).get().n ?? 0);
|
|
355
|
+
let from = q(name);
|
|
356
|
+
let source = 'scan';
|
|
357
|
+
if (total > scanAtMost) {
|
|
358
|
+
source = 'sample';
|
|
359
|
+
const k = Math.max(2, Math.ceil(total / sampleSize));
|
|
360
|
+
try {
|
|
361
|
+
this.db.prepare(`SELECT rowid FROM ${q(name)} LIMIT 1`).get();
|
|
362
|
+
/* Hashed, not bare `rowid % k`: data is allowed to be periodic —
|
|
363
|
+
every tenth row null is a perfectly ordinary import — and a bare
|
|
364
|
+
modulo resonates with exactly that, sampling only the pattern.
|
|
365
|
+
Knuth's multiplicative constant scatters the rowids first, so the
|
|
366
|
+
stride cannot alias with anything the rows themselves repeat. */
|
|
367
|
+
from = `(SELECT * FROM ${q(name)} WHERE ((rowid * 2654435761) & 0x7fffffff) % ${k} = 0)`;
|
|
368
|
+
}
|
|
369
|
+
catch {
|
|
370
|
+
from = `(SELECT * FROM ${q(name)} LIMIT ${sampleSize})`;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
const aggs = meta.columns.map((c, i) => `COUNT(${q(c.name)}) AS n${i}, COUNT(DISTINCT ${q(c.name)}) AS d${i}, `
|
|
374
|
+
+ `MIN(${q(c.name)}) AS mn${i}, MAX(${q(c.name)}) AS mx${i}`);
|
|
375
|
+
const profileStmt = this.db
|
|
376
|
+
.prepare(`SELECT COUNT(*) AS total${aggs.length ? `, ${aggs.join(', ')}` : ''} FROM ${from}`);
|
|
377
|
+
/* MIN/MAX over a 64-bit id column would throw on read without this;
|
|
378
|
+
valueSafe normalises what comes back. */
|
|
379
|
+
profileStmt.setReadBigInts(true);
|
|
380
|
+
const row = profileStmt.get();
|
|
381
|
+
/* The denominator the fractions are honest against: the rows actually
|
|
382
|
+
read, which is the whole table only when nothing was skipped. */
|
|
383
|
+
const basis = Number(row.total ?? 0);
|
|
384
|
+
const columns = meta.columns.map((c, i) => {
|
|
385
|
+
const nonNull = Number(row[`n${i}`] ?? 0);
|
|
386
|
+
const distinct = Number(row[`d${i}`] ?? 0);
|
|
387
|
+
const profiled = {
|
|
388
|
+
column: c.name,
|
|
389
|
+
type: c.type,
|
|
390
|
+
nullFrac: basis ? (basis - nonNull) / basis : 0,
|
|
391
|
+
distinct,
|
|
392
|
+
min: valueSafe(row[`mn${i}`]) ?? undefined,
|
|
393
|
+
max: valueSafe(row[`mx${i}`]) ?? undefined,
|
|
394
|
+
};
|
|
395
|
+
// Only where a value provably repeats; a unique column's most common
|
|
396
|
+
// value is noise.
|
|
397
|
+
if (nonNull > 0 && distinct < nonNull) {
|
|
398
|
+
const topStmt = this.db.prepare(`SELECT ${q(c.name)} AS v, COUNT(*) AS n FROM ${from} `
|
|
399
|
+
+ `WHERE ${q(c.name)} IS NOT NULL GROUP BY ${q(c.name)} ORDER BY n DESC, v LIMIT 5`);
|
|
400
|
+
topStmt.setReadBigInts(true);
|
|
401
|
+
const top = topStmt.all();
|
|
402
|
+
profiled.common = top.map((t) => ({ value: valueSafe(t.v), frac: Number(t.n) / basis }));
|
|
403
|
+
}
|
|
404
|
+
return profiled;
|
|
405
|
+
});
|
|
406
|
+
return {
|
|
407
|
+
table: name,
|
|
408
|
+
rows: total,
|
|
409
|
+
columns,
|
|
410
|
+
source,
|
|
411
|
+
...(source === 'sample' ? { sampled: basis } : {}),
|
|
412
|
+
ms: Math.round(performance.now() - started),
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
/** Introspection is cheap here but not free; the view path re-uses it. */
|
|
416
|
+
cachedSchema = null;
|
|
417
|
+
async schema() {
|
|
418
|
+
if (!this.cachedSchema)
|
|
419
|
+
this.cachedSchema = await this.introspect();
|
|
420
|
+
return this.cachedSchema;
|
|
421
|
+
}
|
|
422
|
+
async count(table, filter) {
|
|
423
|
+
const name = this.assertTable(table);
|
|
424
|
+
const where = buildWhere(filter, ANSI_STYLE);
|
|
425
|
+
const text = `SELECT COUNT(*) AS n FROM ${ANSI_STYLE.quote(name)}${where.text}`;
|
|
426
|
+
const row = this.db.prepare(text).get(...where.params);
|
|
427
|
+
return Number(row?.n ?? 0);
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* SQLite stores the original statement text, so this is the real thing —
|
|
431
|
+
* whitespace, comments, check constraints and all — not a reconstruction.
|
|
432
|
+
* Indexes are appended because they are separate statements in
|
|
433
|
+
* `sqlite_master` and are usually the first thing you want after the shape.
|
|
434
|
+
*/
|
|
435
|
+
async ddl(table) {
|
|
436
|
+
const name = this.assertTable(table);
|
|
437
|
+
const row = this.db
|
|
438
|
+
.prepare(`SELECT sql FROM sqlite_master WHERE name = ? AND type IN ('table','view')`)
|
|
439
|
+
.get(name);
|
|
440
|
+
if (!row?.sql)
|
|
441
|
+
return undefined;
|
|
442
|
+
const indexes = this.db
|
|
443
|
+
.prepare(`SELECT sql FROM sqlite_master
|
|
444
|
+
WHERE type = 'index' AND tbl_name = ? AND sql IS NOT NULL
|
|
445
|
+
ORDER BY name`)
|
|
446
|
+
.all(name);
|
|
447
|
+
const parts = [`${row.sql.trim()};`];
|
|
448
|
+
if (indexes.length)
|
|
449
|
+
parts.push('', ...indexes.map((i) => `${i.sql.trim()};`));
|
|
450
|
+
return parts.join('\n');
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* The write path, on its own handle.
|
|
454
|
+
*
|
|
455
|
+
* `this.db` is opened read-only and stays that way for the life of the
|
|
456
|
+
* process, so browsing can never write no matter what the UI believes. A
|
|
457
|
+
* second handle is opened the first time an edit is actually applied.
|
|
458
|
+
*/
|
|
459
|
+
async update(request) {
|
|
460
|
+
return this.writes.run(() => this.updateNow(request));
|
|
461
|
+
}
|
|
462
|
+
async updateNow(request) {
|
|
463
|
+
const table = this.assertTable(request.table);
|
|
464
|
+
const meta = this.db.prepare(`PRAGMA table_info(${ANSI_STYLE.quote(table)})`).all();
|
|
465
|
+
const pk = meta.filter((c) => c.pk > 0).map((c) => c.name);
|
|
466
|
+
if (!pk.length)
|
|
467
|
+
throw new Refusal(`${table} has no primary key, so no single row can be identified.`);
|
|
468
|
+
const keyColumns = Object.keys(request.key);
|
|
469
|
+
/* The key must be the whole primary key and nothing else. Accepting a
|
|
470
|
+
partial key would let a two-column key match a whole group of rows,
|
|
471
|
+
and accepting extra columns would let the caller widen the match. */
|
|
472
|
+
const same = keyColumns.length === pk.length && pk.every((c) => keyColumns.includes(c));
|
|
473
|
+
if (!same) {
|
|
474
|
+
throw new Refusal(`Updating ${table} needs exactly its primary key (${pk.join(', ')}).`);
|
|
475
|
+
}
|
|
476
|
+
const entries = Object.entries(request.values ?? {});
|
|
477
|
+
if (!entries.length)
|
|
478
|
+
throw new Refusal('Nothing to update.');
|
|
479
|
+
const applied = {};
|
|
480
|
+
for (const [column, raw] of entries) {
|
|
481
|
+
const declared = meta.find((c) => c.name === column);
|
|
482
|
+
if (!declared)
|
|
483
|
+
throw new Refusal(`${table} has no column called "${column}".`);
|
|
484
|
+
if (pk.includes(column)) {
|
|
485
|
+
// Editing a key in place silently repoints every row that referenced
|
|
486
|
+
// it, or fails on the constraint. Neither belongs behind an editor.
|
|
487
|
+
throw new Refusal(`"${column}" is part of the primary key and cannot be edited here.`);
|
|
488
|
+
}
|
|
489
|
+
/* Coerced against the declared type, because everything typed into a
|
|
490
|
+
text box arrives as a string — including "17901" for a REAL column. */
|
|
491
|
+
applied[column] = coerceForColumn(declared.type || '', raw);
|
|
492
|
+
if (applied[column] === null && declared.notnull === 1) {
|
|
493
|
+
throw new Refusal(`"${column}" is NOT NULL and cannot be emptied.`);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
const filter = {
|
|
497
|
+
groups: [pk.map((column) => ({ column, op: '=', value: request.key[column] }))],
|
|
498
|
+
};
|
|
499
|
+
/* Pre-flight. The count runs on the read-only handle, so the check cannot
|
|
500
|
+
itself have side effects, and a filter matching anything other than one
|
|
501
|
+
row stops here rather than at the UPDATE. */
|
|
502
|
+
const matches = await this.count(table, filter);
|
|
503
|
+
if (matches !== 1) {
|
|
504
|
+
throw new Refusal(`Refused: that key matches ${matches} rows, not 1. An update is only allowed when it is certain which row it changes.`);
|
|
505
|
+
}
|
|
506
|
+
const where = buildWhere(filter, ANSI_STYLE);
|
|
507
|
+
const assignments = Object.keys(applied).map((c) => `${ANSI_STYLE.quote(c)} = ?`).join(', ');
|
|
508
|
+
const text = `UPDATE ${ANSI_STYLE.quote(table)} SET ${assignments}${where.text}`;
|
|
509
|
+
const params = [...Object.values(applied), ...where.params];
|
|
510
|
+
const write = this.writeHandle();
|
|
511
|
+
const result = write.prepare(text).run(...params);
|
|
512
|
+
return { affected: Number(result.changes), applied, sql: { text, params } };
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Add one row.
|
|
516
|
+
*
|
|
517
|
+
* The guards are `update`'s where they apply — real columns, coerced
|
|
518
|
+
* values, a NOT NULL that cannot be handed null — but the key rules
|
|
519
|
+
* invert: a primary key may be supplied (currency.code is one somebody
|
|
520
|
+
* types) or left for the database to assign. What is deliberately *not*
|
|
521
|
+
* pre-checked is completeness: a missing NOT NULL column with no default
|
|
522
|
+
* is the database's refusal to make, and its message names the column
|
|
523
|
+
* better than a paraphrase would.
|
|
524
|
+
*
|
|
525
|
+
* `RETURNING *` rather than last_insert_rowid plus a re-read: one
|
|
526
|
+
* statement returns the row as stored — defaults filled, the assigned key
|
|
527
|
+
* included — with no window for another write to land in between.
|
|
528
|
+
*/
|
|
529
|
+
async insert(request) {
|
|
530
|
+
return this.writes.run(() => this.insertNow(request));
|
|
531
|
+
}
|
|
532
|
+
async insertNow(request) {
|
|
533
|
+
const table = this.assertTable(request.table);
|
|
534
|
+
const meta = this.db.prepare(`PRAGMA table_info(${ANSI_STYLE.quote(table)})`).all();
|
|
535
|
+
const entries = Object.entries(request.values ?? {});
|
|
536
|
+
if (!entries.length)
|
|
537
|
+
throw new Refusal('Nothing to insert. Name at least one column.');
|
|
538
|
+
const applied = {};
|
|
539
|
+
for (const [column, raw] of entries) {
|
|
540
|
+
const declared = meta.find((c) => c.name === column);
|
|
541
|
+
if (!declared)
|
|
542
|
+
throw new Refusal(`${table} has no column called "${column}".`);
|
|
543
|
+
applied[column] = coerceForColumn(declared.type || '', raw);
|
|
544
|
+
if (applied[column] === null && declared.notnull === 1) {
|
|
545
|
+
throw new Refusal(`"${column}" is NOT NULL and cannot be null. Leave it out to use its default, if it has one.`);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
const columns = Object.keys(applied);
|
|
549
|
+
const text = `INSERT INTO ${ANSI_STYLE.quote(table)} (${columns.map((c) => ANSI_STYLE.quote(c)).join(', ')})`
|
|
550
|
+
+ ` VALUES (${columns.map(() => '?').join(', ')}) RETURNING *`;
|
|
551
|
+
const params = Object.values(applied);
|
|
552
|
+
const write = this.writeHandle();
|
|
553
|
+
const inserted = write.prepare(text);
|
|
554
|
+
/* The RETURNING row can carry a 64-bit id — most often exactly here, on a
|
|
555
|
+
table whose key is one — so it is read as BigInt rather than throwing,
|
|
556
|
+
and normalised on the way out like every other read. */
|
|
557
|
+
inserted.setReadBigInts(true);
|
|
558
|
+
const row = inserted.get(...params);
|
|
559
|
+
const safe = toJsonSafe(row);
|
|
560
|
+
const pk = meta.filter((c) => c.pk > 0).map((c) => c.name);
|
|
561
|
+
const key = Object.fromEntries(pk.map((c) => [c, safe[c]]));
|
|
562
|
+
return { key, row: safe, applied, sql: { text, params } };
|
|
563
|
+
}
|
|
564
|
+
/**
|
|
565
|
+
* Run a statement the user wrote.
|
|
566
|
+
*
|
|
567
|
+
* The read handle is opened `readOnly: true` at the file level, which SQLite
|
|
568
|
+
* enforces in the engine — there is no session setting for a statement to
|
|
569
|
+
* reset, so unlike the other two adapters no transaction wrapper is needed
|
|
570
|
+
* to make the guarantee real.
|
|
571
|
+
*
|
|
572
|
+
* What SQLite does not have is a statement timeout, and `node:sqlite` is
|
|
573
|
+
* synchronous, so a pathological statement blocks the whole process rather
|
|
574
|
+
* than one request. The LIMIT wrap covers the ordinary case — a streaming
|
|
575
|
+
* SELECT stops once it has enough rows — but not an aggregate over a cross
|
|
576
|
+
* join, which has to finish before it returns anything. That is a real limit
|
|
577
|
+
* of running SQLite in-process and the interface says so rather than
|
|
578
|
+
* pretending otherwise.
|
|
579
|
+
*/
|
|
580
|
+
async runSql(request) {
|
|
581
|
+
const stacked = refuseStacked(request.text);
|
|
582
|
+
if (stacked)
|
|
583
|
+
throw new Error(stacked);
|
|
584
|
+
const limit = clampLimit(request.limit);
|
|
585
|
+
const bounded = !request.write && looksWrappable(request.text);
|
|
586
|
+
const text = bounded ? boundStatement(request.text, limit) : trimStatement(request.text);
|
|
587
|
+
const db = request.write ? this.writeHandle() : this.db;
|
|
588
|
+
const started = performance.now();
|
|
589
|
+
/* The wrap is an optimisation that is allowed to fail. Not every legal
|
|
590
|
+
statement survives being made a derived table, and rather than enumerate
|
|
591
|
+
which, a statement that will not compile wrapped is run as written and
|
|
592
|
+
reported as unbounded. Safe to prepare twice because this handle cannot
|
|
593
|
+
write. */
|
|
594
|
+
let wrapped = bounded;
|
|
595
|
+
let statement;
|
|
596
|
+
try {
|
|
597
|
+
statement = db.prepare(text);
|
|
598
|
+
}
|
|
599
|
+
catch (err) {
|
|
600
|
+
if (!bounded)
|
|
601
|
+
throw err;
|
|
602
|
+
wrapped = false;
|
|
603
|
+
statement = db.prepare(trimStatement(request.text));
|
|
604
|
+
}
|
|
605
|
+
if (request.write) {
|
|
606
|
+
const info = statement.run();
|
|
607
|
+
return {
|
|
608
|
+
columns: [], rows: [], ms: Math.round(performance.now() - started),
|
|
609
|
+
truncated: false, bounded: false,
|
|
610
|
+
affected: Number(info.changes ?? 0),
|
|
611
|
+
sql: { text, params: [] },
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
/* Arrays and a real column list, both from the driver.
|
|
615
|
+
|
|
616
|
+
`columns()` reports every output column including two of the same name,
|
|
617
|
+
and `setReturnArrays` makes the rows positional so both values survive —
|
|
618
|
+
an object keyed by name can only hold one of them. Deriving names from
|
|
619
|
+
the first row's keys, which is what `query()` does, loses the duplicate
|
|
620
|
+
and returns nothing at all for an empty result. */
|
|
621
|
+
statement.setReturnArrays(true);
|
|
622
|
+
/* Same reason as the table reads: a raw `SELECT` can return a 64-bit id,
|
|
623
|
+
and without this the driver throws on the first one past 2^53. */
|
|
624
|
+
statement.setReadBigInts(true);
|
|
625
|
+
const columns = dedupe(statement.columns().map((c) => c.name ?? c.column ?? '?'));
|
|
626
|
+
const raw = statement.all();
|
|
627
|
+
/* Capped whether or not the wrap applied. `bounded` says who did the
|
|
628
|
+
limiting — the database, or us after it had already done the work — and
|
|
629
|
+
the caller shows that difference. What it never means is that more rows
|
|
630
|
+
than asked for reach the client. */
|
|
631
|
+
const truncated = raw.length > limit;
|
|
632
|
+
const kept = truncated ? raw.slice(0, limit) : raw;
|
|
633
|
+
return {
|
|
634
|
+
columns,
|
|
635
|
+
rows: kept.map((row) => row.map(valueSafe)),
|
|
636
|
+
ms: Math.round(performance.now() - started),
|
|
637
|
+
truncated,
|
|
638
|
+
bounded: wrapped,
|
|
639
|
+
sql: { text: wrapped ? text : trimStatement(request.text), params: [] },
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* Delete one row.
|
|
644
|
+
*
|
|
645
|
+
* The guards are `update`'s, for the same reason they are `update`'s: the
|
|
646
|
+
* danger in an editor is never injection, it is a statement whose WHERE is
|
|
647
|
+
* broader than the author believed. A delete has the sharper version of that
|
|
648
|
+
* problem, so nothing here is relaxed.
|
|
649
|
+
*
|
|
650
|
+
* Foreign keys are the database's business. `node:sqlite` enables
|
|
651
|
+
* `foreign_keys` by default — checked, not assumed — so a RESTRICT fires and
|
|
652
|
+
* a CASCADE cascades exactly as the schema says, and the error the user sees
|
|
653
|
+
* is SQLite's own.
|
|
654
|
+
*/
|
|
655
|
+
async remove(request) {
|
|
656
|
+
return this.writes.run(() => this.removeNow(request));
|
|
657
|
+
}
|
|
658
|
+
async removeNow(request) {
|
|
659
|
+
const table = this.assertTable(request.table);
|
|
660
|
+
const meta = this.db.prepare(`PRAGMA table_info(${ANSI_STYLE.quote(table)})`).all();
|
|
661
|
+
const pk = meta.filter((c) => c.pk > 0).map((c) => c.name);
|
|
662
|
+
if (!pk.length)
|
|
663
|
+
throw new Refusal(`${table} has no primary key, so no single row can be identified.`);
|
|
664
|
+
const keyColumns = Object.keys(request.key);
|
|
665
|
+
const same = keyColumns.length === pk.length && pk.every((c) => keyColumns.includes(c));
|
|
666
|
+
if (!same) {
|
|
667
|
+
throw new Refusal(`Deleting from ${table} needs exactly its primary key (${pk.join(', ')}).`);
|
|
668
|
+
}
|
|
669
|
+
const filter = {
|
|
670
|
+
groups: [pk.map((column) => ({ column, op: '=', value: request.key[column] }))],
|
|
671
|
+
};
|
|
672
|
+
/* Pre-flight on the read-only handle, so the check cannot itself have side
|
|
673
|
+
effects, and a filter matching anything other than one row stops here
|
|
674
|
+
rather than at the DELETE. */
|
|
675
|
+
const matches = await this.count(table, filter);
|
|
676
|
+
if (matches !== 1) {
|
|
677
|
+
throw new Refusal(`Refused: that key matches ${matches} rows, not 1. A delete is only allowed when it is certain which row it removes.`);
|
|
678
|
+
}
|
|
679
|
+
const where = buildWhere(filter, ANSI_STYLE);
|
|
680
|
+
const text = `DELETE FROM ${ANSI_STYLE.quote(table)}${where.text}`;
|
|
681
|
+
const write = this.writeHandle();
|
|
682
|
+
const result = write.prepare(text).run(...where.params);
|
|
683
|
+
return { affected: Number(result.changes), sql: { text, params: where.params } };
|
|
684
|
+
}
|
|
685
|
+
writeHandle() {
|
|
686
|
+
if (!this.writeDb) {
|
|
687
|
+
this.writeDb = new DatabaseSync(this.file);
|
|
688
|
+
waitForLocks(this.writeDb);
|
|
689
|
+
}
|
|
690
|
+
return this.writeDb;
|
|
691
|
+
}
|
|
692
|
+
/**
|
|
693
|
+
* Several writes as one, or none of them.
|
|
694
|
+
*
|
|
695
|
+
* `BEGIN` / `COMMIT` on the write handle, which is a second connection to
|
|
696
|
+
* the same file. The read handle is a third, opened read-only — so the
|
|
697
|
+
* pre-flight counts inside the callback see the file as it was before this
|
|
698
|
+
* transaction started, which is what a teardown wants and what an insert
|
|
699
|
+
* does not care about.
|
|
700
|
+
*
|
|
701
|
+
* `node:sqlite` is synchronous, so nothing can interleave *within* one
|
|
702
|
+
* statement; the queue is what stops another request's write interleaving
|
|
703
|
+
* *between* two of these.
|
|
704
|
+
*/
|
|
705
|
+
async transaction(run) {
|
|
706
|
+
return this.writes.run(async () => {
|
|
707
|
+
const write = this.writeHandle();
|
|
708
|
+
write.exec('BEGIN');
|
|
709
|
+
try {
|
|
710
|
+
const out = await run({
|
|
711
|
+
insert: (r) => this.insertNow(r),
|
|
712
|
+
remove: (r) => this.removeNow(r),
|
|
713
|
+
update: (r) => this.updateNow(r),
|
|
714
|
+
});
|
|
715
|
+
write.exec('COMMIT');
|
|
716
|
+
return out;
|
|
717
|
+
}
|
|
718
|
+
catch (err) {
|
|
719
|
+
try {
|
|
720
|
+
write.exec('ROLLBACK');
|
|
721
|
+
}
|
|
722
|
+
catch {
|
|
723
|
+
/* Already rolled back, or never begun. The original error is the
|
|
724
|
+
one worth reporting either way. */
|
|
725
|
+
}
|
|
726
|
+
throw err;
|
|
727
|
+
}
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
async close() {
|
|
731
|
+
this.db.close();
|
|
732
|
+
this.writeDb?.close();
|
|
733
|
+
this.writeDb = null;
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
736
|
+
* Table names are the one identifier that cannot be a bound parameter, so
|
|
737
|
+
* they are checked against the catalog rather than escaped and hoped for.
|
|
738
|
+
* An unknown name is rejected before it reaches a statement.
|
|
739
|
+
*/
|
|
740
|
+
assertTable(name) {
|
|
741
|
+
const exists = this.db
|
|
742
|
+
.prepare(`SELECT 1 FROM sqlite_master WHERE type IN ('table','view') AND name = ?`)
|
|
743
|
+
.get(name);
|
|
744
|
+
if (!exists)
|
|
745
|
+
throw new Error(`No table or view called "${name}" in this database.`);
|
|
746
|
+
return name;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
/**
|
|
750
|
+
* One value from the database, made safe to send as JSON.
|
|
751
|
+
*
|
|
752
|
+
* Two things do not survive `JSON.stringify`, and one of them does not survive
|
|
753
|
+
* being *read* by default. `node:sqlite` throws on any integer past 2^53
|
|
754
|
+
* unless the statement is told to read big integers — so the data statements
|
|
755
|
+
* ask for that (`setReadBigInts`), and everything comes back as `bigint`.
|
|
756
|
+
*
|
|
757
|
+
* A bigint inside the safe range is turned back into a plain number, because
|
|
758
|
+
* the client formats numbers — thousands separators, right alignment — and a
|
|
759
|
+
* string would lose all of that on the id column of every ordinary table. One
|
|
760
|
+
* *past* the safe range is kept as a string, because that is the whole point:
|
|
761
|
+
* a Postgres/Snowflake-style 64-bit id shown as `9007199254740992` when it is
|
|
762
|
+
* really `...993` looks right and matches nothing when clicked. Passing it
|
|
763
|
+
* through as a number would be the silent corruption this exists to prevent.
|
|
764
|
+
*
|
|
765
|
+
* Blobs become `<n bytes>`: the grid has no use for the bytes and every use
|
|
766
|
+
* for the size.
|
|
767
|
+
*/
|
|
768
|
+
function valueSafe(v) {
|
|
769
|
+
if (typeof v === 'bigint') {
|
|
770
|
+
return v >= -9007199254740991n && v <= 9007199254740991n ? Number(v) : v.toString();
|
|
771
|
+
}
|
|
772
|
+
if (v instanceof Uint8Array)
|
|
773
|
+
return `<${v.byteLength} bytes>`;
|
|
774
|
+
return v;
|
|
775
|
+
}
|
|
776
|
+
function toJsonSafe(row) {
|
|
777
|
+
const out = {};
|
|
778
|
+
for (const [k, v] of Object.entries(row))
|
|
779
|
+
out[k] = valueSafe(v);
|
|
780
|
+
return out;
|
|
781
|
+
}
|