@ultimat3/db 29.3.2 → 30.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +78 -116
- package/README.md +199 -16
- package/package.json +13 -2
- package/src/app-relation.ts +7 -2
- package/src/catalog-fold.ts +5 -0
- package/src/catalog-objects.ts +20 -24
- package/src/catalog-policies.ts +81 -0
- package/src/catalog-relations.ts +31 -10
- package/src/catalog.ts +40 -1
- package/src/check-ddl.ts +34 -7
- package/src/client.ts +132 -48
- package/src/column-alter.ts +42 -1
- package/src/concurrent-index.ts +157 -0
- package/src/concurrent-plan.ts +294 -0
- package/src/connection-url.ts +16 -4
- package/src/db-diagnostics-deadline.ts +43 -0
- package/src/db-diagnostics-redact.ts +58 -0
- package/src/db-diagnostics-rows.ts +166 -0
- package/src/db-diagnostics-sql.ts +92 -0
- package/src/db-diagnostics.ts +178 -0
- package/src/destructive.ts +21 -35
- package/src/diagnostics-entry.ts +15 -0
- package/src/drift-errors.ts +9 -4
- package/src/drift-findings.ts +47 -62
- package/src/drift-missing.ts +203 -0
- package/src/drift-row-security.ts +88 -0
- package/src/drift-triggers.ts +54 -0
- package/src/drift.ts +88 -21
- package/src/entity-shape.ts +75 -0
- package/src/errors.ts +80 -9
- package/src/expected-loop.ts +34 -4
- package/src/failure-store-pg.ts +157 -0
- package/src/foreign-key-plan.ts +75 -23
- package/src/foreign-key.ts +76 -5
- package/src/generate-counter.ts +85 -0
- package/src/generate-history.ts +142 -0
- package/src/generate-row-security.ts +51 -0
- package/src/generate-snapshot.ts +82 -0
- package/src/generate-steps.ts +99 -0
- package/src/generate-triggers.ts +119 -0
- package/src/generate-view.ts +97 -0
- package/src/generate.ts +128 -89
- package/src/index-ddl.ts +23 -35
- package/src/index-plan.ts +35 -14
- package/src/index.ts +136 -6
- package/src/introspect-catalog.ts +26 -6
- package/src/introspect-rows.ts +82 -0
- package/src/introspect.ts +108 -50
- package/src/keyset.ts +182 -0
- package/src/lifecycle-errors.ts +15 -0
- package/src/local-settings.ts +38 -0
- package/src/marker-line.ts +47 -0
- package/src/migrate-lock-retry.ts +60 -0
- package/src/migrate-rollback.ts +45 -6
- package/src/migrate.ts +67 -69
- package/src/migration-errors.ts +48 -1
- package/src/migration-ledger.ts +38 -4
- package/src/migration-step.ts +192 -0
- package/src/partition-order.ts +56 -0
- package/src/partitioned-tables.ts +24 -0
- package/src/pool-gate.ts +120 -0
- package/src/pool-listeners.ts +143 -0
- package/src/pool-pins.ts +68 -0
- package/src/pool-profile.ts +23 -7
- package/src/pool-reserve.ts +11 -4
- package/src/pool-retire.ts +22 -0
- package/src/pool-sweep.ts +115 -0
- package/src/pool-timer.ts +10 -0
- package/src/pooler-fix.ts +53 -0
- package/src/pooler.ts +161 -0
- package/src/read-transaction.ts +59 -0
- package/src/readonly-query.ts +4 -4
- package/src/replica-client.ts +54 -22
- package/src/replica-position.ts +89 -0
- package/src/replica-scope.ts +25 -4
- package/src/retired.ts +131 -0
- package/src/retype-dependents.ts +2 -0
- package/src/rolling-errors.ts +97 -0
- package/src/rolling.ts +212 -0
- package/src/row-secure-client.ts +54 -0
- package/src/row-security-catalog.ts +26 -0
- package/src/row-security-migrator.ts +48 -0
- package/src/row-security-plan.ts +58 -0
- package/src/row-security-role.ts +60 -0
- package/src/row-security-roles.ts +64 -0
- package/src/row-security.ts +59 -0
- package/src/same-tenant.ts +173 -0
- package/src/schema-dump-policies.ts +26 -0
- package/src/schema-dump-table.ts +21 -6
- package/src/schema-dump.ts +13 -2
- package/src/snapshot-parse.ts +42 -2
- package/src/sqlstate.ts +4 -0
- package/src/statement-bound.ts +82 -0
- package/src/transaction-errors.ts +14 -0
- package/src/transaction-guard.ts +40 -0
- package/src/transaction-settings.ts +24 -0
- package/src/transaction.ts +74 -19
- package/src/ungeneratable.ts +30 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// Single responsibility: one catalog row, as the driver handed it, into `dbDiagnostics`' typed
|
|
2
|
+
// row. Column names stay the SQL's (`db-diagnostics-sql.ts`); the fields are what `--json` prints.
|
|
3
|
+
// The two drivers disagree on `int8` (text, bigint or number), so every count is read here, once.
|
|
4
|
+
|
|
5
|
+
type Raw = Readonly<Record<string, unknown>>;
|
|
6
|
+
type Redact = (text: string) => string;
|
|
7
|
+
|
|
8
|
+
/** A count or an age in seconds, whichever type the driver chose; `null` when the server had none. */
|
|
9
|
+
const count = (value: unknown): number | null => {
|
|
10
|
+
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
|
|
11
|
+
if (typeof value === 'bigint') return Number(value);
|
|
12
|
+
if (typeof value === 'string' && value.trim() !== '') {
|
|
13
|
+
const parsed = Number(value);
|
|
14
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
15
|
+
}
|
|
16
|
+
return null;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const text = (value: unknown): string | null => (typeof value === 'string' ? value : null);
|
|
20
|
+
|
|
21
|
+
const flag = (value: unknown): boolean | null => (typeof value === 'boolean' ? value : null);
|
|
22
|
+
|
|
23
|
+
/** Statement text, shaped by the caller's redactor: never sent out as the server stored it. */
|
|
24
|
+
const statement = (value: unknown, redact: Redact): string | null => {
|
|
25
|
+
const raw = text(value);
|
|
26
|
+
return raw === null ? null : redact(raw);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export interface BlockerRow {
|
|
30
|
+
readonly pid: number | null;
|
|
31
|
+
readonly blockedBy: number | null;
|
|
32
|
+
readonly waitEventType: string | null;
|
|
33
|
+
readonly waitEvent: string | null;
|
|
34
|
+
readonly waitingSeconds: number | null;
|
|
35
|
+
readonly query: string | null;
|
|
36
|
+
readonly blockerState: string | null;
|
|
37
|
+
readonly blockerTransactionSeconds: number | null;
|
|
38
|
+
readonly blockerQuery: string | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const blockerRow = (raw: Raw, redact: Redact): BlockerRow => ({
|
|
42
|
+
pid: count(raw['pid']),
|
|
43
|
+
blockedBy: count(raw['blocked_by']),
|
|
44
|
+
waitEventType: text(raw['wait_event_type']),
|
|
45
|
+
waitEvent: text(raw['wait_event']),
|
|
46
|
+
waitingSeconds: count(raw['waiting_seconds']),
|
|
47
|
+
query: statement(raw['query'], redact),
|
|
48
|
+
blockerState: text(raw['blocker_state']),
|
|
49
|
+
blockerTransactionSeconds: count(raw['blocker_transaction_seconds']),
|
|
50
|
+
blockerQuery: statement(raw['blocker_query'], redact),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
export interface ActivityRow {
|
|
54
|
+
readonly pid: number | null;
|
|
55
|
+
readonly role: string | null;
|
|
56
|
+
readonly applicationName: string | null;
|
|
57
|
+
readonly state: string | null;
|
|
58
|
+
readonly waitEventType: string | null;
|
|
59
|
+
readonly waitEvent: string | null;
|
|
60
|
+
readonly transactionSeconds: number | null;
|
|
61
|
+
readonly querySeconds: number | null;
|
|
62
|
+
/** False when this role lacks `pg_read_all_stats` for that session: a fact, not an error. */
|
|
63
|
+
readonly queryVisible: boolean;
|
|
64
|
+
readonly query: string | null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const activityRow = (raw: Raw, redact: Redact): ActivityRow => {
|
|
68
|
+
const queryVisible = flag(raw['query_visible']) ?? false;
|
|
69
|
+
return {
|
|
70
|
+
pid: count(raw['pid']),
|
|
71
|
+
role: text(raw['role']),
|
|
72
|
+
applicationName: text(raw['application_name']),
|
|
73
|
+
state: text(raw['state']),
|
|
74
|
+
waitEventType: text(raw['wait_event_type']),
|
|
75
|
+
waitEvent: text(raw['wait_event']),
|
|
76
|
+
transactionSeconds: count(raw['transaction_seconds']),
|
|
77
|
+
querySeconds: count(raw['query_seconds']),
|
|
78
|
+
queryVisible,
|
|
79
|
+
query: queryVisible ? statement(raw['query'], redact) : null,
|
|
80
|
+
};
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export interface SlowQueryRow {
|
|
84
|
+
readonly queryId: string | null;
|
|
85
|
+
readonly calls: number | null;
|
|
86
|
+
readonly totalMs: number | null;
|
|
87
|
+
readonly meanMs: number | null;
|
|
88
|
+
readonly rows: number | null;
|
|
89
|
+
readonly query: string | null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export const slowQueryRow = (raw: Raw, redact: Redact): SlowQueryRow => ({
|
|
93
|
+
queryId: text(raw['query_id']),
|
|
94
|
+
calls: count(raw['calls']),
|
|
95
|
+
totalMs: count(raw['total_ms']),
|
|
96
|
+
meanMs: count(raw['mean_ms']),
|
|
97
|
+
rows: count(raw['rows']),
|
|
98
|
+
query: statement(raw['query'], redact),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
export interface UnusedIndexRow {
|
|
102
|
+
readonly schema: string | null;
|
|
103
|
+
readonly table: string | null;
|
|
104
|
+
readonly index: string | null;
|
|
105
|
+
readonly sizeBytes: number | null;
|
|
106
|
+
/** Seconds since this database's counters were reset; `null` = never reset. */
|
|
107
|
+
readonly statsAgeSeconds: number | null;
|
|
108
|
+
/** Counters are per server: a replica's zero says nothing of the primary's scans. */
|
|
109
|
+
readonly replica: boolean | null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export const unusedIndexRow = (raw: Raw): UnusedIndexRow => ({
|
|
113
|
+
schema: text(raw['schema']),
|
|
114
|
+
table: text(raw['table_name']),
|
|
115
|
+
index: text(raw['index_name']),
|
|
116
|
+
sizeBytes: count(raw['size_bytes']),
|
|
117
|
+
statsAgeSeconds: count(raw['stats_age_seconds']),
|
|
118
|
+
replica: flag(raw['replica']),
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
export interface InvalidIndexRow {
|
|
122
|
+
readonly schema: string | null;
|
|
123
|
+
readonly table: string | null;
|
|
124
|
+
readonly index: string | null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export const invalidIndexRow = (raw: Raw): InvalidIndexRow => ({
|
|
128
|
+
schema: text(raw['schema']),
|
|
129
|
+
table: text(raw['table_name']),
|
|
130
|
+
index: text(raw['index_name']),
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
export interface NotValidConstraintRow {
|
|
134
|
+
readonly schema: string | null;
|
|
135
|
+
readonly table: string | null;
|
|
136
|
+
readonly constraint: string | null;
|
|
137
|
+
/** `check`, `foreign-key`, or Postgres' own one-letter `contype` for anything else. */
|
|
138
|
+
readonly kind: string | null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export const notValidConstraintRow = (raw: Raw): NotValidConstraintRow => ({
|
|
142
|
+
schema: text(raw['schema']),
|
|
143
|
+
table: text(raw['table_name']),
|
|
144
|
+
constraint: text(raw['constraint_name']),
|
|
145
|
+
kind: text(raw['kind']),
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
export interface DeadTupleRow {
|
|
149
|
+
readonly schema: string | null;
|
|
150
|
+
readonly table: string | null;
|
|
151
|
+
readonly live: number | null;
|
|
152
|
+
readonly dead: number | null;
|
|
153
|
+
readonly autovacuumAgeSeconds: number | null;
|
|
154
|
+
readonly vacuumAgeSeconds: number | null;
|
|
155
|
+
readonly analyzeAgeSeconds: number | null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export const deadTupleRow = (raw: Raw): DeadTupleRow => ({
|
|
159
|
+
schema: text(raw['schema']),
|
|
160
|
+
table: text(raw['table_name']),
|
|
161
|
+
live: count(raw['live']),
|
|
162
|
+
dead: count(raw['dead']),
|
|
163
|
+
autovacuumAgeSeconds: count(raw['autovacuum_age_seconds']),
|
|
164
|
+
vacuumAgeSeconds: count(raw['vacuum_age_seconds']),
|
|
165
|
+
analyzeAgeSeconds: count(raw['analyze_age_seconds']),
|
|
166
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Single responsibility: the seven catalog reads behind `dbDiagnostics`, one constant statement
|
|
2
|
+
// each. Catalog and statistics views only — no statement here reads a user table, so no row value
|
|
3
|
+
// can be in an answer; the one free text (`query`) is redacted by the caller before it leaves.
|
|
4
|
+
|
|
5
|
+
/** Every database name filter is `current_database()`: a shared server's other tenants are not ours. */
|
|
6
|
+
const OUR_DATABASE = 'datname = current_database()';
|
|
7
|
+
|
|
8
|
+
/** Waiting sessions and the pid each one waits on, longest wait first. */
|
|
9
|
+
export const BLOCKERS_SQL = `select w.pid as pid, b.pid as blocked_by, w.wait_event_type as wait_event_type,
|
|
10
|
+
w.wait_event as wait_event, round(extract(epoch from now() - w.query_start))::int as waiting_seconds,
|
|
11
|
+
w.query as query, b.state as blocker_state,
|
|
12
|
+
round(extract(epoch from now() - b.xact_start))::int as blocker_transaction_seconds,
|
|
13
|
+
b.query as blocker_query
|
|
14
|
+
from pg_stat_activity w
|
|
15
|
+
cross join lateral unnest(pg_blocking_pids(w.pid)) as blocker(pid)
|
|
16
|
+
join pg_stat_activity b on b.pid = blocker.pid
|
|
17
|
+
where w.${OUR_DATABASE}
|
|
18
|
+
order by waiting_seconds desc nulls last, w.pid`;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Every client session that is not idle, oldest transaction first. A session another role owns
|
|
22
|
+
* reads `<insufficient privilege>` without `pg_read_all_stats`: reported as `query_visible`.
|
|
23
|
+
*/
|
|
24
|
+
export const ACTIVITY_SQL = `select pid, usename as role, application_name, state,
|
|
25
|
+
wait_event_type, wait_event,
|
|
26
|
+
round(extract(epoch from now() - xact_start))::int as transaction_seconds,
|
|
27
|
+
round(extract(epoch from now() - query_start))::int as query_seconds,
|
|
28
|
+
query is distinct from '<insufficient privilege>' as query_visible, query
|
|
29
|
+
from pg_stat_activity
|
|
30
|
+
where ${OUR_DATABASE} and pid <> pg_backend_pid() and backend_type = 'client backend'
|
|
31
|
+
and coalesce(state, '') <> 'idle'
|
|
32
|
+
order by xact_start nulls last, pid`;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* `pg_stat_statements` is an extension: on a server without it this is `42P01` and the section is
|
|
36
|
+
* reported unavailable. Its text is what the server normalised; the caller redacts it anyway, since
|
|
37
|
+
* a utility statement is stored as sent.
|
|
38
|
+
*/
|
|
39
|
+
export const SLOW_QUERIES_SQL = `select queryid::text as query_id, calls,
|
|
40
|
+
round(total_exec_time::numeric, 1)::float8 as total_ms,
|
|
41
|
+
round(mean_exec_time::numeric, 2)::float8 as mean_ms, rows, query
|
|
42
|
+
from pg_stat_statements
|
|
43
|
+
where dbid = (select oid from pg_database where ${OUR_DATABASE})
|
|
44
|
+
order by total_exec_time desc`;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Never scanned since the counters were reset. Unique and primary indexes are excluded: they
|
|
48
|
+
* enforce, and a scan count does not measure that. The counters' age and the server's recovery
|
|
49
|
+
* state ride on each row, because a count since yesterday, or on a replica, is not "unused".
|
|
50
|
+
*/
|
|
51
|
+
export const UNUSED_INDEXES_SQL = `select s.schemaname as schema, s.relname as table_name,
|
|
52
|
+
s.indexrelname as index_name, pg_relation_size(s.indexrelid) as size_bytes,
|
|
53
|
+
(select round(extract(epoch from now() - d.stats_reset))::int
|
|
54
|
+
from pg_stat_database d where d.${OUR_DATABASE}) as stats_age_seconds,
|
|
55
|
+
pg_is_in_recovery() as replica
|
|
56
|
+
from pg_stat_user_indexes s
|
|
57
|
+
join pg_index i on i.indexrelid = s.indexrelid
|
|
58
|
+
where s.idx_scan = 0 and not i.indisunique and not i.indisprimary
|
|
59
|
+
order by pg_relation_size(s.indexrelid) desc, s.indexrelname`;
|
|
60
|
+
|
|
61
|
+
const USER_SCHEMA = (column: string): string =>
|
|
62
|
+
`${column} not in ('pg_catalog', 'information_schema') and ${column} not like 'pg\\_toast%'`;
|
|
63
|
+
|
|
64
|
+
/** An index a failed `create index concurrently` left behind: written to, never read from. */
|
|
65
|
+
export const INVALID_INDEXES_SQL = `select n.nspname as schema, t.relname as table_name,
|
|
66
|
+
c.relname as index_name
|
|
67
|
+
from pg_index i
|
|
68
|
+
join pg_class c on c.oid = i.indexrelid
|
|
69
|
+
join pg_class t on t.oid = i.indrelid
|
|
70
|
+
join pg_namespace n on n.oid = c.relnamespace
|
|
71
|
+
where not i.indisvalid and ${USER_SCHEMA('n.nspname')}
|
|
72
|
+
order by n.nspname, t.relname, c.relname`;
|
|
73
|
+
|
|
74
|
+
/** Added `not valid` and never validated: new rows are checked, old ones are not. Names only. */
|
|
75
|
+
export const NOT_VALID_CONSTRAINTS_SQL = `select n.nspname as schema, t.relname as table_name,
|
|
76
|
+
c.conname as constraint_name,
|
|
77
|
+
case c.contype when 'c' then 'check' when 'f' then 'foreign-key' else c.contype::text end as kind
|
|
78
|
+
from pg_constraint c
|
|
79
|
+
join pg_class t on t.oid = c.conrelid
|
|
80
|
+
join pg_namespace n on n.oid = t.relnamespace
|
|
81
|
+
where not c.convalidated and ${USER_SCHEMA('n.nspname')}
|
|
82
|
+
order by n.nspname, t.relname, c.conname`;
|
|
83
|
+
|
|
84
|
+
/** Counts and ages from the statistics collector; no estimate of bloat, which would be a guess. */
|
|
85
|
+
export const DEAD_TUPLES_SQL = `select schemaname as schema, relname as table_name,
|
|
86
|
+
n_live_tup as live, n_dead_tup as dead,
|
|
87
|
+
round(extract(epoch from now() - last_autovacuum))::int as autovacuum_age_seconds,
|
|
88
|
+
round(extract(epoch from now() - greatest(last_vacuum, last_autovacuum)))::int as vacuum_age_seconds,
|
|
89
|
+
round(extract(epoch from now() - greatest(last_analyze, last_autoanalyze)))::int as analyze_age_seconds
|
|
90
|
+
from pg_stat_user_tables
|
|
91
|
+
where n_dead_tup > 0
|
|
92
|
+
order by n_dead_tup desc, relname`;
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// Single responsibility: `dbDiagnostics()` — what the database is doing right now (blockers,
|
|
2
|
+
// activity, slow statements, unused and invalid indexes, unvalidated constraints, dead tuples), as
|
|
3
|
+
// facts and never a verdict. `db-health.ts` stays the readiness probe `/readyz` reads.
|
|
4
|
+
|
|
5
|
+
import { finiteCount, isUltimateError, renderThrowable, type Scheduler } from '@ultimat3/core';
|
|
6
|
+
import type { DbClient } from './client';
|
|
7
|
+
import { commandDeadline, EXPIRED, within } from './db-diagnostics-deadline';
|
|
8
|
+
import { redactQueryText } from './db-diagnostics-redact';
|
|
9
|
+
import {
|
|
10
|
+
type ActivityRow,
|
|
11
|
+
activityRow,
|
|
12
|
+
type BlockerRow,
|
|
13
|
+
blockerRow,
|
|
14
|
+
type DeadTupleRow,
|
|
15
|
+
deadTupleRow,
|
|
16
|
+
type InvalidIndexRow,
|
|
17
|
+
invalidIndexRow,
|
|
18
|
+
type NotValidConstraintRow,
|
|
19
|
+
notValidConstraintRow,
|
|
20
|
+
type SlowQueryRow,
|
|
21
|
+
slowQueryRow,
|
|
22
|
+
type UnusedIndexRow,
|
|
23
|
+
unusedIndexRow,
|
|
24
|
+
} from './db-diagnostics-rows';
|
|
25
|
+
import {
|
|
26
|
+
ACTIVITY_SQL,
|
|
27
|
+
BLOCKERS_SQL,
|
|
28
|
+
DEAD_TUPLES_SQL,
|
|
29
|
+
INVALID_INDEXES_SQL,
|
|
30
|
+
NOT_VALID_CONSTRAINTS_SQL,
|
|
31
|
+
SLOW_QUERIES_SQL,
|
|
32
|
+
UNUSED_INDEXES_SQL,
|
|
33
|
+
} from './db-diagnostics-sql';
|
|
34
|
+
import { dbUnavailable } from './errors';
|
|
35
|
+
import { unrefSchedule } from './pool-timer';
|
|
36
|
+
import { readOnlyQuery } from './readonly-query';
|
|
37
|
+
|
|
38
|
+
/** Per statement. A catalog read that takes longer is itself the finding, reported as its section. */
|
|
39
|
+
export const DIAGNOSTICS_TIMEOUT_MS = 2_000;
|
|
40
|
+
/** Rows per section, fetched through a cursor so the server stops there. */
|
|
41
|
+
export const DIAGNOSTICS_LIMIT = 20;
|
|
42
|
+
/** The client's own wait per statement: the server's bound plus a second for the answer to land. */
|
|
43
|
+
export const DIAGNOSTICS_SECTION_DEADLINE_MS = DIAGNOSTICS_TIMEOUT_MS + 1_000;
|
|
44
|
+
/** The whole command. Past it, the sections not yet asked are reported unsent. */
|
|
45
|
+
export const DIAGNOSTICS_DEADLINE_MS = 15_000;
|
|
46
|
+
|
|
47
|
+
export interface DbDiagnosticsOptions {
|
|
48
|
+
/** Override the ambient pool. */
|
|
49
|
+
readonly client?: DbClient | undefined;
|
|
50
|
+
/** Rows per section; a whole number ≥ 1. Default `DIAGNOSTICS_LIMIT`. */
|
|
51
|
+
readonly limit?: number | undefined;
|
|
52
|
+
/** Arms both client-side deadlines. A seam for tests; default an unref'd `setTimeout`. */
|
|
53
|
+
readonly schedule?: Scheduler | undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A section answered, or why it could not be and what would make it answer. Never a throw. */
|
|
57
|
+
export type DiagnosticSection<Row> =
|
|
58
|
+
| { readonly available: true; readonly rows: readonly Row[] }
|
|
59
|
+
| {
|
|
60
|
+
readonly available: false;
|
|
61
|
+
readonly code: string;
|
|
62
|
+
readonly reason: string;
|
|
63
|
+
readonly fix: string;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export interface DbDiagnostics {
|
|
67
|
+
readonly blockers: DiagnosticSection<BlockerRow>;
|
|
68
|
+
readonly activity: DiagnosticSection<ActivityRow>;
|
|
69
|
+
readonly slowQueries: DiagnosticSection<SlowQueryRow>;
|
|
70
|
+
readonly unusedIndexes: DiagnosticSection<UnusedIndexRow>;
|
|
71
|
+
readonly invalidIndexes: DiagnosticSection<InvalidIndexRow>;
|
|
72
|
+
readonly notValidConstraints: DiagnosticSection<NotValidConstraintRow>;
|
|
73
|
+
readonly deadTuples: DiagnosticSection<DeadTupleRow>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** An operator's act, in the database: this framework never creates an extension for the app. */
|
|
77
|
+
export const SLOW_QUERIES_FIX =
|
|
78
|
+
`psql "$DATABASE_URL" -c 'create extension if not exists pg_stat_statements'` +
|
|
79
|
+
" # after shared_preload_libraries = 'pg_stat_statements' in postgresql.conf and a server restart";
|
|
80
|
+
|
|
81
|
+
/** A section the client stopped waiting for, or never sent: the server's own timeout code. */
|
|
82
|
+
const TIMED_OUT = 'X_DB_STATEMENT_TIMEOUT';
|
|
83
|
+
|
|
84
|
+
/** What a section without an `UltimateError` of its own hands out: the same read, by hand. */
|
|
85
|
+
const GENERIC_FIX =
|
|
86
|
+
'x db diagnostics --json # re-run once the load that timed this read out passes';
|
|
87
|
+
|
|
88
|
+
/** `scheme://user:password@host` → `scheme://***@host`: a reason is printed, a credential never. */
|
|
89
|
+
const scrubCredentials = (text: string): string =>
|
|
90
|
+
text.replace(/([a-z][a-z0-9+.-]*:\/\/)[^\s/@'"]*@/gi, '$1***@');
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Every section is one statement through `readOnlyQuery` — its own `BEGIN READ ONLY`, a 2 s
|
|
94
|
+
* `statement_timeout`, a cursor capped at `limit` — run one after another, so a diagnostic holds
|
|
95
|
+
* one connection and never seven. A section that fails is reported in place: one timeout does not
|
|
96
|
+
* lose the other six. The single throw is a probe that cannot open a connection at all.
|
|
97
|
+
*/
|
|
98
|
+
export async function dbDiagnostics(options: DbDiagnosticsOptions = {}): Promise<DbDiagnostics> {
|
|
99
|
+
const limit = finiteCount('dbDiagnostics', 'limit', options.limit ?? DIAGNOSTICS_LIMIT, 1);
|
|
100
|
+
const read = { client: options.client, timeoutMs: DIAGNOSTICS_TIMEOUT_MS } as const;
|
|
101
|
+
const schedule = options.schedule ?? unrefSchedule;
|
|
102
|
+
const sectionMs = DIAGNOSTICS_SECTION_DEADLINE_MS;
|
|
103
|
+
try {
|
|
104
|
+
const probe = await within(readOnlyQuery('select 1', read), sectionMs, schedule);
|
|
105
|
+
if (probe === EXPIRED) {
|
|
106
|
+
throw dbUnavailable(`x db diagnostics had no answer to select 1 within ${sectionMs}ms`);
|
|
107
|
+
}
|
|
108
|
+
} catch (error) {
|
|
109
|
+
throw isUltimateError(error)
|
|
110
|
+
? error
|
|
111
|
+
: dbUnavailable('x db diagnostics could not open a connection', error);
|
|
112
|
+
}
|
|
113
|
+
const deadline = commandDeadline(DIAGNOSTICS_DEADLINE_MS, schedule);
|
|
114
|
+
const section = async <Row>(
|
|
115
|
+
statement: string,
|
|
116
|
+
toRow: (raw: Readonly<Record<string, unknown>>) => Row,
|
|
117
|
+
fixFor?: (code: string, reason: string) => string | undefined,
|
|
118
|
+
): Promise<DiagnosticSection<Row>> => {
|
|
119
|
+
if (deadline.passed()) {
|
|
120
|
+
const reason = `not sent: x db diagnostics passed its ${DIAGNOSTICS_DEADLINE_MS}ms deadline first`;
|
|
121
|
+
return { available: false, code: TIMED_OUT, reason, fix: GENERIC_FIX };
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
const answer = await within(
|
|
125
|
+
readOnlyQuery<Readonly<Record<string, unknown>>>(statement, { ...read, maxRows: limit }),
|
|
126
|
+
sectionMs,
|
|
127
|
+
schedule,
|
|
128
|
+
);
|
|
129
|
+
if (answer === EXPIRED) {
|
|
130
|
+
// The embedded database cannot interrupt the statement, so it may still be running.
|
|
131
|
+
const reason = `no answer within ${sectionMs}ms; the client stopped waiting`;
|
|
132
|
+
return { available: false, code: TIMED_OUT, reason, fix: GENERIC_FIX };
|
|
133
|
+
}
|
|
134
|
+
return { available: true, rows: answer.rows.map(toRow) };
|
|
135
|
+
} catch (error) {
|
|
136
|
+
const code = isUltimateError(error) ? error.code : 'X_DB_STATEMENT_FAILED';
|
|
137
|
+
const reason = scrubCredentials(
|
|
138
|
+
isUltimateError(error) ? error.cause : renderThrowable(error),
|
|
139
|
+
);
|
|
140
|
+
const own = isUltimateError(error) ? error.fix : undefined;
|
|
141
|
+
const fix = fixFor?.(code, reason) ?? own ?? GENERIC_FIX;
|
|
142
|
+
return { available: false, code, reason, fix: scrubCredentials(fix) };
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
try {
|
|
146
|
+
return await readSections(section);
|
|
147
|
+
} finally {
|
|
148
|
+
deadline.release();
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
type Section = <Row>(
|
|
153
|
+
statement: string,
|
|
154
|
+
toRow: (raw: Readonly<Record<string, unknown>>) => Row,
|
|
155
|
+
fixFor?: (code: string, reason: string) => string | undefined,
|
|
156
|
+
) => Promise<DiagnosticSection<Row>>;
|
|
157
|
+
|
|
158
|
+
/** The seven, in order, each through `section`. */
|
|
159
|
+
async function readSections(section: Section): Promise<DbDiagnostics> {
|
|
160
|
+
return {
|
|
161
|
+
blockers: await section(BLOCKERS_SQL, (raw) => blockerRow(raw, redactQueryText)),
|
|
162
|
+
activity: await section(ACTIVITY_SQL, (raw) => activityRow(raw, redactQueryText)),
|
|
163
|
+
// `42P01` (not installed) and `55000` (installed, not preloaded) both mean the extension is
|
|
164
|
+
// not answering — its own fix, never `X_DB_SCHEMA_STALE`'s `x db gen`.
|
|
165
|
+
slowQueries: await section(
|
|
166
|
+
SLOW_QUERIES_SQL,
|
|
167
|
+
(raw) => slowQueryRow(raw, redactQueryText),
|
|
168
|
+
(code, reason) =>
|
|
169
|
+
code === 'X_DB_SCHEMA_STALE' || reason.includes('pg_stat_statements')
|
|
170
|
+
? SLOW_QUERIES_FIX
|
|
171
|
+
: undefined,
|
|
172
|
+
),
|
|
173
|
+
unusedIndexes: await section(UNUSED_INDEXES_SQL, unusedIndexRow),
|
|
174
|
+
invalidIndexes: await section(INVALID_INDEXES_SQL, invalidIndexRow),
|
|
175
|
+
notValidConstraints: await section(NOT_VALID_CONSTRAINTS_SQL, notValidConstraintRow),
|
|
176
|
+
deadTuples: await section(DEAD_TUPLES_SQL, deadTupleRow),
|
|
177
|
+
};
|
|
178
|
+
}
|
package/src/destructive.ts
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
// an *undeclared* drop is not. Only `up` is ever asked: reversing `create table` is `drop table`,
|
|
4
4
|
// so a rail reading `down` would mark every migration ever generated, and a mark on all is none.
|
|
5
5
|
|
|
6
|
+
import { hasMarkerLine } from './marker-line';
|
|
6
7
|
import { stripSqlNoise } from './sql-noise';
|
|
7
|
-
import { noiseAt } from './sql-scan';
|
|
8
8
|
import { statementExcerpt } from './statement-excerpt';
|
|
9
9
|
import { statementsOf } from './statement-split';
|
|
10
10
|
|
|
@@ -18,40 +18,12 @@ export const DESTRUCTIVE_MARKER = '-- destructive: true';
|
|
|
18
18
|
*/
|
|
19
19
|
const MARKER_COMMENT = /^--[ \t]*destructive:[ \t]*true[ \t]*\r?\n?$/i;
|
|
20
20
|
|
|
21
|
-
/** Whether only spaces and tabs separate `index` from the start of its line. */
|
|
22
|
-
function startsLine(sql: string, index: number): boolean {
|
|
23
|
-
for (let at = index - 1; at >= 0; at -= 1) {
|
|
24
|
-
const char = sql[at];
|
|
25
|
-
if (char === '\n') return true;
|
|
26
|
-
if (char !== ' ' && char !== '\t') return false;
|
|
27
|
-
}
|
|
28
|
-
return true;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
21
|
/**
|
|
32
|
-
* Declared only where a reader would see it: a top-level `--` line of its own.
|
|
33
|
-
*
|
|
22
|
+
* Declared only where a reader would see it: a top-level `--` line of its own (`marker-line.ts`).
|
|
34
23
|
* A regex over the raw file matched the marker inside `/* … *\/` and inside a dollar-quoted body,
|
|
35
|
-
* where it declares nothing
|
|
36
|
-
* bought the file past `x verify`. Scanning is what tells a comment from a comment about a
|
|
37
|
-
* comment; the marker is a lexical fact, not a substring.
|
|
24
|
+
* where it declares nothing — yet it bought the file past `x verify`.
|
|
38
25
|
*/
|
|
39
|
-
export
|
|
40
|
-
let index = 0;
|
|
41
|
-
while (index < sql.length) {
|
|
42
|
-
const noise = noiseAt(sql, index);
|
|
43
|
-
if (noise === null) {
|
|
44
|
-
index += 1;
|
|
45
|
-
continue;
|
|
46
|
-
}
|
|
47
|
-
const text = sql.slice(index, noise.end);
|
|
48
|
-
if (noise.kind === 'line-comment' && startsLine(sql, index) && MARKER_COMMENT.test(text)) {
|
|
49
|
-
return true;
|
|
50
|
-
}
|
|
51
|
-
index = noise.end;
|
|
52
|
-
}
|
|
53
|
-
return false;
|
|
54
|
-
}
|
|
26
|
+
export const hasDestructiveMarker = (sql: string): boolean => hasMarkerLine(sql, MARKER_COMMENT);
|
|
55
27
|
|
|
56
28
|
export type DestructiveKind = 'drop-table' | 'drop-column' | 'retype-column' | 'truncate';
|
|
57
29
|
|
|
@@ -69,6 +41,20 @@ export interface DestructiveStatement {
|
|
|
69
41
|
readonly statement: string;
|
|
70
42
|
}
|
|
71
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Inside an `alter table`, a bare `drop <name>` is a column: every sub-clause that drops something
|
|
46
|
+
* the database can rebuild names itself, and all of them are listed here. One list, two readers:
|
|
47
|
+
* this file's regex and `rolling.ts`'s token walk.
|
|
48
|
+
*/
|
|
49
|
+
export const REBUILDABLE_DROP_CLAUSES: readonly string[] = [
|
|
50
|
+
'constraint',
|
|
51
|
+
'default',
|
|
52
|
+
'not',
|
|
53
|
+
'identity',
|
|
54
|
+
'expression',
|
|
55
|
+
'generated',
|
|
56
|
+
];
|
|
57
|
+
|
|
72
58
|
/**
|
|
73
59
|
* The closed list. Four kinds, because a rail that tried to enumerate every Postgres foot-gun
|
|
74
60
|
* would be a second SQL parser competing with the server's own — and every one of these is a
|
|
@@ -82,11 +68,11 @@ const RULES: readonly (readonly [DestructiveKind, RegExp])[] = [
|
|
|
82
68
|
// Statement-leading only: `before truncate` in a trigger and `grant/revoke truncate` name the
|
|
83
69
|
// operation to guard or permit it, and the rail read them as the operation itself (#522).
|
|
84
70
|
['truncate', /^\s*truncate\b/],
|
|
85
|
-
// Inside an `alter table`, a bare `drop <name>` is a column: every sub-clause that drops
|
|
86
|
-
// something the database can rebuild names itself, and all of them are listed here.
|
|
87
71
|
[
|
|
88
72
|
'drop-column',
|
|
89
|
-
|
|
73
|
+
new RegExp(
|
|
74
|
+
`\\balter\\s+table\\b[\\s\\S]*?\\bdrop\\s+(?!${REBUILDABLE_DROP_CLAUSES.map((clause) => `${clause}\\b`).join('|')})`,
|
|
75
|
+
),
|
|
90
76
|
],
|
|
91
77
|
// Not "narrowing". Whether the new type is narrower is knowable only against the old one, and a
|
|
92
78
|
// rewrite that fails on one row fails the whole migration whichever direction it went.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// `@ultimat3/db/diagnostics`: `dbDiagnostics()` and its row types. Its own entry because one caller
|
|
2
|
+
// runs it — `x db diagnostics` — while `@ultimat3/db` is imported by every role of every app: on the
|
|
3
|
+
// barrel these five modules were evaluated by every web, worker and migrate pod to serve nothing.
|
|
4
|
+
|
|
5
|
+
export type { DbDiagnostics, DbDiagnosticsOptions, DiagnosticSection } from './db-diagnostics';
|
|
6
|
+
export { DIAGNOSTICS_LIMIT, DIAGNOSTICS_TIMEOUT_MS, dbDiagnostics } from './db-diagnostics';
|
|
7
|
+
export type {
|
|
8
|
+
ActivityRow,
|
|
9
|
+
BlockerRow,
|
|
10
|
+
DeadTupleRow,
|
|
11
|
+
InvalidIndexRow,
|
|
12
|
+
NotValidConstraintRow,
|
|
13
|
+
SlowQueryRow,
|
|
14
|
+
UnusedIndexRow,
|
|
15
|
+
} from './db-diagnostics-rows';
|
package/src/drift-errors.ts
CHANGED
|
@@ -9,6 +9,13 @@
|
|
|
9
9
|
import { DbError } from './errors';
|
|
10
10
|
import { shellInertIdentifier } from './sql';
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* What a `x db gen` fix owes after its `#`. It diffs entities against the newest snapshot, so a column
|
|
14
|
+
* only a migration holds is an empty diff: exit 0, the finding still there. Both ways out are named.
|
|
15
|
+
*/
|
|
16
|
+
export const undeclaredColumnNext = (column: string): string =>
|
|
17
|
+
`declare ${column} on its entity first, or this writes nothing; no entity should hold it: restore the edited migration, then x db reset`;
|
|
18
|
+
|
|
12
19
|
/**
|
|
13
20
|
* The contract's pinned wording, and the ONE definition of it: `@ultimat3/entity` carried a second
|
|
14
21
|
* `dbDrift()` held in step by a comment and a test, deleted `As of 2026-10-02`. Drift is this
|
|
@@ -28,9 +35,7 @@ export const dbDrift = (tableName: string, columnName: string): DbError =>
|
|
|
28
35
|
cause: `table "${tableName}" has column "${columnName}" not present in any migration`,
|
|
29
36
|
fix:
|
|
30
37
|
shellInertIdentifier(columnName) === null
|
|
31
|
-
?
|
|
32
|
-
|
|
33
|
-
'this command'
|
|
34
|
-
: `x db gen "add ${columnName}"`,
|
|
38
|
+
? `x db gen "add the column named in this error" # its name carries shell syntax, so it is in the cause and not in this command; ${undeclaredColumnNext('it')}`
|
|
39
|
+
: `x db gen "add ${columnName}" # ${undeclaredColumnNext(columnName)}`,
|
|
35
40
|
meta: { table: tableName, column: columnName },
|
|
36
41
|
});
|