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.
Files changed (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +553 -0
  3. package/dist/adapters/adapter.js +372 -0
  4. package/dist/adapters/connect.js +33 -0
  5. package/dist/adapters/mysql.js +951 -0
  6. package/dist/adapters/postgres.js +1000 -0
  7. package/dist/adapters/sqlite.js +781 -0
  8. package/dist/client/agent.js +262 -0
  9. package/dist/client/app.js +973 -0
  10. package/dist/client/arrange.js +254 -0
  11. package/dist/client/ask.js +133 -0
  12. package/dist/client/breakdown.js +317 -0
  13. package/dist/client/clauses.js +390 -0
  14. package/dist/client/columns.js +98 -0
  15. package/dist/client/complete.js +437 -0
  16. package/dist/client/compose.js +166 -0
  17. package/dist/client/composer.css +495 -0
  18. package/dist/client/composer.js +1972 -0
  19. package/dist/client/connections.js +234 -0
  20. package/dist/client/connmanager.js +962 -0
  21. package/dist/client/connurl.js +188 -0
  22. package/dist/client/core.js +893 -0
  23. package/dist/client/deeplink.js +270 -0
  24. package/dist/client/delete.js +144 -0
  25. package/dist/client/diagram.js +885 -0
  26. package/dist/client/dropdown.js +279 -0
  27. package/dist/client/export.js +456 -0
  28. package/dist/client/features.css +524 -0
  29. package/dist/client/findvalue.js +169 -0
  30. package/dist/client/grid.js +205 -0
  31. package/dist/client/handoff.js +153 -0
  32. package/dist/client/help.css +145 -0
  33. package/dist/client/help.js +881 -0
  34. package/dist/client/history.js +222 -0
  35. package/dist/client/index.html +116 -0
  36. package/dist/client/insert.js +151 -0
  37. package/dist/client/menu.js +160 -0
  38. package/dist/client/nested.js +255 -0
  39. package/dist/client/page.css +713 -0
  40. package/dist/client/page.js +1345 -0
  41. package/dist/client/pagebuilder.js +1222 -0
  42. package/dist/client/pagemarks.js +95 -0
  43. package/dist/client/palette.js +374 -0
  44. package/dist/client/peek.js +254 -0
  45. package/dist/client/picker.js +139 -0
  46. package/dist/client/pins.js +140 -0
  47. package/dist/client/prompt.js +129 -0
  48. package/dist/client/record.js +707 -0
  49. package/dist/client/schemaexport.js +242 -0
  50. package/dist/client/schematext.js +125 -0
  51. package/dist/client/shape.js +178 -0
  52. package/dist/client/shapecheck.js +129 -0
  53. package/dist/client/skeleton.js +139 -0
  54. package/dist/client/sql.css +126 -0
  55. package/dist/client/sql.js +398 -0
  56. package/dist/client/sqlcomplete.js +163 -0
  57. package/dist/client/sqlsaved.js +107 -0
  58. package/dist/client/style.css +2711 -0
  59. package/dist/client/summary.js +259 -0
  60. package/dist/client/table.js +1035 -0
  61. package/dist/client/template.js +539 -0
  62. package/dist/client/theme.js +74 -0
  63. package/dist/client/tour.js +324 -0
  64. package/dist/client/undo.js +105 -0
  65. package/dist/client/url.js +166 -0
  66. package/dist/client/value.js +223 -0
  67. package/dist/client/views.js +215 -0
  68. package/dist/client/virtual.js +176 -0
  69. package/dist/client/welcome.js +170 -0
  70. package/dist/client/write.js +414 -0
  71. package/dist/server/changeimpact.js +195 -0
  72. package/dist/server/connections.js +615 -0
  73. package/dist/server/constraints.js +62 -0
  74. package/dist/server/credentials.js +230 -0
  75. package/dist/server/fixture.js +199 -0
  76. package/dist/server/graph.js +194 -0
  77. package/dist/server/impact.js +48 -0
  78. package/dist/server/index.js +2204 -0
  79. package/dist/server/journal.js +173 -0
  80. package/dist/server/layouts.js +128 -0
  81. package/dist/server/mcp.js +2840 -0
  82. package/dist/server/shapeonly.js +91 -0
  83. package/dist/shared/breakdown.js +231 -0
  84. package/dist/shared/breakdowntext.js +257 -0
  85. package/dist/shared/diff.js +130 -0
  86. package/dist/shared/like.js +29 -0
  87. package/dist/shared/lint.js +149 -0
  88. package/dist/shared/order.js +133 -0
  89. package/dist/shared/page.js +932 -0
  90. package/dist/shared/query.js +831 -0
  91. package/dist/shared/recordview.js +343 -0
  92. package/dist/shared/schema.js +377 -0
  93. package/dist/shared/sqlsaved.js +67 -0
  94. package/dist/shared/view.js +981 -0
  95. package/dist/shared/viewtext.js +273 -0
  96. package/dist/shared/vocabulary.js +164 -0
  97. package/package.json +57 -0
@@ -0,0 +1,1000 @@
1
+ /**
2
+ * Postgres adapter, on `pg`.
3
+ *
4
+ * Where SQLite hands you a per-table PRAGMA, Postgres hands you a catalog you
5
+ * can query in one shot. That difference is worth exploiting: introspection
6
+ * here is three queries total regardless of how many tables exist, which
7
+ * matters the first time someone points this at a schema with four hundred of
8
+ * them.
9
+ *
10
+ * The queries below read `information_schema` for columns and `pg_catalog` for
11
+ * keys and row estimates. `information_schema` is the portable half but it has
12
+ * no cheap row count and describes composite keys awkwardly; `pg_catalog`
13
+ * carries both, at the cost of being Postgres-specific. Since we are already
14
+ * inside the Postgres adapter, that cost is zero.
15
+ */
16
+ import { POSTGRES_STYLE, buildWhere, clampLimit, boundStatement, dedupe, looksWrappable, refuseStacked, trimStatement, clampOffset, Refusal, Serial, } from './adapter.js';
17
+ import { coerceForColumn, deleteRuleOf } from '../shared/schema.js';
18
+ import { checkVocabulary, checksIn } from '../shared/vocabulary.js';
19
+ import { compileView, compileViewCount } from '../shared/view.js';
20
+ import { compileBreakdown, compileBreakdownCount } from '../shared/breakdown.js';
21
+ /** Schemas that are the server's own business, not the user's data. */
22
+ const SYSTEM_SCHEMAS = ['pg_catalog', 'information_schema', 'pg_toast'];
23
+ /** The first check that pins this column's values, if any does. */
24
+ function firstVocabulary(checks, column) {
25
+ for (const check of checks) {
26
+ const values = checkVocabulary(check, column);
27
+ if (values)
28
+ return values;
29
+ }
30
+ return undefined;
31
+ }
32
+ /**
33
+ * Hand dates and timestamps back as the text the server stores.
34
+ *
35
+ * `pg` parses `timestamp without time zone` into a JavaScript `Date`, which
36
+ * has no such thing as "without time zone" — so it reads the value in the
37
+ * *process's* offset and prints it back in UTC. A row written 09:00 came out
38
+ * of this adapter as `2026-08-01T13:00:00.000Z`: not a formatting difference,
39
+ * a different time, on a value the column explicitly says has no zone. The
40
+ * MySQL adapter avoids the whole question with `dateStrings: true`; `pg` has
41
+ * no such option, so the parsers are replaced instead.
42
+ *
43
+ * `timestamptz` is included even though it is a real instant. Its text form
44
+ * carries the offset, which is more information than a `Date` rendered in
45
+ * whatever zone the browser happens to be in — and tablewalk's job is to show
46
+ * what is stored.
47
+ *
48
+ * Set on the module, because that is the only place `pg` keeps them. Done
49
+ * once, and idempotent, so opening a second connection does not repeat it.
50
+ */
51
+ const PG_DATE_OIDS = [
52
+ 1082, // date
53
+ 1083, // time
54
+ 1114, // timestamp without time zone
55
+ 1184, // timestamp with time zone
56
+ 1266, // time with time zone
57
+ ];
58
+ let datesKept = false;
59
+ function keepDatesAsText(pg) {
60
+ if (datesKept)
61
+ return;
62
+ const mod = pg;
63
+ const types = mod.types ?? mod.default?.types;
64
+ /* A `pg` that does not expose `types` is one this cannot fix, and a
65
+ connection is worth more than a formatting guarantee. */
66
+ if (!types?.setTypeParser)
67
+ return;
68
+ for (const oid of PG_DATE_OIDS)
69
+ types.setTypeParser(oid, (value) => value);
70
+ datesKept = true;
71
+ }
72
+ export class PostgresAdapter {
73
+ connectionString;
74
+ label;
75
+ schemas;
76
+ dialect = 'postgres';
77
+ client = null;
78
+ /* The second connection, opened the first time a write is actually applied.
79
+ See `writeHandle`. */
80
+ writer = null;
81
+ /* Every write on that connection, one at a time. See `Serial`. */
82
+ writes = new Serial();
83
+ constructor(connectionString, label = redact(connectionString),
84
+ /** Restrict to these schemas. Empty means every non-system schema. */
85
+ schemas = []) {
86
+ this.connectionString = connectionString;
87
+ this.label = label;
88
+ this.schemas = schemas;
89
+ }
90
+ async conn() {
91
+ if (this.client)
92
+ return this.client;
93
+ /* Imported lazily, and through a variable, so `pg` is only required when
94
+ someone actually opens a Postgres URL. The indirection also stops the
95
+ type checker demanding the package be installed to compile the file. */
96
+ const specifier = 'pg';
97
+ const pg = (await import(specifier).catch(() => {
98
+ throw new Error('Postgres support needs the "pg" package. Install it with: npm install pg');
99
+ }));
100
+ const Client = pg.Client ?? pg.default?.Client;
101
+ if (!Client)
102
+ throw new Error('Could not find a Client export in the "pg" package.');
103
+ keepDatesAsText(pg);
104
+ const client = new Client({ connectionString: this.connectionString });
105
+ await client.connect();
106
+ /* Belt and braces alongside the query builder: even if a read somehow
107
+ compiled to a write, the transaction would refuse it. */
108
+ await client.query('SET default_transaction_read_only = on');
109
+ await client.query("SET statement_timeout = '30s'");
110
+ /* A dead client must not be handed out again.
111
+
112
+ The cached client was returned for the life of the process with no
113
+ liveness check, so a server restart or a dropped socket left every
114
+ later request failing identically — the same error for ever, with no
115
+ way back short of a manual refresh, on a connection the database itself
116
+ had long since made available again. `pg` emits `error` on a client
117
+ whose connection has gone; forgetting it there means the next `conn()`
118
+ dials a new one, which is what "reconnect" means for a pool of size
119
+ one.
120
+
121
+ The listener is required, not optional: an `error` event with nothing
122
+ listening is an unhandled `'error'` on an EventEmitter, which takes the
123
+ process down. A read-only database browser crashing because the
124
+ database bounced would be a poor trade for a stack trace. */
125
+ client.on?.('error', () => {
126
+ if (this.client === client)
127
+ this.client = null;
128
+ });
129
+ this.client = client;
130
+ return client;
131
+ }
132
+ /**
133
+ * Run a statement the user wrote.
134
+ *
135
+ * The session already carries `default_transaction_read_only = on`, and for
136
+ * everything else in this adapter that is enough, because every statement is
137
+ * one this file built. It is **not** enough here: `SET
138
+ * default_transaction_read_only = off` is itself a statement, and a user who
139
+ * typed it followed by a `DELETE` would get both.
140
+ *
141
+ * `BEGIN READ ONLY` is the version that holds. A transaction opened read-only
142
+ * cannot be promoted from inside it — Postgres refuses `SET TRANSACTION READ
143
+ * WRITE` once the transaction has begun — so the guarantee stops depending on
144
+ * what the statement does or does not contain. The `ROLLBACK` afterwards also
145
+ * discards any session state the statement set, so one run cannot change what
146
+ * the next one means.
147
+ *
148
+ * The rollback runs in a `finally`: a statement that errors has still opened
149
+ * a transaction, and leaving it open would leave the connection in an aborted
150
+ * state where every later query fails with "current transaction is aborted".
151
+ *
152
+ * `write: true` is the other half, and it is a different request rather than
153
+ * a relaxation of this one: a separate handle, no READ ONLY transaction, and
154
+ * a decision made further up about whether this connection may write at all.
155
+ * The two paths share the statement text and nothing else.
156
+ */
157
+ async runSql(request) {
158
+ const stacked = refuseStacked(request.text);
159
+ if (stacked)
160
+ throw new Error(stacked);
161
+ const limit = clampLimit(request.limit);
162
+ /* A write is never wrapped in a bounding subquery: `UPDATE … RETURNING`
163
+ inside `SELECT * FROM (…)` is a different statement, and one Postgres
164
+ refuses outright. The caller asked for this text; it runs as typed. */
165
+ const bounded = !request.write && looksWrappable(request.text);
166
+ const text = bounded ? boundStatement(request.text, limit) : trimStatement(request.text);
167
+ if (request.write) {
168
+ /* On the write handle, and outside the READ ONLY transaction every other
169
+ statement here gets — which is the whole difference. Whether this is
170
+ allowed at all was decided before the request arrived: the connection
171
+ has to be marked writable in the config, and that check does not live
172
+ in the adapter.
173
+
174
+ No wrapper transaction of our own. One statement is already atomic in
175
+ Postgres, and a BEGIN/COMMIT around it would only add a way to leave a
176
+ transaction open on a connection that outlives the request. */
177
+ const write = await this.writeHandle();
178
+ const started = performance.now();
179
+ const result = await write.query(text, []);
180
+ return {
181
+ /* A write that returns rows — `RETURNING` — is worth showing, and one
182
+ that does not returns an empty grid rather than a fabricated one. */
183
+ columns: dedupe(result.fields?.map((f) => f.name) ?? []),
184
+ rows: (result.rows ?? []).map((row) => Object.values(toJsonSafe(row)).map(valueSafe)),
185
+ ms: Math.round(performance.now() - started),
186
+ truncated: false,
187
+ bounded: false,
188
+ affected: Number(result.rowCount ?? 0),
189
+ sql: { text, params: [] },
190
+ };
191
+ }
192
+ const db = await this.conn();
193
+ const started = performance.now();
194
+ /* SET LOCAL dies with the transaction, so the session's 30s cap is
195
+ untouched — and the floor stops a caller asking for a timeout so low
196
+ that even the fast path loses races to it. */
197
+ const cap = request.timeoutMs === undefined
198
+ ? undefined
199
+ : Math.max(100, Math.min(30_000, Math.floor(request.timeoutMs)));
200
+ await db.query('BEGIN READ ONLY');
201
+ try {
202
+ if (cap !== undefined)
203
+ await db.query(`SET LOCAL statement_timeout = ${cap}`);
204
+ /* `rowMode: 'array'` so two output columns of the same name keep both
205
+ values; the default object rows would let the second overwrite the
206
+ first. `values: []` keeps this on the extended protocol, which carries
207
+ exactly one statement — a second line of defence behind the stacking
208
+ check above. */
209
+ let wrapped = bounded;
210
+ const result = await db
211
+ .query({ text, values: [], rowMode: 'array' })
212
+ .catch(async (err) => {
213
+ /* The wrap is an optimisation that is allowed to fail — not every
214
+ legal statement survives being made a derived table. Run as
215
+ written and reported as unbounded. The transaction has to be
216
+ restarted: a failed statement aborts it, and every later query in
217
+ it would fail with "current transaction is aborted". */
218
+ if (!bounded)
219
+ throw err;
220
+ wrapped = false;
221
+ await db.query('ROLLBACK');
222
+ await db.query('BEGIN READ ONLY');
223
+ if (cap !== undefined)
224
+ await db.query(`SET LOCAL statement_timeout = ${cap}`);
225
+ return db.query({ text: trimStatement(request.text), values: [], rowMode: 'array' });
226
+ });
227
+ const truncated = result.rows.length > limit;
228
+ return {
229
+ columns: dedupe(result.fields.map((f) => f.name)),
230
+ rows: (truncated ? result.rows.slice(0, limit) : result.rows).map((row) => row.map(valueSafe)),
231
+ ms: Math.round(performance.now() - started),
232
+ truncated,
233
+ bounded: wrapped,
234
+ sql: { text: wrapped ? text : trimStatement(request.text), params: [] },
235
+ };
236
+ }
237
+ finally {
238
+ await db.query('ROLLBACK').catch(() => {
239
+ /* The connection is already unusable if this fails; the original error
240
+ is the one worth reporting. */
241
+ });
242
+ }
243
+ }
244
+ /**
245
+ * EXPLAIN, plain — never the analysing variant that executes to time.
246
+ *
247
+ * `runSql`'s bounding wrap is precisely wrong here: a statement made a
248
+ * derived table is a different statement, with a different plan. The text
249
+ * goes to the planner as written, inside the same READ ONLY transaction
250
+ * every user statement gets — so even a smuggled write is only ever
251
+ * planned, and an execution attempt is the transaction's to refuse, not a
252
+ * string check's.
253
+ */
254
+ async explainSql(request) {
255
+ const stacked = refuseStacked(request.text);
256
+ if (stacked)
257
+ throw new Error(stacked);
258
+ const db = await this.conn();
259
+ const started = performance.now();
260
+ await db.query('BEGIN READ ONLY');
261
+ try {
262
+ const result = await db.query({
263
+ text: `EXPLAIN ${trimStatement(request.text)}`,
264
+ values: (request.params ?? []),
265
+ rowMode: 'array',
266
+ });
267
+ return {
268
+ plan: result.rows.map((r) => String(r[0])),
269
+ ms: Math.round(performance.now() - started),
270
+ };
271
+ }
272
+ finally {
273
+ await db.query('ROLLBACK').catch(() => { });
274
+ }
275
+ }
276
+ /**
277
+ * The planner's own statistics, read rather than computed.
278
+ *
279
+ * `pg_stats` already holds the null share, the distinct estimate, the most
280
+ * common values with their frequencies, and the histogram — ANALYZE paid
281
+ * for them, reading them touches no user table. The numbers are
282
+ * sample-based and stale until the next ANALYZE, which is what
283
+ * `source: 'statistics'` tells the caller; a column ANALYZE has never
284
+ * visited simply has no numbers, which beats invented ones.
285
+ */
286
+ async profile(table) {
287
+ const { schema, name } = await this.assertTable(table);
288
+ const db = await this.conn();
289
+ const started = performance.now();
290
+ const rel = await db.query(`SELECT CASE WHEN c.reltuples < 0 THEN NULL ELSE c.reltuples END AS approx_rows
291
+ FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
292
+ WHERE n.nspname = $1 AND c.relname = $2`, [schema, name]);
293
+ const approx = rel.rows[0]?.approx_rows;
294
+ const rows = approx == null ? undefined : Math.round(Number(approx));
295
+ /* Every live column, with its stats row where ANALYZE has made one.
296
+ `most_common_vals` and `histogram_bounds` are `anyarray`, which the
297
+ driver has no parser for; the ::text::text[] two-step turns them into
298
+ the text array it can parse. */
299
+ const stats = await db.query(`SELECT a.attname::text AS column,
300
+ format_type(a.atttypid, a.atttypmod) AS type,
301
+ s.null_frac,
302
+ s.n_distinct,
303
+ s.most_common_vals::text::text[] AS common_values,
304
+ s.most_common_freqs AS common_freqs,
305
+ s.histogram_bounds::text::text[] AS bounds
306
+ FROM pg_attribute a
307
+ JOIN pg_class c ON c.oid = a.attrelid
308
+ JOIN pg_namespace n ON n.oid = c.relnamespace
309
+ LEFT JOIN pg_stats s ON s.schemaname = n.nspname AND s.tablename = c.relname AND s.attname = a.attname
310
+ WHERE n.nspname = $1 AND c.relname = $2 AND a.attnum > 0 AND NOT a.attisdropped
311
+ ORDER BY a.attnum`, [schema, name]);
312
+ const columns = stats.rows.map((r) => {
313
+ /* Negative n_distinct is a ratio — "-0.3 of the rows are distinct" —
314
+ which only converts when a row estimate exists to multiply by. */
315
+ const distinct = r.n_distinct == null ? undefined
316
+ : Number(r.n_distinct) >= 0 ? Math.round(Number(r.n_distinct))
317
+ : rows === undefined ? undefined
318
+ : Math.round(-Number(r.n_distinct) * rows);
319
+ const common = r.common_values?.length && r.common_freqs?.length
320
+ ? r.common_values.map((v, i) => ({ value: v, frac: Number(r.common_freqs?.[i] ?? 0) }))
321
+ : undefined;
322
+ return {
323
+ column: r.column,
324
+ type: r.type,
325
+ nullFrac: r.null_frac == null ? undefined : Number(r.null_frac),
326
+ distinct,
327
+ /* The histogram's outer edges, as text from the sample. A column too
328
+ uniform to earn a histogram gets no min/max — its common values
329
+ sorted lexically would call 9 larger than 10. */
330
+ min: r.bounds?.length ? r.bounds[0] : undefined,
331
+ max: r.bounds?.length ? r.bounds[r.bounds.length - 1] : undefined,
332
+ common,
333
+ };
334
+ });
335
+ return { table, rows, columns, source: 'statistics', ms: Math.round(performance.now() - started) };
336
+ }
337
+ async introspect() {
338
+ const db = await this.conn();
339
+ const filterSchemas = this.schemas.length
340
+ ? `AND n.nspname = ANY($1)`
341
+ : `AND n.nspname <> ALL($1)`;
342
+ const schemaParam = this.schemas.length ? this.schemas : SYSTEM_SCHEMAS;
343
+ /* 1. Relations, with the planner's row estimate.
344
+ `reltuples` is an estimate, not a count, and is labelled as such in
345
+ the UI. An exact count on a large table is a sequential scan, which
346
+ is a strange thing for a schema browser to do on connect. */
347
+ const rels = await db.query(`SELECT n.nspname AS table_schema,
348
+ c.relname AS table_name,
349
+ c.relkind AS kind,
350
+ CASE WHEN c.reltuples < 0 THEN 0 ELSE c.reltuples END AS approx_rows,
351
+ obj_description(c.oid) AS comment
352
+ FROM pg_class c
353
+ JOIN pg_namespace n ON n.oid = c.relnamespace
354
+ WHERE c.relkind IN ('r','v','m','p','f')
355
+ ${filterSchemas}
356
+ ORDER BY n.nspname, c.relname`, [schemaParam]);
357
+ const relRows = rels.rows;
358
+ const wanted = new Set(relRows.map((r) => `${r.table_schema}.${r.table_name}`));
359
+ /* 2. Columns, in one pass over information_schema. */
360
+ const cols = await db.query(
361
+ /* `col_description` joined on rather than left out.
362
+
363
+ A column comment is the table's own documentation — the one place a
364
+ schema explains itself — and the MySQL adapter has always read them
365
+ while this one did not, so the same database described in two engines
366
+ gave a different amount of help. It lives in `pg_description` rather
367
+ than `information_schema`, which is why it needs the join. */
368
+ `SELECT c.table_schema, c.table_name, c.column_name, c.data_type,
369
+ c.is_nullable, c.column_default, c.ordinal_position,
370
+ col_description(pc.oid, c.ordinal_position::int) AS comment,
371
+ /* Cast to text before aggregating: enumlabel has type "name",
372
+ and the driver hands a name[] back as the literal string
373
+ '{open,held,closed}' rather than an array — the same value,
374
+ typed as something no caller can use. */
375
+ (SELECT array_agg(e.enumlabel::text ORDER BY e.enumsortorder)
376
+ FROM pg_enum e
377
+ JOIN pg_type t ON t.oid = e.enumtypid
378
+ JOIN pg_namespace tn ON tn.oid = t.typnamespace
379
+ WHERE t.typname = c.udt_name AND tn.nspname = c.udt_schema) AS enum_labels
380
+ FROM information_schema.columns c
381
+ LEFT JOIN pg_namespace pn ON pn.nspname = c.table_schema
382
+ LEFT JOIN pg_class pc ON pc.relname = c.table_name AND pc.relnamespace = pn.oid
383
+ WHERE c.table_schema ${this.schemas.length ? '= ANY($1)' : '<> ALL($1)'}
384
+ ORDER BY c.table_schema, c.table_name, c.ordinal_position`, [schemaParam]);
385
+ /* Checks, for the other half of the vocabulary. A `status text CHECK
386
+ (status IN (...))` is far commoner in the wild than a real enum type,
387
+ and to anything reading the catalog it is an untyped text column unless
388
+ the constraint is read too. Postgres stores what it rewrote rather than
389
+ what was written, which `checkVocabulary` knows about. */
390
+ const checks = await db.query(`SELECT n.nspname AS table_schema, cl.relname AS table_name,
391
+ pg_get_constraintdef(co.oid) AS expression
392
+ FROM pg_constraint co
393
+ JOIN pg_class cl ON cl.oid = co.conrelid
394
+ JOIN pg_namespace n ON n.oid = cl.relnamespace
395
+ WHERE co.contype = 'c'
396
+ AND n.nspname ${this.schemas.length ? '= ANY($1)' : '<> ALL($1)'}`, [schemaParam]);
397
+ const checksByTable = new Map();
398
+ for (const row of checks.rows) {
399
+ const id = `${row.table_schema}.${row.table_name}`;
400
+ const list = checksByTable.get(id) ?? [];
401
+ /* `pg_get_constraintdef` returns `CHECK ((expr))`; the parser wants the
402
+ expression, and `checksIn` is the same unwrapping SQLite needs. */
403
+ list.push(...checksIn(row.expression));
404
+ checksByTable.set(id, list);
405
+ }
406
+ /* 3. Keys. `unnest ... WITH ORDINALITY` keeps composite key columns in
407
+ their declared order, which a plain array_agg over conkey would not
408
+ guarantee and which matters for a two-column join.
409
+
410
+ `attname` is of type `name`, not `text`, so aggregating it yields
411
+ `name[]` — an array OID the driver has no parser for, which arrives
412
+ as the raw string "{a,b}" rather than an array. The ::text casts are
413
+ what make these come back as JavaScript arrays. */
414
+ const keys = await db.query(`SELECT con.conname AS constraint_name,
415
+ fn.nspname AS from_schema,
416
+ f.relname AS from_table,
417
+ (SELECT array_agg(a.attname::text ORDER BY k.ord)
418
+ FROM unnest(con.conkey) WITH ORDINALITY AS k(attnum, ord)
419
+ JOIN pg_attribute a ON a.attrelid = f.oid AND a.attnum = k.attnum) AS from_columns,
420
+ tn.nspname AS to_schema,
421
+ t.relname AS to_table,
422
+ (SELECT array_agg(a.attname::text ORDER BY k.ord)
423
+ FROM unnest(con.confkey) WITH ORDINALITY AS k(attnum, ord)
424
+ JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum) AS to_columns,
425
+ con.confdeltype AS delete_rule
426
+ FROM pg_constraint con
427
+ JOIN pg_class f ON f.oid = con.conrelid
428
+ JOIN pg_namespace fn ON fn.oid = f.relnamespace
429
+ JOIN pg_class t ON t.oid = con.confrelid
430
+ JOIN pg_namespace tn ON tn.oid = t.relnamespace
431
+ WHERE con.contype = 'f'`);
432
+ const pks = await db.query(`SELECT n.nspname AS table_schema,
433
+ c.relname AS table_name,
434
+ (SELECT array_agg(a.attname::text ORDER BY k.ord)
435
+ FROM unnest(con.conkey) WITH ORDINALITY AS k(attnum, ord)
436
+ JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = k.attnum) AS columns
437
+ FROM pg_constraint con
438
+ JOIN pg_class c ON c.oid = con.conrelid
439
+ JOIN pg_namespace n ON n.oid = c.relnamespace
440
+ WHERE con.contype = 'p'`);
441
+ const pkByTable = new Map();
442
+ for (const row of pks.rows) {
443
+ pkByTable.set(`${row.table_schema}.${row.table_name}`, row.columns ?? []);
444
+ }
445
+ /* Only the leading column of each index.
446
+
447
+ `indkey[0]` is the first key column, and it is the only one an ORDER BY
448
+ can be satisfied by — an index on (customer_id, opened_at) makes
449
+ `ORDER BY customer_id` free and `ORDER BY opened_at` a full sort. An
450
+ expression index has attnum 0 and joins to nothing, which drops it, as
451
+ it should: there is no bare column to sort by. */
452
+ const indexes = await db.query(`SELECT n.nspname AS table_schema,
453
+ c.relname AS table_name,
454
+ a.attname::text AS column_name
455
+ FROM pg_index i
456
+ JOIN pg_class c ON c.oid = i.indrelid
457
+ JOIN pg_namespace n ON n.oid = c.relnamespace
458
+ JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = i.indkey[0]
459
+ WHERE i.indisvalid`);
460
+ const indexedByTable = new Map();
461
+ for (const row of indexes.rows) {
462
+ const id = `${row.table_schema}.${row.table_name}`;
463
+ const set = indexedByTable.get(id) ?? new Set();
464
+ set.add(row.column_name);
465
+ indexedByTable.set(id, set);
466
+ }
467
+ const foreignKeys = [];
468
+ /* A column-level reference map, so the table view can show "this column
469
+ points there" without searching the whole key list per cell. */
470
+ const refByColumn = new Map();
471
+ for (const row of keys.rows) {
472
+ const from = `${row.from_schema}.${row.from_table}`;
473
+ const to = `${row.to_schema}.${row.to_table}`;
474
+ // Keys can point out of the browsed schema set; those edges lead
475
+ // nowhere the user can click, so they are dropped rather than shown broken.
476
+ if (!wanted.has(from) || !wanted.has(to))
477
+ continue;
478
+ foreignKeys.push({
479
+ name: row.constraint_name,
480
+ from: { table: from, columns: row.from_columns ?? [] },
481
+ to: { table: to, columns: row.to_columns ?? [] },
482
+ onDelete: deleteRuleOf(row.delete_rule),
483
+ });
484
+ (row.from_columns ?? []).forEach((col, i) => {
485
+ refByColumn.set(`${from}.${col}`, {
486
+ table: to,
487
+ column: row.to_columns?.[i] ?? row.to_columns?.[0] ?? '',
488
+ });
489
+ });
490
+ }
491
+ const columnsByTable = new Map();
492
+ for (const row of cols.rows) {
493
+ const id = `${row.table_schema}.${row.table_name}`;
494
+ if (!wanted.has(id))
495
+ continue;
496
+ const list = columnsByTable.get(id) ?? [];
497
+ list.push(row);
498
+ columnsByTable.set(id, list);
499
+ }
500
+ const tables = relRows.map((r) => {
501
+ const id = `${r.table_schema}.${r.table_name}`;
502
+ const pk = new Set(pkByTable.get(id) ?? []);
503
+ const tableChecks = checksByTable.get(id) ?? [];
504
+ const columns = (columnsByTable.get(id) ?? []).map((c) => ({
505
+ name: c.column_name,
506
+ type: c.data_type,
507
+ nullable: c.is_nullable === 'YES',
508
+ primaryKey: pk.has(c.column_name),
509
+ references: refByColumn.get(`${id}.${c.column_name}`),
510
+ default: c.column_default ?? undefined,
511
+ comment: c.comment ?? undefined,
512
+ /* The type wins where there is one: an enum's labels are the whole
513
+ truth, while a check is one rule among however many the table has. */
514
+ allowed: c.enum_labels?.length
515
+ ? c.enum_labels
516
+ : firstVocabulary(tableChecks, c.column_name),
517
+ }));
518
+ return {
519
+ name: r.table_name,
520
+ schema: r.table_schema,
521
+ id,
522
+ columns,
523
+ approxRows: Math.max(0, Math.round(Number(r.approx_rows) || 0)),
524
+ comment: r.comment ?? undefined,
525
+ // Views and materialised views are read-only; foreign tables are
526
+ // remote. All three are worth flagging rather than silently listing.
527
+ isView: r.kind === 'v' || r.kind === 'm' || r.kind === 'f',
528
+ indexed: [...(indexedByTable.get(id) ?? [])],
529
+ };
530
+ });
531
+ return {
532
+ label: this.label,
533
+ dialect: this.dialect,
534
+ tables,
535
+ foreignKeys,
536
+ readAt: new Date().toISOString(),
537
+ };
538
+ }
539
+ async query(request) {
540
+ const db = await this.conn();
541
+ const { schema, name } = await this.assertTable(request.table);
542
+ const qualified = `${POSTGRES_STYLE.quote(schema)}.${POSTGRES_STYLE.quote(name)}`;
543
+ const where = buildWhere(request.filter, POSTGRES_STYLE);
544
+ const cols = request.columns?.length
545
+ ? request.columns.map((c) => POSTGRES_STYLE.quote(c)).join(', ')
546
+ : '*';
547
+ const order = request.orderBy?.length
548
+ ? ' ORDER BY ' +
549
+ request.orderBy
550
+ .map((o) => `${POSTGRES_STYLE.quote(o.column)} ${o.direction === 'desc' ? 'DESC' : 'ASC'}`)
551
+ .join(', ')
552
+ : '';
553
+ /* Every read is bounded. There is no code path that reaches the database
554
+ without a LIMIT, including one that forgot to ask for one. */
555
+ const limit = clampLimit(request.limit);
556
+ /* LIMIT and OFFSET continue the placeholder numbering the WHERE clause
557
+ started, which is the one thing Postgres needs that SQLite does not. */
558
+ const n = where.params.length;
559
+ const text = `SELECT ${cols} FROM ${qualified}${where.text}${order} LIMIT $${n + 1} OFFSET $${n + 2}`;
560
+ const params = [...where.params, limit, clampOffset(request.offset)];
561
+ const started = performance.now();
562
+ const result = await db.query(text, params);
563
+ const ms = Math.round(performance.now() - started);
564
+ return {
565
+ // Taking columns from `fields` rather than the first row means an empty
566
+ // result still renders its headers instead of collapsing to nothing.
567
+ columns: result.fields.map((f) => f.name),
568
+ rows: result.rows.map(toJsonSafe),
569
+ ms,
570
+ sql: { text, params },
571
+ };
572
+ }
573
+ /**
574
+ * Run a compiled view.
575
+ *
576
+ * Note what is *not* here: any check that the tables named in the view
577
+ * exist. `compileView` resolves the base table and every hop against the
578
+ * introspected catalog and refuses anything it cannot find, so the only
579
+ * identifiers that reach the quoting step are ones the catalog produced.
580
+ * The user's contribution — the values — never leaves the parameter array.
581
+ *
582
+ * The dialect difference this exercises is placeholder numbering:
583
+ * `POSTGRES_STYLE` counts $1..$n across the whole statement, and the joins
584
+ * sitting between the SELECT list and the WHERE clause are exactly what
585
+ * makes an off-by-one here easy and invisible.
586
+ */
587
+ async runView(view, offset = 0) {
588
+ const db = await this.conn();
589
+ const schema = await this.schema();
590
+ const compiled = compileView(schema, view, POSTGRES_STYLE, offset);
591
+ const started = performance.now();
592
+ const result = await db.query(compiled.text, compiled.params);
593
+ const ms = Math.round(performance.now() - started);
594
+ return {
595
+ columns: compiled.columns.map((c) => c.name),
596
+ rows: result.rows.map(toJsonSafe),
597
+ ms,
598
+ sql: { text: compiled.text, params: compiled.params },
599
+ resolved: compiled.columns,
600
+ joins: compiled.joins,
601
+ };
602
+ }
603
+ async runBreakdown(breakdown, offset = 0) {
604
+ const db = await this.conn();
605
+ const compiled = compileBreakdown(await this.schema(), breakdown, POSTGRES_STYLE, offset);
606
+ const started = performance.now();
607
+ const result = await db.query(compiled.text, compiled.params);
608
+ return {
609
+ columns: compiled.columns.map((c) => c.name),
610
+ rows: result.rows.map(toJsonSafe),
611
+ ms: Math.round(performance.now() - started),
612
+ sql: { text: compiled.text, params: compiled.params },
613
+ resolved: compiled.columns,
614
+ };
615
+ }
616
+ async countBreakdown(breakdown) {
617
+ const db = await this.conn();
618
+ const compiled = compileBreakdownCount(await this.schema(), breakdown, POSTGRES_STYLE);
619
+ const result = await db.query(compiled.text, compiled.params);
620
+ return Number(result.rows[0]?.n ?? 0);
621
+ }
622
+ async countView(view) {
623
+ const db = await this.conn();
624
+ const compiled = compileViewCount(await this.schema(), view, POSTGRES_STYLE);
625
+ const result = await db.query(compiled.text, compiled.params);
626
+ return Number(result.rows[0]?.n ?? 0);
627
+ }
628
+ /* Introspection here is three catalog queries; a view preview runs on every
629
+ keystroke. Caching it is the difference between a live preview and a
630
+ stutter. The registry refreshes by discarding the adapter, so a stale
631
+ cache cannot outlive an explicit reload. */
632
+ cachedSchema = null;
633
+ async schema() {
634
+ if (!this.cachedSchema)
635
+ this.cachedSchema = await this.introspect();
636
+ return this.cachedSchema;
637
+ }
638
+ /**
639
+ * The planner's row estimate, straight from `pg_class`.
640
+ *
641
+ * `reltuples` is what the planner itself uses to decide how to run a query,
642
+ * so it is the number Postgres is already trusting. It is -1 on a table that
643
+ * has never been analysed, which is a "do not know" rather than a zero —
644
+ * returning undefined there gets an exact count instead of confidently
645
+ * reporting an empty table that is not empty.
646
+ */
647
+ async estimateCount(table) {
648
+ const db = await this.conn();
649
+ const { schema, name } = await this.assertTable(table);
650
+ const result = await db.query(`SELECT c.reltuples AS n
651
+ FROM pg_class c JOIN pg_namespace ns ON ns.oid = c.relnamespace
652
+ WHERE ns.nspname = $1 AND c.relname = $2`, [schema, name]);
653
+ const n = Number(result.rows[0]?.n);
654
+ if (!Number.isFinite(n) || n < 0)
655
+ return undefined;
656
+ return Math.round(n);
657
+ }
658
+ async count(table, filter) {
659
+ const db = await this.conn();
660
+ const { schema, name } = await this.assertTable(table);
661
+ const where = buildWhere(filter, POSTGRES_STYLE);
662
+ const text = `SELECT COUNT(*) AS n FROM ${POSTGRES_STYLE.quote(schema)}.${POSTGRES_STYLE.quote(name)}${where.text}`;
663
+ const result = await db.query(text, where.params);
664
+ return Number(result.rows[0]?.n ?? 0);
665
+ }
666
+ /**
667
+ * The write path, on its own handle.
668
+ *
669
+ * `this.client` carries `default_transaction_read_only = on` for the life of
670
+ * the process, so browsing can never write no matter what the UI believes.
671
+ * A second connection — without that setting — is opened the first time an
672
+ * edit is actually applied, and not before: a connection that can write
673
+ * should exist because somebody wrote, not because somebody connected.
674
+ *
675
+ * Every guard below is the SQLite adapter's, one for one. An edit refused on
676
+ * SQLite and allowed on Postgres would mean the safety of a cell editor
677
+ * depended on which database it was pointed at.
678
+ */
679
+ async writeHandle() {
680
+ if (this.writer)
681
+ return this.writer;
682
+ const specifier = 'pg';
683
+ const pg = (await import(specifier));
684
+ const Client = (pg.Client ?? pg.default?.Client);
685
+ const client = new Client({ connectionString: this.connectionString });
686
+ await client.connect();
687
+ await client.query("SET statement_timeout = '30s'");
688
+ // The same liveness rule as the read handle: a dead client must not be
689
+ // handed out again, and an unheard 'error' event ends the process.
690
+ client.on?.('error', () => {
691
+ if (this.writer === client)
692
+ this.writer = null;
693
+ });
694
+ this.writer = client;
695
+ return client;
696
+ }
697
+ /**
698
+ * A table's columns as the catalog has them now, not as they were on connect.
699
+ *
700
+ * `information_schema` would do for the names and types, but not for the
701
+ * primary key — it describes a composite one across two more views — and
702
+ * the key is the whole safety argument for a write. `pg_catalog` answers
703
+ * both in one query, and this is the Postgres adapter, so being
704
+ * Postgres-specific costs nothing.
705
+ *
706
+ * Read per write rather than from the cached schema: a column added since
707
+ * the connection opened is a column the editor should be able to write, and
708
+ * one dropped since is one it must not.
709
+ */
710
+ async columnsOf(schema, name) {
711
+ const db = await this.conn();
712
+ const result = await db.query(`SELECT a.attname AS name,
713
+ format_type(a.atttypid, a.atttypmod) AS type,
714
+ a.attnotnull AS notnull,
715
+ COALESCE(k.is_pk, false) AS is_pk
716
+ FROM pg_attribute a
717
+ JOIN pg_class c ON c.oid = a.attrelid
718
+ JOIN pg_namespace n ON n.oid = c.relnamespace
719
+ LEFT JOIN LATERAL (
720
+ SELECT true AS is_pk FROM pg_index i
721
+ WHERE i.indrelid = c.oid AND i.indisprimary AND a.attnum = ANY (i.indkey)
722
+ ) k ON true
723
+ WHERE n.nspname = $1 AND c.relname = $2
724
+ AND a.attnum > 0 AND NOT a.attisdropped
725
+ ORDER BY a.attnum`, [schema, name]);
726
+ return result.rows;
727
+ }
728
+ /**
729
+ * Update one row, having proved it is one row.
730
+ *
731
+ * The pre-flight count runs on the *read* handle, so the check that decides
732
+ * whether to write cannot itself write. A key that matches anything other
733
+ * than exactly one row stops here rather than at the UPDATE — the danger in
734
+ * a cell editor was never injection, it is a WHERE broader than its author
735
+ * believed.
736
+ */
737
+ async update(request) {
738
+ return this.writes.run(() => this.updateNow(request));
739
+ }
740
+ async updateNow(request) {
741
+ const found = await this.assertTable(request.table);
742
+ const id = `${found.schema}.${found.name}`;
743
+ /* Postgres updates through a simple view, and the update lands on the
744
+ table underneath. Refused: what the editor showed and what changed are
745
+ then two different objects. */
746
+ if (found.isView)
747
+ throw new Refusal(`${id} is a view, so no single row can be identified.`);
748
+ const meta = await this.columnsOf(found.schema, found.name);
749
+ const pk = meta.filter((c) => c.is_pk).map((c) => c.name);
750
+ if (!pk.length)
751
+ throw new Refusal(`${id} has no primary key, so no single row can be identified.`);
752
+ const keyColumns = Object.keys(request.key);
753
+ /* The key must be the whole primary key and nothing else. A partial key
754
+ would let a two-column key match a whole group of rows, and extra
755
+ columns would let the caller widen the match. */
756
+ const same = keyColumns.length === pk.length && pk.every((c) => keyColumns.includes(c));
757
+ if (!same)
758
+ throw new Refusal(`Updating ${id} needs exactly its primary key (${pk.join(', ')}).`);
759
+ const entries = Object.entries(request.values ?? {});
760
+ if (!entries.length)
761
+ throw new Refusal('Nothing to update.');
762
+ const applied = {};
763
+ for (const [column, raw] of entries) {
764
+ const declared = meta.find((c) => c.name === column);
765
+ if (!declared)
766
+ throw new Refusal(`${id} has no column called "${column}".`);
767
+ if (pk.includes(column)) {
768
+ // Editing a key in place silently repoints every row that referenced
769
+ // it, or fails on the constraint. Neither belongs behind an editor.
770
+ throw new Refusal(`"${column}" is part of the primary key and cannot be edited here.`);
771
+ }
772
+ /* Coerced against the declared type, because everything typed into a
773
+ text box arrives as a string — and Postgres, unlike SQLite, rejects
774
+ "17901" for a numeric column rather than quietly taking it. */
775
+ applied[column] = coerceForColumn(declared.type || '', raw);
776
+ if (applied[column] === null && declared.notnull) {
777
+ throw new Refusal(`"${column}" is NOT NULL and cannot be emptied.`);
778
+ }
779
+ }
780
+ const filter = {
781
+ groups: [pk.map((column) => ({ column, op: '=', value: request.key[column] }))],
782
+ };
783
+ const matches = await this.count(id, filter);
784
+ if (matches !== 1) {
785
+ throw new Refusal(`Refused: that key matches ${matches} rows, not 1. An update is only allowed when it is certain which row it changes.`);
786
+ }
787
+ /* The WHERE is built after the SET, so its placeholders continue the same
788
+ numbering — `$1, $2` for the assignments, `$3…` for the key. Postgres
789
+ numbers parameters rather than taking them in order, which is the one
790
+ place this differs from the other two adapters. */
791
+ const columns = Object.keys(applied);
792
+ const assignments = columns
793
+ .map((c, i) => `${POSTGRES_STYLE.quote(c)} = ${POSTGRES_STYLE.placeholder(i + 1)}`)
794
+ .join(', ');
795
+ const where = buildWhere(filter, POSTGRES_STYLE, columns.length + 1);
796
+ const text = `UPDATE ${qualified(found)} SET ${assignments}${where.text}`;
797
+ const params = [...Object.values(applied), ...where.params];
798
+ const write = await this.writeHandle();
799
+ const result = await write.query(text, params);
800
+ /* Postgres counts rows matched, not rows whose bytes changed, so saving a
801
+ cell without changing its value still reports 1 — the editor would
802
+ otherwise tell the user a successful write failed. */
803
+ return { affected: Number(result.rowCount ?? 0), applied, sql: { text, params } };
804
+ }
805
+ /**
806
+ * Add one row.
807
+ *
808
+ * `update`'s guards where they apply, with the key rules inverted: a primary
809
+ * key may be supplied (a country code somebody types) or left for the
810
+ * database to assign. What is deliberately *not* pre-checked is
811
+ * completeness — a missing NOT NULL column with no default is the database's
812
+ * refusal to make, and its message names the column better than a paraphrase
813
+ * would.
814
+ *
815
+ * `RETURNING *` rather than a re-read by key: one statement gives back the
816
+ * row as stored, defaults filled and the assigned key included, with no
817
+ * window for another write to land in between.
818
+ */
819
+ async insert(request) {
820
+ return this.writes.run(() => this.insertNow(request));
821
+ }
822
+ async insertNow(request) {
823
+ const found = await this.assertTable(request.table);
824
+ const id = `${found.schema}.${found.name}`;
825
+ if (found.isView)
826
+ throw new Refusal(`${id} is a view. Insert into the table underneath it.`);
827
+ const meta = await this.columnsOf(found.schema, found.name);
828
+ const entries = Object.entries(request.values ?? {});
829
+ if (!entries.length)
830
+ throw new Refusal('Nothing to insert. Name at least one column.');
831
+ const applied = {};
832
+ for (const [column, raw] of entries) {
833
+ const declared = meta.find((c) => c.name === column);
834
+ if (!declared)
835
+ throw new Refusal(`${id} has no column called "${column}".`);
836
+ applied[column] = coerceForColumn(declared.type || '', raw);
837
+ if (applied[column] === null && declared.notnull) {
838
+ throw new Refusal(`"${column}" is NOT NULL and cannot be null. Leave it out to use its default, if it has one.`);
839
+ }
840
+ }
841
+ const columns = Object.keys(applied);
842
+ const text = `INSERT INTO ${qualified(found)} (${columns.map((c) => POSTGRES_STYLE.quote(c)).join(', ')})`
843
+ + ` VALUES (${columns.map((_, i) => POSTGRES_STYLE.placeholder(i + 1)).join(', ')}) RETURNING *`;
844
+ const params = Object.values(applied);
845
+ const write = await this.writeHandle();
846
+ const result = await write.query(text, params);
847
+ const row = result.rows[0] ? toJsonSafe(result.rows[0]) : undefined;
848
+ const pk = meta.filter((c) => c.is_pk).map((c) => c.name);
849
+ /* Read off the returned row rather than off what was sent: a key the
850
+ database assigned is only knowable from what came back, and a key that
851
+ was supplied comes back identical. */
852
+ const key = {};
853
+ if (row)
854
+ for (const c of pk)
855
+ key[c] = row[c];
856
+ return { key, row, applied, sql: { text, params } };
857
+ }
858
+ /**
859
+ * Remove one row, having proved it is one row.
860
+ *
861
+ * What is *not* checked here is whether the row should go: what references
862
+ * it, and what the database will do to those references, is answered above
863
+ * this layer where the schema is in hand. A `RESTRICT` that fires is the
864
+ * correct outcome in the engine's own words.
865
+ */
866
+ async remove(request) {
867
+ return this.writes.run(() => this.removeNow(request));
868
+ }
869
+ async removeNow(request) {
870
+ const found = await this.assertTable(request.table);
871
+ const id = `${found.schema}.${found.name}`;
872
+ if (found.isView)
873
+ throw new Refusal(`${id} is a view, so no single row can be identified.`);
874
+ const meta = await this.columnsOf(found.schema, found.name);
875
+ const pk = meta.filter((c) => c.is_pk).map((c) => c.name);
876
+ if (!pk.length)
877
+ throw new Refusal(`${id} has no primary key, so no single row can be identified.`);
878
+ const keyColumns = Object.keys(request.key);
879
+ const same = keyColumns.length === pk.length && pk.every((c) => keyColumns.includes(c));
880
+ if (!same)
881
+ throw new Refusal(`Deleting from ${id} needs exactly its primary key (${pk.join(', ')}).`);
882
+ const filter = {
883
+ groups: [pk.map((column) => ({ column, op: '=', value: request.key[column] }))],
884
+ };
885
+ const matches = await this.count(id, filter);
886
+ if (matches !== 1) {
887
+ throw new Refusal(`Refused: that key matches ${matches} rows, not 1. A delete is only allowed when it is certain which row it removes.`);
888
+ }
889
+ const where = buildWhere(filter, POSTGRES_STYLE);
890
+ const text = `DELETE FROM ${qualified(found)}${where.text}`;
891
+ const write = await this.writeHandle();
892
+ const result = await write.query(text, where.params);
893
+ return { affected: Number(result.rowCount ?? 0), sql: { text, params: where.params } };
894
+ }
895
+ /**
896
+ * Several writes as one, or none of them.
897
+ *
898
+ * Plain `BEGIN` / `COMMIT` on the write connection. The callback is handed
899
+ * the same write bodies the public methods call, so every guard — the whole
900
+ * key, the pre-flight count, the type coercion — applies inside a
901
+ * transaction exactly as it does outside one.
902
+ *
903
+ * The pre-flight counts run on the *read* connection, which cannot see this
904
+ * transaction. That is the right answer for a teardown, where the rows were
905
+ * committed long ago, and the wrong one for "delete a row this same
906
+ * transaction inserted" — which nothing asks for and which the rollback
907
+ * would undo anyway.
908
+ */
909
+ async transaction(run) {
910
+ return this.writes.run(async () => {
911
+ const write = await this.writeHandle();
912
+ await write.query('BEGIN');
913
+ try {
914
+ const out = await run({
915
+ insert: (r) => this.insertNow(r),
916
+ remove: (r) => this.removeNow(r),
917
+ update: (r) => this.updateNow(r),
918
+ });
919
+ await write.query('COMMIT');
920
+ return out;
921
+ }
922
+ catch (err) {
923
+ /* A failed statement aborts a Postgres transaction, so the rollback is
924
+ what makes the connection usable again — not merely tidiness. Its
925
+ own failure is swallowed: the original error is the one worth
926
+ reporting, and a connection this broken will be replaced by the
927
+ liveness listener anyway. */
928
+ await write.query('ROLLBACK').catch(() => { });
929
+ throw err;
930
+ }
931
+ });
932
+ }
933
+ async close() {
934
+ if (this.client)
935
+ await this.client.end();
936
+ this.client = null;
937
+ if (this.writer)
938
+ await this.writer.end();
939
+ this.writer = null;
940
+ }
941
+ /**
942
+ * Identifiers cannot be bound, so a table name is checked against the
943
+ * catalog before it is quoted into a statement. The check is a parameterised
944
+ * query, which means the untrusted string never reaches SQL as syntax.
945
+ */
946
+ async assertTable(id) {
947
+ const dot = id.indexOf('.');
948
+ const schema = dot === -1 ? 'public' : id.slice(0, dot);
949
+ const name = dot === -1 ? id : id.slice(dot + 1);
950
+ const db = await this.conn();
951
+ const found = await db.query(`SELECT c.relkind AS kind FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
952
+ WHERE n.nspname = $1 AND c.relname = $2 AND c.relkind IN ('r','v','m','p','f')`, [schema, name]);
953
+ if (!found.rows.length)
954
+ throw new Error(`No table or view called "${id}" in this database.`);
955
+ /* `v` is a view and `m` a materialised one. Both are read-only as far as
956
+ the write path is concerned — the relkind is asked for here rather than
957
+ re-derived from the cached schema, so a view created since the
958
+ connection opened is still recognised as one. */
959
+ const kind = String(found.rows[0]?.kind ?? 'r');
960
+ return { schema, name, isView: kind === 'v' || kind === 'm' };
961
+ }
962
+ }
963
+ /** `schema.table`, both halves quoted. */
964
+ function qualified(at) {
965
+ return `${POSTGRES_STYLE.quote(at.schema)}.${POSTGRES_STYLE.quote(at.name)}`;
966
+ }
967
+ /** Keep the password out of the label, the logs and the UI. */
968
+ function redact(connectionString) {
969
+ try {
970
+ const url = new URL(connectionString);
971
+ return `${url.hostname}${url.pathname}`;
972
+ }
973
+ catch {
974
+ return 'postgres';
975
+ }
976
+ }
977
+ /** One value, made safe to send as JSON. See `toJsonSafe` for the row form. */
978
+ function valueSafe(v) {
979
+ if (typeof v === 'bigint')
980
+ return v.toString();
981
+ if (v instanceof Uint8Array)
982
+ return `<${v.byteLength} bytes>`;
983
+ if (v instanceof Date)
984
+ return v.toISOString();
985
+ return v;
986
+ }
987
+ function toJsonSafe(row) {
988
+ const out = {};
989
+ for (const [k, v] of Object.entries(row)) {
990
+ if (typeof v === 'bigint')
991
+ out[k] = v.toString();
992
+ else if (v instanceof Uint8Array)
993
+ out[k] = `<${v.byteLength} bytes>`;
994
+ else if (v instanceof Date)
995
+ out[k] = v.toISOString();
996
+ else
997
+ out[k] = v;
998
+ }
999
+ return out;
1000
+ }