@steve31415/baselib 3.3.0 → 3.4.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.
@@ -0,0 +1,95 @@
1
+ import type { Runner } from '../deploy/types.js';
2
+ import { type CliIo } from './cli.js';
3
+ export declare const AXIOM_QUERY_URL = "https://api.axiom.co/v1/datasets/_apl?format=legacy";
4
+ export declare const QUERY_TOKEN_SECRET = "shared--axiom-query-token";
5
+ export declare const USAGE = "usage: pw-logs [options] [APL tail]\nQuery the fleet's Axiom logs (query-only token from Secret Manager).\n\n -d, --dataset <name> apps (default) | platform | rum\n -a, --app <name> app filter; default: the enclosing repo's deploy.config.json; \"all\" for none\n -l, --level <level> debug | info | warn | error; add \"+\" for that level and above (warn+)\n -s, --since <when> window start: duration back from now (30m, 6h, 3d) or timestamp (default 1h)\n -u, --until <when> window end, same forms (default now)\n -n, --limit <n> rows to return (default 200); events are the newest, printed oldest first\n --json print events (or aggregation rows) as a JSON array\n --all text mode: show every field (batch ids, full build_id)\n --apl print the query that would run and exit\n -h, --help\n\nThe tail is appended to the generated pipeline. The newest-first ordering\nis added only when the tail just filters or reshapes events (where, extend,\nproject-away/keep/rename, mv-expand, parse); a summarize, project, order or\ntake stage of your own decides the order. The limit always applies.\nExamples:\n pw-logs -l error -s 6h\n pw-logs -a chime \"where meta.user == 'x' | where message has 'sync'\"\n pw-logs -a all -l warn+ -s 24h \"summarize n=count() by app, message\"\n pw-logs -d platform -s 3d \"where severity != 'INFO'\"\nFields: apps = app, level, message, source, build_id, meta.* (a map: query\nsub-keys with dot notation, cast for summarize by \u2014 tostring(meta.user));\nplatform = service, severity, text, revision; rum = app, metric, value,\nrating, path, build_id.";
6
+ type Fields = Record<string, unknown>;
7
+ interface DatasetSpec {
8
+ appField: string;
9
+ levelField: string;
10
+ /** Ascending severity order; --level maps onto it. */
11
+ levels: readonly string[];
12
+ levelFor: Record<string, string>;
13
+ /** The fixed-position part of an event's text line, from these fields. */
14
+ headline: string[];
15
+ line?: (f: Fields) => string;
16
+ /** Present on every event of the dataset; rows without it (a summarize
17
+ * followed by more stages comes back as rows, not buckets) are tabled. */
18
+ marker?: string;
19
+ /** Fields hidden in text mode unless --all. */
20
+ quiet: string[];
21
+ }
22
+ export declare const DATASETS: Record<string, DatasetSpec>;
23
+ export declare function datasetSpec(name: string): DatasetSpec;
24
+ export interface LogsQuery {
25
+ dataset: string;
26
+ app?: string;
27
+ level?: string;
28
+ tail: string;
29
+ limit: number;
30
+ }
31
+ export declare function tailKeepsEvents(tail: string): boolean;
32
+ export declare function levelFilter(spec: DatasetSpec, level: string): string;
33
+ export declare function buildApl(q: LogsQuery): {
34
+ apl: string;
35
+ autoOrdered: boolean;
36
+ };
37
+ interface LegacyMatch {
38
+ _time: string;
39
+ data: Fields;
40
+ }
41
+ interface LegacyGroup {
42
+ group: Fields;
43
+ aggregations: {
44
+ op: string;
45
+ value: unknown;
46
+ }[];
47
+ }
48
+ interface LegacyResponse {
49
+ status?: {
50
+ isPartial?: boolean;
51
+ rowsMatched?: number;
52
+ elapsedTime?: number;
53
+ };
54
+ matches?: LegacyMatch[];
55
+ buckets?: {
56
+ series?: {
57
+ startTime: string;
58
+ endTime: string;
59
+ groups: LegacyGroup[] | null;
60
+ }[];
61
+ totals?: LegacyGroup[];
62
+ };
63
+ request?: {
64
+ aggregations?: {
65
+ op: string;
66
+ field: string;
67
+ alias: string;
68
+ }[] | null;
69
+ };
70
+ }
71
+ /** Events as flat records: `_time` first, nulls gone. */
72
+ export declare function eventRecords(matches: LegacyMatch[]): Fields[];
73
+ /** One greppable line per event: the dataset's headline, then the rest as k=v. */
74
+ export declare function renderEvents(dataset: string, records: Fields[], all: boolean): string;
75
+ /** Aggregation rows: group keys then aggregation aliases; a bucket time
76
+ * column when the query binned by time. min/max of _time come back as
77
+ * epoch nanoseconds and are shown as timestamps. */
78
+ export declare function aggregationRecords(response: LegacyResponse): Fields[];
79
+ export declare function renderTable(records: Fields[]): string;
80
+ export interface LogsOptions extends Partial<CliIo> {
81
+ argv: string[];
82
+ cwd: string;
83
+ env?: NodeJS.ProcessEnv;
84
+ now?: () => Date;
85
+ fetchFn?: typeof fetch;
86
+ runner?: Runner;
87
+ }
88
+ export declare function queryToken(env: NodeJS.ProcessEnv, runner: Runner, project: string): Promise<string>;
89
+ export declare function queryAxiom(fetchFn: typeof fetch, token: string, body: {
90
+ apl: string;
91
+ startTime: string;
92
+ endTime: string;
93
+ }): Promise<LegacyResponse>;
94
+ export declare function runLogs(options: LogsOptions): Promise<number>;
95
+ export {};
@@ -0,0 +1,302 @@
1
+ // `pw-logs` — an APL query against the fleet's Axiom datasets with the
2
+ // query-only token from Secret Manager. Bakes in the dataset conventions
3
+ // (OPERATIONS.md "Logging"): `apps` rows carry app / level / message /
4
+ // source / build_id and structured data under the `meta` map; `platform`
5
+ // rows are GCP platform events keyed by service / severity / text; `rum`
6
+ // rows are web-vitals keyed by app / metric / value / rating / path.
7
+ //
8
+ // The only request ever made is a POST to the query endpoint; the token is
9
+ // query-only and is never printed.
10
+ import { DEFAULT_PROJECT, NAME } from '../deploy/config.js';
11
+ import { must, realRunner } from '../deploy/exec.js';
12
+ import { LOG_LEVELS } from '../log-core.js';
13
+ import { findAppConfig } from './app.js';
14
+ import { cliIo, parseCli } from './cli.js';
15
+ import { flatten, formatRecords, pairs, parseTimeFlag, pruneNulls, scalarText } from './format.js';
16
+ export const AXIOM_QUERY_URL = 'https://api.axiom.co/v1/datasets/_apl?format=legacy';
17
+ export const QUERY_TOKEN_SECRET = 'shared--axiom-query-token';
18
+ const DEFAULT_LIMIT = 200;
19
+ const DEFAULT_SINCE = '1h';
20
+ const REQUEST_TIMEOUT_MS = 60_000;
21
+ const DATASET_NAME = /^[a-z][a-z0-9_-]*$/;
22
+ export const USAGE = `usage: pw-logs [options] [APL tail]
23
+ Query the fleet's Axiom logs (query-only token from Secret Manager).
24
+
25
+ -d, --dataset <name> apps (default) | platform | rum
26
+ -a, --app <name> app filter; default: the enclosing repo's deploy.config.json; "all" for none
27
+ -l, --level <level> debug | info | warn | error; add "+" for that level and above (warn+)
28
+ -s, --since <when> window start: duration back from now (30m, 6h, 3d) or timestamp (default ${DEFAULT_SINCE})
29
+ -u, --until <when> window end, same forms (default now)
30
+ -n, --limit <n> rows to return (default ${DEFAULT_LIMIT}); events are the newest, printed oldest first
31
+ --json print events (or aggregation rows) as a JSON array
32
+ --all text mode: show every field (batch ids, full build_id)
33
+ --apl print the query that would run and exit
34
+ -h, --help
35
+
36
+ The tail is appended to the generated pipeline. The newest-first ordering
37
+ is added only when the tail just filters or reshapes events (where, extend,
38
+ project-away/keep/rename, mv-expand, parse); a summarize, project, order or
39
+ take stage of your own decides the order. The limit always applies.
40
+ Examples:
41
+ pw-logs -l error -s 6h
42
+ pw-logs -a chime "where meta.user == 'x' | where message has 'sync'"
43
+ pw-logs -a all -l warn+ -s 24h "summarize n=count() by app, message"
44
+ pw-logs -d platform -s 3d "where severity != 'INFO'"
45
+ Fields: apps = app, level, message, source, build_id, meta.* (a map: query
46
+ sub-keys with dot notation, cast for summarize by — tostring(meta.user));
47
+ platform = service, severity, text, revision; rum = app, metric, value,
48
+ rating, path, build_id.`;
49
+ const APP_LEVEL_FOR = { debug: 'debug', info: 'info', warn: 'warn', error: 'error' };
50
+ // batch_id/batch_seq are minted by the shipper and severity mirrors level
51
+ // (log-core buildEvent/formBatch): noise when reading, kept for --json/--all.
52
+ const NOISE = ['batch_id', 'batch_seq', 'severity'];
53
+ const text = (v) => scalarText(v);
54
+ export const DATASETS = {
55
+ apps: {
56
+ appField: 'app',
57
+ levelField: 'level',
58
+ levels: LOG_LEVELS,
59
+ levelFor: APP_LEVEL_FOR,
60
+ headline: ['app', 'level', 'message', 'source'],
61
+ line: (f) => `${text(f.level).toUpperCase().padEnd(5)} ${text(f.app)}${f.source === 'browser' ? '/browser' : ''} ${text(f.message)}`,
62
+ marker: 'message',
63
+ quiet: NOISE,
64
+ },
65
+ platform: {
66
+ appField: 'service',
67
+ levelField: 'severity',
68
+ levels: ['DEBUG', 'INFO', 'NOTICE', 'WARNING', 'ERROR', 'CRITICAL', 'ALERT', 'EMERGENCY'],
69
+ levelFor: { debug: 'DEBUG', info: 'INFO', warn: 'WARNING', error: 'ERROR' },
70
+ headline: ['service', 'severity', 'text', 'revision'],
71
+ line: (f) => `${text(f.severity).padEnd(7)} ${text(f.service)}${f.revision ? `@${text(f.revision)}` : ''} ${text(f.text)}`,
72
+ marker: 'text',
73
+ quiet: ['batch_id', 'batch_seq', 'insert_id'],
74
+ },
75
+ rum: {
76
+ appField: 'app',
77
+ levelField: 'level',
78
+ levels: LOG_LEVELS,
79
+ levelFor: APP_LEVEL_FOR,
80
+ headline: ['app', 'metric', 'value', 'rating', 'path'],
81
+ line: (f) => `${text(f.app)} ${text(f.metric)}=${text(f.value)} ${text(f.rating)} ${text(f.path)}`,
82
+ marker: 'metric',
83
+ quiet: [...NOISE, 'message', 'level', 'source'],
84
+ },
85
+ };
86
+ const GENERIC = { appField: 'app', levelField: 'level', levels: LOG_LEVELS, levelFor: APP_LEVEL_FOR, headline: [], quiet: [] };
87
+ export function datasetSpec(name) {
88
+ return DATASETS[name] ?? GENERIC;
89
+ }
90
+ const quote = (s) => `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
91
+ // Stages that keep one row per event (and its _time), so the default
92
+ // newest-first ordering still means something after them. Anything else —
93
+ // summarize, project, order, take, an operator not listed — decides its own
94
+ // shape and order. Splitting on `|` misreads a pipe inside a string literal,
95
+ // which only costs the default ordering, never a broken query.
96
+ const KEEPS_EVENTS = new Set(['where', 'extend', 'project-away', 'project-keep', 'project-rename', 'mv-expand', 'parse']);
97
+ export function tailKeepsEvents(tail) {
98
+ return tail.split('|').every((stage) => {
99
+ const word = stage.trim().split(/\s+/)[0].toLowerCase();
100
+ return word === '' || KEEPS_EVENTS.has(word);
101
+ });
102
+ }
103
+ export function levelFilter(spec, level) {
104
+ const plus = level.endsWith('+');
105
+ const word = (plus ? level.slice(0, -1) : level).toLowerCase();
106
+ const value = spec.levelFor[word];
107
+ if (!value)
108
+ throw new Error(`unknown level ${level}: use debug, info, warn or error (optionally with +)`);
109
+ if (!plus)
110
+ return `${spec.levelField} == ${quote(value)}`;
111
+ const from = spec.levels.indexOf(value);
112
+ return `${spec.levelField} in (${spec.levels.slice(from).map(quote).join(', ')})`;
113
+ }
114
+ export function buildApl(q) {
115
+ const spec = datasetSpec(q.dataset);
116
+ const stages = [`['${q.dataset}']`];
117
+ if (q.app)
118
+ stages.push(`where ${spec.appField} == ${quote(q.app)}`);
119
+ if (q.level)
120
+ stages.push(`where ${levelFilter(spec, q.level)}`);
121
+ const tail = q.tail.replace(/^\s*\|/, '').trim();
122
+ if (tail)
123
+ stages.push(tail);
124
+ const autoOrdered = tailKeepsEvents(tail);
125
+ if (autoOrdered)
126
+ stages.push('order by _time desc');
127
+ stages.push(`take ${q.limit}`);
128
+ return { apl: stages.join(' | '), autoOrdered };
129
+ }
130
+ const isoTime = (t) => {
131
+ const d = new Date(t);
132
+ return isNaN(d.getTime()) ? t : d.toISOString();
133
+ };
134
+ /** Events as flat records: `_time` first, nulls gone. */
135
+ export function eventRecords(matches) {
136
+ return matches.map((m) => ({ _time: isoTime(m._time), ...pruneNulls(m.data) }));
137
+ }
138
+ /** One greppable line per event: the dataset's headline, then the rest as k=v. */
139
+ export function renderEvents(dataset, records, all) {
140
+ const spec = datasetSpec(dataset);
141
+ return records
142
+ .map((record) => {
143
+ const { _time, ...fields } = record;
144
+ const rest = {};
145
+ for (const [k, v] of Object.entries(fields)) {
146
+ if (spec.headline.includes(k))
147
+ continue;
148
+ if (!all && spec.quiet.includes(k))
149
+ continue;
150
+ rest[k] = !all && k === 'build_id' && typeof v === 'string' ? v.slice(0, 7) : v;
151
+ }
152
+ return [text(_time), spec.line?.(fields) ?? '', pairs(flatten(rest))].filter((s) => s !== '').join(' ');
153
+ })
154
+ .join('\n');
155
+ }
156
+ /** Aggregation rows: group keys then aggregation aliases; a bucket time
157
+ * column when the query binned by time. min/max of _time come back as
158
+ * epoch nanoseconds and are shown as timestamps. */
159
+ export function aggregationRecords(response) {
160
+ const series = (response.buckets?.series ?? []).filter((s) => s.groups && s.groups.length > 0);
161
+ const binned = series.length > 1 || !series[0]?.startTime.startsWith('1970-');
162
+ const timeAggs = new Set((response.request?.aggregations ?? [])
163
+ .filter((a) => a.field === '_time' && (a.op === 'min' || a.op === 'max'))
164
+ .map((a) => a.alias));
165
+ return series.flatMap((s) => (s.groups ?? []).map((g) => {
166
+ const row = binned ? { _time: isoTime(s.startTime) } : {};
167
+ Object.assign(row, g.group);
168
+ for (const a of g.aggregations) {
169
+ row[a.op] =
170
+ timeAggs.has(a.op) && typeof a.value === 'number' ? new Date(Math.round(a.value / 1e6)).toISOString() : a.value;
171
+ }
172
+ return row;
173
+ }));
174
+ }
175
+ export function renderTable(records) {
176
+ const columns = [];
177
+ for (const r of records)
178
+ for (const k of Object.keys(r))
179
+ if (!columns.includes(k))
180
+ columns.push(k);
181
+ return formatRecords(columns, records);
182
+ }
183
+ export async function queryToken(env, runner, project) {
184
+ if (env.AXIOM_QUERY_TOKEN)
185
+ return env.AXIOM_QUERY_TOKEN;
186
+ const result = await must(runner, 'gcloud', [
187
+ 'secrets', 'versions', 'access', 'latest', '--secret', QUERY_TOKEN_SECRET, '--project', project,
188
+ ]);
189
+ const token = result.stdout.trim();
190
+ if (!token)
191
+ throw new Error(`secret ${QUERY_TOKEN_SECRET} is empty`);
192
+ return token;
193
+ }
194
+ export async function queryAxiom(fetchFn, token, body) {
195
+ const response = await fetchFn(AXIOM_QUERY_URL, {
196
+ method: 'POST',
197
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
198
+ body: JSON.stringify(body),
199
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
200
+ });
201
+ const raw = await response.text();
202
+ if (!response.ok) {
203
+ let detail = raw;
204
+ try {
205
+ const parsed = JSON.parse(raw);
206
+ if (parsed.message)
207
+ detail = parsed.message;
208
+ }
209
+ catch {
210
+ // not JSON; report the raw body
211
+ }
212
+ throw new Error(`Axiom HTTP ${response.status}: ${detail}`);
213
+ }
214
+ return JSON.parse(raw);
215
+ }
216
+ const FLAGS = {
217
+ dataset: { type: 'string', short: 'd', default: 'apps' },
218
+ app: { type: 'string', short: 'a' },
219
+ level: { type: 'string', short: 'l' },
220
+ since: { type: 'string', short: 's', default: DEFAULT_SINCE },
221
+ until: { type: 'string', short: 'u' },
222
+ limit: { type: 'string', short: 'n', default: String(DEFAULT_LIMIT) },
223
+ json: { type: 'boolean', default: false },
224
+ all: { type: 'boolean', default: false },
225
+ apl: { type: 'boolean', default: false },
226
+ help: { type: 'boolean', short: 'h', default: false },
227
+ };
228
+ export async function runLogs(options) {
229
+ const io = cliIo('pw-logs', options);
230
+ const parsed = parseCli(options.argv, FLAGS, USAGE, io);
231
+ if ('code' in parsed)
232
+ return parsed.code;
233
+ const { values: v, positionals } = parsed;
234
+ const env = options.env ?? process.env;
235
+ const now = (options.now ?? (() => new Date()))();
236
+ const appConfig = await findAppConfig(options.cwd);
237
+ let query;
238
+ let apl;
239
+ let autoOrdered;
240
+ let startTime;
241
+ let endTime;
242
+ try {
243
+ if (!DATASET_NAME.test(v.dataset))
244
+ throw new Error(`invalid dataset name: ${v.dataset}`);
245
+ const limit = Number(v.limit);
246
+ if (!Number.isInteger(limit) || limit < 1)
247
+ throw new Error(`--limit must be a positive integer, got ${v.limit}`);
248
+ let app = v.app ?? appConfig?.app;
249
+ if (app === 'all')
250
+ app = undefined;
251
+ if (app !== undefined && !NAME.test(app))
252
+ throw new Error(`invalid app name: ${app}`);
253
+ query = { dataset: v.dataset, app, level: v.level, tail: positionals.join(' '), limit };
254
+ ({ apl, autoOrdered } = buildApl(query));
255
+ startTime = parseTimeFlag(v.since, now);
256
+ endTime = v.until === undefined ? now : parseTimeFlag(v.until, now);
257
+ if (endTime <= startTime)
258
+ throw new Error(`empty window: ${startTime.toISOString()} .. ${endTime.toISOString()}`);
259
+ }
260
+ catch (err) {
261
+ io.log(err.message);
262
+ return 2;
263
+ }
264
+ const body = { apl, startTime: startTime.toISOString(), endTime: endTime.toISOString() };
265
+ if (v.apl) {
266
+ io.stdout(`${apl}\n-- ${body.startTime} .. ${body.endTime}`);
267
+ return 0;
268
+ }
269
+ let response;
270
+ try {
271
+ const token = await queryToken(env, options.runner ?? realRunner, appConfig?.project ?? DEFAULT_PROJECT);
272
+ response = await queryAxiom(options.fetchFn ?? fetch, token, body);
273
+ }
274
+ catch (err) {
275
+ io.log(`query failed: ${err.message}\n${apl}`);
276
+ return 1;
277
+ }
278
+ const matches = response.matches ?? [];
279
+ const window = `${body.startTime} .. ${body.endTime}`;
280
+ const marker = datasetSpec(query.dataset).marker;
281
+ const events = matches.length > 0 && (marker === undefined || matches[0].data[marker] != null);
282
+ if (events) {
283
+ // Newest-first from the server; print oldest first so the log reads down.
284
+ const records = eventRecords(autoOrdered ? [...matches].reverse() : matches);
285
+ io.stdout(v.json ? JSON.stringify(records, null, 2) : renderEvents(query.dataset, records, v.all));
286
+ const matched = response.status?.rowsMatched;
287
+ const of = matched !== undefined && matched > records.length ? ` of ${matched} matching` : '';
288
+ io.log(`${records.length} event${records.length === 1 ? '' : 's'}${of}, ${window}`);
289
+ }
290
+ else {
291
+ const rows = matches.length > 0 ? eventRecords(matches) : aggregationRecords(response);
292
+ if (v.json)
293
+ io.stdout(JSON.stringify(rows, null, 2));
294
+ else
295
+ io.stdout(rows.length === 0 ? '(no results)' : renderTable(rows));
296
+ const noun = rows.length === 0 && autoOrdered ? 'events' : `row${rows.length === 1 ? '' : 's'}`;
297
+ io.log(`${rows.length} ${noun}, ${window}`);
298
+ }
299
+ if (response.status?.isPartial)
300
+ io.log('warning: Axiom reported a partial result');
301
+ return 0;
302
+ }
@@ -0,0 +1,42 @@
1
+ import pg from 'pg';
2
+ import type { Runner } from '../deploy/types.js';
3
+ import { type CliIo } from './cli.js';
4
+ export declare const DEFAULT_INSTANCE = "plasticine-prod:us-west1:plasticine-pg";
5
+ export declare const DEFAULT_DB_USER: string;
6
+ export interface SqlTarget {
7
+ instance: string;
8
+ user: string;
9
+ database: string;
10
+ }
11
+ export interface SqlResult {
12
+ command: string;
13
+ fields: string[];
14
+ rows: Record<string, unknown>[];
15
+ rowCount: number | null;
16
+ }
17
+ export interface SqlClient {
18
+ query(text: string): Promise<pg.QueryResult | pg.QueryResult[]>;
19
+ end(): Promise<void>;
20
+ }
21
+ export interface SqlOptions extends Partial<CliIo> {
22
+ argv: string[];
23
+ cwd: string;
24
+ /** Reads the whole of stdin (the SQL when no positional was given). */
25
+ stdin?: () => Promise<string>;
26
+ stdinIsTty?: boolean;
27
+ /** Test seam: replaces the Cloud SQL connector. */
28
+ connect?: (target: SqlTarget, log: (m: string) => void) => Promise<SqlClient>;
29
+ runner?: Runner;
30
+ }
31
+ export declare const USAGE: string;
32
+ /** Text the tool will not send at all: anything that could turn the session
33
+ * read-write, end the guarding transaction, or act on other backends. A
34
+ * false positive (the word "commit" in a string literal) is the acceptable
35
+ * cost; the message names the pattern so the query can be rephrased. */
36
+ export declare function refuseUnsafeSql(sql: string): string | null;
37
+ /** Run `sql` inside a read-only transaction that is always rolled back.
38
+ * The lock-in query both verifies the mode and pins it: after a snapshot has
39
+ * been taken Postgres refuses `set transaction read write`. */
40
+ export declare function executeReadOnly(client: SqlClient, sql: string, timeoutMs: number): Promise<SqlResult[]>;
41
+ export declare function connectCloudSql(target: SqlTarget, log: (m: string) => void, runner?: Runner): Promise<SqlClient>;
42
+ export declare function runSql(options: SqlOptions): Promise<number>;
@@ -0,0 +1,219 @@
1
+ // `pw-sql` — a read-only SQL query against a production database on the
2
+ // fleet's Cloud SQL instance, with IAM authentication as the coding agent.
3
+ //
4
+ // Read-only is enforced three ways, each sufficient on its own:
5
+ // 1. the session starts with default_transaction_read_only=on (a startup
6
+ // parameter, so it holds before any statement runs);
7
+ // 2. the query runs inside `begin read only`, after a lock-in query — Postgres
8
+ // refuses a switch to read-write once a transaction has taken a snapshot —
9
+ // and is always rolled back;
10
+ // 3. SQL that could change those settings or end the transaction is refused
11
+ // before it is sent (refuseUnsafeSql).
12
+ // The agent's Postgres role has write privileges on every app database, so
13
+ // the tool, not the grants, is the safeguard.
14
+ import pg from 'pg';
15
+ import { cloudSqlClientOptions, dateAsText } from '../db.js';
16
+ import { DEFAULT_ACCOUNT, DEFAULT_PROJECT } from '../deploy/config.js';
17
+ import { findAppConfig } from './app.js';
18
+ import { cliIo, parseCli } from './cli.js';
19
+ import { formatRecords } from './format.js';
20
+ import { authClientFor, describeCredential, resolveGcpCredential } from './gcp-auth.js';
21
+ const INSTANCE_NAME = 'plasticine-pg';
22
+ export const DEFAULT_INSTANCE = `${DEFAULT_PROJECT}:us-west1:${INSTANCE_NAME}`;
23
+ export const DEFAULT_DB_USER = DEFAULT_ACCOUNT.replace(/\.gserviceaccount\.com$/, '');
24
+ const DEFAULT_TIMEOUT_S = 60;
25
+ const CONNECT_TIMEOUT_MS = 20_000;
26
+ export const USAGE = `usage: pw-sql [options] [SQL...]
27
+ Read-only query against a production database (Cloud SQL, IAM auth).
28
+ SQL comes from the arguments (joined by spaces) or from stdin.
29
+
30
+ --db <name> database; default: the app of the enclosing repo's deploy.config.json
31
+ --json print rows as a JSON array (one array per statement) instead of a table
32
+ --timeout <s> statement timeout in seconds (default ${DEFAULT_TIMEOUT_S})
33
+ --instance <conn> Cloud SQL connection name (default ${DEFAULT_INSTANCE})
34
+ --user <iam-user> IAM database user (default ${DEFAULT_DB_USER})
35
+ -h, --help
36
+
37
+ Examples:
38
+ pw-sql "select count(*) from items"
39
+ pw-sql --db chime --json "select * from reminders order by id desc limit 5"
40
+ echo "select now()" | pw-sql --db auth
41
+ Every session is read-only: writes, DDL, temp tables and sequence advances fail.`;
42
+ /** Text the tool will not send at all: anything that could turn the session
43
+ * read-write, end the guarding transaction, or act on other backends. A
44
+ * false positive (the word "commit" in a string literal) is the acceptable
45
+ * cost; the message names the pattern so the query can be rephrased. */
46
+ export function refuseUnsafeSql(sql) {
47
+ const checks = [
48
+ [/transaction_read_only/i, 'references transaction_read_only'],
49
+ [/\bread\s+write\b/i, 'sets read-write mode'],
50
+ [/\bset_config\s*\(/i, 'calls set_config()'],
51
+ [/\b(commit|prepare\s+transaction)\b/i, 'contains transaction control (commit/prepare transaction)'],
52
+ [
53
+ /\bpg_(terminate_backend|cancel_backend|reload_conf|switch_wal|create_restore_point|create_physical_replication_slot|create_logical_replication_slot|drop_replication_slot|promote)\b/i,
54
+ 'calls an administrative function with side effects',
55
+ ],
56
+ ];
57
+ for (const [pattern, reason] of checks) {
58
+ if (pattern.test(sql))
59
+ return `refusing SQL that ${reason} (pw-sql is read-only)`;
60
+ }
61
+ return null;
62
+ }
63
+ /** Run `sql` inside a read-only transaction that is always rolled back.
64
+ * The lock-in query both verifies the mode and pins it: after a snapshot has
65
+ * been taken Postgres refuses `set transaction read write`. */
66
+ export async function executeReadOnly(client, sql, timeoutMs) {
67
+ await client.query('begin read only');
68
+ try {
69
+ await client.query(`set local statement_timeout = ${Math.max(1, Math.floor(timeoutMs))}`);
70
+ const mode = (await client.query("select current_setting('transaction_read_only') as ro"));
71
+ if (mode.rows[0]?.ro !== 'on')
72
+ throw new Error('session is not read-only; refusing to run the query');
73
+ const raw = await client.query(sql);
74
+ return (Array.isArray(raw) ? raw : [raw]).map((r) => ({
75
+ command: r.command,
76
+ fields: r.fields.map((f) => f.name),
77
+ rows: r.rows,
78
+ rowCount: r.rowCount,
79
+ }));
80
+ }
81
+ finally {
82
+ await client.query('rollback').catch(() => { });
83
+ }
84
+ }
85
+ // Value parsing for output: int8 as a number when exact; dates and
86
+ // zone-less timestamps as the server's text (db.ts's DATE policy — a Date
87
+ // would smear them across zones); everything else pg's default, so
88
+ // timestamptz becomes a Date and prints as UTC ISO.
89
+ function outputTypes() {
90
+ const { builtins } = pg.types;
91
+ const overrides = {
92
+ [builtins.INT8]: (v) => (Number.isSafeInteger(Number(v)) ? Number(v) : v),
93
+ [builtins.DATE]: dateAsText,
94
+ [builtins.TIMESTAMP]: dateAsText,
95
+ };
96
+ return {
97
+ getTypeParser: (oid, format) => {
98
+ if (format !== 'binary' && overrides[oid])
99
+ return overrides[oid];
100
+ return pg.types.getTypeParser(oid, format);
101
+ },
102
+ };
103
+ }
104
+ export async function connectCloudSql(target, log, runner) {
105
+ const cred = await resolveGcpCredential({ runner });
106
+ log(`connecting to ${target.instance} db=${target.database} as ${target.user} via ${describeCredential(cred)}`);
107
+ const { options: connection, connector } = await cloudSqlClientOptions(target.instance, authClientFor(cred));
108
+ const client = new pg.Client({
109
+ ...connection,
110
+ user: target.user,
111
+ database: target.database,
112
+ options: '-c default_transaction_read_only=on',
113
+ connectionTimeoutMillis: CONNECT_TIMEOUT_MS,
114
+ types: outputTypes(),
115
+ });
116
+ try {
117
+ await client.connect();
118
+ }
119
+ catch (err) {
120
+ connector.close();
121
+ throw err;
122
+ }
123
+ return {
124
+ query: (text) => client.query(text),
125
+ end: async () => {
126
+ await client.end();
127
+ connector.close();
128
+ },
129
+ };
130
+ }
131
+ function renderResults(results, json) {
132
+ if (json)
133
+ return results.map((r) => JSON.stringify(r.rows, null, 2)).join('\n');
134
+ return results
135
+ .map((r) => {
136
+ if (r.fields.length === 0)
137
+ return `${r.command}${r.rowCount === null ? '' : ` ${r.rowCount}`}`;
138
+ return `${formatRecords(r.fields, r.rows)}\n(${r.rows.length} row${r.rows.length === 1 ? '' : 's'})`;
139
+ })
140
+ .join('\n\n');
141
+ }
142
+ const FLAGS = {
143
+ db: { type: 'string' },
144
+ json: { type: 'boolean', default: false },
145
+ timeout: { type: 'string' },
146
+ instance: { type: 'string' },
147
+ user: { type: 'string' },
148
+ help: { type: 'boolean', short: 'h', default: false },
149
+ };
150
+ export async function runSql(options) {
151
+ const io = cliIo('pw-sql', options);
152
+ const parsed = parseCli(options.argv, FLAGS, USAGE, io);
153
+ if ('code' in parsed)
154
+ return parsed.code;
155
+ const { values, positionals } = parsed;
156
+ const timeoutS = values.timeout === undefined ? DEFAULT_TIMEOUT_S : Number(values.timeout);
157
+ if (!(timeoutS > 0)) {
158
+ io.log(`--timeout must be a positive number of seconds, got ${values.timeout}`);
159
+ return 2;
160
+ }
161
+ let sql = positionals.join(' ').trim();
162
+ if (!sql) {
163
+ if (options.stdinIsTty ?? process.stdin.isTTY) {
164
+ io.stdout(USAGE);
165
+ return 2;
166
+ }
167
+ sql = (await (options.stdin ?? readStdin)()).trim();
168
+ if (!sql) {
169
+ io.log('no SQL given (arguments or stdin)');
170
+ return 2;
171
+ }
172
+ }
173
+ const refusal = refuseUnsafeSql(sql);
174
+ if (refusal) {
175
+ io.log(refusal);
176
+ return 2;
177
+ }
178
+ const app = await findAppConfig(options.cwd);
179
+ const database = values.db ?? app?.app;
180
+ if (!database) {
181
+ io.log('no database: pass --db <name> or run inside an app repo (deploy.config.json)');
182
+ return 2;
183
+ }
184
+ const target = {
185
+ instance: values.instance ?? (app ? `${app.project}:${app.region}:${INSTANCE_NAME}` : DEFAULT_INSTANCE),
186
+ user: values.user ?? DEFAULT_DB_USER,
187
+ database,
188
+ };
189
+ const connect = options.connect ?? ((t, l) => connectCloudSql(t, l, options.runner));
190
+ let client;
191
+ try {
192
+ client = await connect(target, io.log);
193
+ }
194
+ catch (err) {
195
+ io.log(`connection failed: ${err.message}`);
196
+ return 1;
197
+ }
198
+ try {
199
+ const started = Date.now();
200
+ const results = await executeReadOnly(client, sql, timeoutS * 1000);
201
+ io.stdout(renderResults(results, values.json));
202
+ io.log(`${results.reduce((n, r) => n + r.rows.length, 0)} rows in ${Date.now() - started} ms`);
203
+ return 0;
204
+ }
205
+ catch (err) {
206
+ const e = err;
207
+ io.log(`query failed: ${e.message}${e.position ? ` (at character ${e.position})` : ''}${e.hint ? `\nhint: ${e.hint}` : ''}`);
208
+ return 1;
209
+ }
210
+ finally {
211
+ await client.end().catch(() => { });
212
+ }
213
+ }
214
+ async function readStdin() {
215
+ const chunks = [];
216
+ for await (const chunk of process.stdin)
217
+ chunks.push(chunk);
218
+ return Buffer.concat(chunks).toString('utf8');
219
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steve31415/baselib",
3
- "version": "3.3.0",
3
+ "version": "3.4.0",
4
4
  "description": "Plasticine new-world shared platform library: logging, auth, service-to-service auth, db, HTTP, sync, app updates",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -73,7 +73,9 @@
73
73
  "bin": {
74
74
  "check-test-owners": "./dist/bin/check-test-owners.js",
75
75
  "pw-deploy": "./dist/bin/pw-deploy.js",
76
- "pw-rollback": "./dist/bin/pw-rollback.js"
76
+ "pw-logs": "./dist/bin/pw-logs.js",
77
+ "pw-rollback": "./dist/bin/pw-rollback.js",
78
+ "pw-sql": "./dist/bin/pw-sql.js"
77
79
  },
78
80
  "engines": {
79
81
  "node": "22.x"