@steve31415/baselib 3.3.1 → 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.
package/README.md CHANGED
@@ -28,6 +28,10 @@ from a named `NAME@snewman.net` sender, no outbox — a failed send is an
28
28
  ERROR log (`~/plasticine-way/docs/SECURITY.md`, "Resend API key").
29
29
 
30
30
  Bins: `check-test-owners` — the fleet's structural test-coverage gate; every
31
- app runs it from `npm run verify`.
31
+ app runs it from `npm run verify`. `pw-deploy` / `pw-rollback` — the fleet
32
+ deploy (`~/plasticine-way/docs/OPERATIONS.md`, "Deployment safety").
33
+ `pw-sql` / `pw-logs` — read-only production queries (database, Axiom), the
34
+ first step of any production investigation (same doc, "Investigating
35
+ production state").
32
36
 
33
37
  Reflects Plasticine Way commit: see `docs/IMPL.md` footer.
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ // `pw-logs` — Axiom log query (OPERATIONS.md "Investigating production
3
+ // state"). All logic lives in ../ops/logs.ts.
4
+ import { runLogs } from '../ops/logs.js';
5
+ process.exit(await runLogs({ argv: process.argv.slice(2), cwd: process.cwd() }));
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ // `pw-sql` — read-only production SQL (OPERATIONS.md "Investigating
3
+ // production state"). All logic lives in ../ops/sql.ts.
4
+ import { runSql } from '../ops/sql.js';
5
+ process.exit(await runSql({ argv: process.argv.slice(2), cwd: process.cwd() }));
package/dist/db.d.ts CHANGED
@@ -1,5 +1,17 @@
1
+ import type { ConnectorOptions } from '@google-cloud/cloud-sql-connector';
1
2
  import pg from 'pg';
2
3
  import { type Logger } from './log-core.js';
4
+ export declare const dateAsText: (v: string) => string;
5
+ /** Connection options for a Cloud SQL instance the fleet way: the Node
6
+ * connector, IAM authentication, public IP. `auth` replaces the connector's
7
+ * default credential resolution (the agent's tooling needs that; deployed
8
+ * code uses the default). The connector stays open for the connections'
9
+ * lifetime; close it when they are done. */
10
+ export declare function cloudSqlClientOptions(instance: string, auth?: ConnectorOptions['auth']): Promise<{
11
+ options: Awaited<ReturnType<Connector['getOptions']>>;
12
+ connector: Connector;
13
+ }>;
14
+ type Connector = import('@google-cloud/cloud-sql-connector').Connector;
3
15
  export interface PoolOptions {
4
16
  /** Override the database name (default: env DB_NAME / DATABASE_URL path). */
5
17
  database?: string;
@@ -32,3 +44,4 @@ export declare function endPool(pool: pg.Pool): Promise<void>;
32
44
  export declare function migrate(pool: pg.Pool, migrationsDir: string, logger?: Logger): Promise<{
33
45
  applied: string[];
34
46
  }>;
47
+ export {};
package/dist/db.js CHANGED
@@ -13,7 +13,23 @@ import { serializeError } from './log-core.js';
13
13
  // pg instance createPool actually uses — because apps that set it on their
14
14
  // own pg copy miss this one (file:-dependency installs resolve separate pg
15
15
  // instances).
16
- pg.types.setTypeParser(pg.types.builtins.DATE, (v) => v);
16
+ export const dateAsText = (v) => v;
17
+ pg.types.setTypeParser(pg.types.builtins.DATE, dateAsText);
18
+ /** Connection options for a Cloud SQL instance the fleet way: the Node
19
+ * connector, IAM authentication, public IP. `auth` replaces the connector's
20
+ * default credential resolution (the agent's tooling needs that; deployed
21
+ * code uses the default). The connector stays open for the connections'
22
+ * lifetime; close it when they are done. */
23
+ export async function cloudSqlClientOptions(instance, auth) {
24
+ const { Connector, AuthTypes, IpAddressTypes } = await import('@google-cloud/cloud-sql-connector');
25
+ const connector = new Connector(auth ? { auth } : {});
26
+ const options = await connector.getOptions({
27
+ instanceConnectionName: instance,
28
+ ipType: IpAddressTypes.PUBLIC,
29
+ authType: AuthTypes.IAM,
30
+ });
31
+ return { options, connector };
32
+ }
17
33
  /** pg-pool re-emits an idle client's socket error as its own 'error' event —
18
34
  * in production "Connection terminated unexpectedly" when Cloud SQL closes an
19
35
  * idle connection. Unhandled, that event throws and takes the process down
@@ -37,13 +53,7 @@ export async function createPool(opts = {}) {
37
53
  async function newPool(opts) {
38
54
  const instance = process.env.CLOUD_SQL_INSTANCE;
39
55
  if (instance) {
40
- const { Connector, AuthTypes, IpAddressTypes } = await import('@google-cloud/cloud-sql-connector');
41
- const connector = new Connector();
42
- const clientOpts = await connector.getOptions({
43
- instanceConnectionName: instance,
44
- ipType: IpAddressTypes.PUBLIC,
45
- authType: AuthTypes.IAM,
46
- });
56
+ const { options: clientOpts } = await cloudSqlClientOptions(instance);
47
57
  return new pg.Pool({
48
58
  ...clientOpts,
49
59
  user: requireEnv('DB_IAM_USER'),
@@ -1,5 +1,8 @@
1
1
  import type { DeployConfig, DeployHooks } from './types.js';
2
+ export declare const DEFAULT_PROJECT = "plasticine-prod";
2
3
  export declare const DEFAULT_ACCOUNT = "coding-agent@plasticine-prod.iam.gserviceaccount.com";
4
+ /** App and service names (also the database and log `app` values). */
5
+ export declare const NAME: RegExp;
3
6
  export declare function validateConfig(raw: unknown): DeployConfig;
4
7
  export declare function loadConfig(repoRoot: string): Promise<DeployConfig>;
5
8
  export declare function loadHooks(repoRoot: string, config: DeployConfig): Promise<DeployHooks>;
@@ -3,8 +3,10 @@
3
3
  import { readFile } from 'node:fs/promises';
4
4
  import { pathToFileURL } from 'node:url';
5
5
  import { resolve } from 'node:path';
6
- export const DEFAULT_ACCOUNT = 'coding-agent@plasticine-prod.iam.gserviceaccount.com';
7
- const NAME = /^[a-z][a-z0-9-]{1,30}$/;
6
+ export const DEFAULT_PROJECT = 'plasticine-prod';
7
+ export const DEFAULT_ACCOUNT = `coding-agent@${DEFAULT_PROJECT}.iam.gserviceaccount.com`;
8
+ /** App and service names (also the database and log `app` values). */
9
+ export const NAME = /^[a-z][a-z0-9-]{1,30}$/;
8
10
  const PREFIX = /^[a-z0-9][a-z0-9/_-]*\/$/;
9
11
  function fail(detail) {
10
12
  throw new Error(`deploy.config.json invalid: ${detail}`);
@@ -1,4 +1,5 @@
1
- export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
1
+ export declare const LOG_LEVELS: readonly ["debug", "info", "warn", "error"];
2
+ export type LogLevel = (typeof LOG_LEVELS)[number];
2
3
  export interface LogEvent {
3
4
  _time: string;
4
5
  app: string;
package/dist/log-core.js CHANGED
@@ -9,6 +9,7 @@
9
9
  // logger turns those into stdout drop markers that watchdog2's daily
10
10
  // ship-failure check counts. makeEvent also guards the shape of `meta`: every
11
11
  // distinct key is an Axiom column (see "Key-shape guard" below).
12
+ export const LOG_LEVELS = ['debug', 'info', 'warn', 'error'];
12
13
  export function serializeError(err) {
13
14
  if (err instanceof Error) {
14
15
  const out = { name: err.name, message: err.message, stack: err.stack };
@@ -0,0 +1,4 @@
1
+ import type { DeployConfig } from '../deploy/types.js';
2
+ export declare function fileExists(path: string): Promise<boolean>;
3
+ /** The deploy config of the nearest enclosing app repo, or null outside one. */
4
+ export declare function findAppConfig(startDir: string): Promise<DeployConfig | null>;
@@ -0,0 +1,28 @@
1
+ // Which app is this? The production query tools take their defaults (the
2
+ // database name, the log `app` filter, the GCP project) from the
3
+ // deploy.config.json of the app repo they are run in, found by walking up
4
+ // from the working directory.
5
+ import { access } from 'node:fs/promises';
6
+ import { dirname, join } from 'node:path';
7
+ import { loadConfig } from '../deploy/config.js';
8
+ export async function fileExists(path) {
9
+ try {
10
+ await access(path);
11
+ return true;
12
+ }
13
+ catch {
14
+ return false;
15
+ }
16
+ }
17
+ /** The deploy config of the nearest enclosing app repo, or null outside one. */
18
+ export async function findAppConfig(startDir) {
19
+ let dir = startDir;
20
+ for (;;) {
21
+ if (await fileExists(join(dir, 'deploy.config.json')))
22
+ return loadConfig(dir);
23
+ const parent = dirname(dir);
24
+ if (parent === dir)
25
+ return null;
26
+ dir = parent;
27
+ }
28
+ }
@@ -0,0 +1,16 @@
1
+ import { parseArgs, type ParseArgsConfig } from 'node:util';
2
+ export interface CliIo {
3
+ stdout: (text: string) => void;
4
+ log: (message: string) => void;
5
+ }
6
+ export declare function cliIo(name: string, overrides?: Partial<CliIo>): CliIo;
7
+ type Options = NonNullable<ParseArgsConfig['options']>;
8
+ type Parsed<T extends Options> = ReturnType<typeof parseArgs<{
9
+ options: T;
10
+ allowPositionals: true;
11
+ }>>;
12
+ /** Parsed flags and positionals, or the exit code already dealt with. */
13
+ export declare function parseCli<T extends Options>(argv: string[], options: T, usage: string, io: CliIo): Parsed<T> | {
14
+ code: number;
15
+ };
16
+ export {};
@@ -0,0 +1,27 @@
1
+ // Shared command-line conventions of the pw-* query tools: parseArgs with
2
+ // positionals, usage + exit 2 on a bad flag, usage + exit 0 on --help,
3
+ // progress on stderr under the tool's name, results on stdout.
4
+ import { parseArgs } from 'node:util';
5
+ export function cliIo(name, overrides = {}) {
6
+ return {
7
+ stdout: overrides.stdout ?? ((t) => process.stdout.write(`${t}\n`)),
8
+ log: overrides.log ?? ((m) => console.error(`[${name}] ${m}`)),
9
+ };
10
+ }
11
+ /** Parsed flags and positionals, or the exit code already dealt with. */
12
+ export function parseCli(argv, options, usage, io) {
13
+ let parsed;
14
+ try {
15
+ parsed = parseArgs({ args: argv, options, allowPositionals: true });
16
+ }
17
+ catch (err) {
18
+ io.log(err.message);
19
+ io.stdout(usage);
20
+ return { code: 2 };
21
+ }
22
+ if (parsed.values.help) {
23
+ io.stdout(usage);
24
+ return { code: 0 };
25
+ }
26
+ return parsed;
27
+ }
@@ -0,0 +1,26 @@
1
+ export declare const isRecord: (v: unknown) => v is Record<string, unknown>;
2
+ /** Recursively drop null/undefined values and the objects left empty by
3
+ * that. Axiom's legacy query format returns every field of the dataset's
4
+ * union schema, almost all null; this turns a row back into what was
5
+ * actually logged. Arrays are kept whole (elements pruned in place). */
6
+ export declare function pruneNulls(value: unknown): unknown;
7
+ /** `{ meta: { user: 'x' } }` -> `{ 'meta.user': 'x' }`. Arrays and Dates are leaves. */
8
+ export declare function flatten(value: Record<string, unknown>, prefix?: string): Record<string, unknown>;
9
+ /** A value as text: strings as they are, Dates as UTC ISO, anything else as JSON. */
10
+ export declare function scalarText(value: unknown, nullAs?: string): string;
11
+ /** One table cell: NULL spelled out, line breaks escaped so a row stays one line. */
12
+ export declare function cell(value: unknown): string;
13
+ /** Aligned text table: header, rule, rows. Columns are left-aligned except
14
+ * numeric ones, which are right-aligned like psql. */
15
+ export declare function formatTable(columns: string[], rows: unknown[][]): string;
16
+ /** formatTable over records, one column per name. */
17
+ export declare function formatRecords(columns: string[], records: Record<string, unknown>[]): string;
18
+ /** `k=v` pairs for the fields of a flattened record, values JSON-quoted when
19
+ * they contain whitespace, quotes, or `=`. Greppable one-line form. */
20
+ export declare function pairs(record: Record<string, unknown>): string;
21
+ /** `30s`, `15m`, `6h`, `3d`, `1w` -> milliseconds; null when not a duration. */
22
+ export declare function parseDuration(text: string): number | null;
23
+ /** A time flag is either a duration counted back from `now` (`6h`) or an
24
+ * absolute timestamp (`2026-09-03T21:00Z`, `2026-09-03`, parsed as UTC when
25
+ * no zone is given). */
26
+ export declare function parseTimeFlag(text: string, now: Date): Date;
@@ -0,0 +1,104 @@
1
+ // Output helpers shared by the production query tools (pw-sql, pw-logs):
2
+ // null pruning, key flattening, aligned tables, and the time-flag grammar.
3
+ export const isRecord = (v) => typeof v === 'object' && v !== null && !Array.isArray(v) && !(v instanceof Date);
4
+ /** Recursively drop null/undefined values and the objects left empty by
5
+ * that. Axiom's legacy query format returns every field of the dataset's
6
+ * union schema, almost all null; this turns a row back into what was
7
+ * actually logged. Arrays are kept whole (elements pruned in place). */
8
+ export function pruneNulls(value) {
9
+ if (Array.isArray(value))
10
+ return value.map(pruneNulls);
11
+ if (!isRecord(value))
12
+ return value;
13
+ const out = {};
14
+ for (const [key, raw] of Object.entries(value)) {
15
+ const v = pruneNulls(raw);
16
+ if (v === null || v === undefined)
17
+ continue;
18
+ if (isRecord(v) && Object.keys(v).length === 0)
19
+ continue;
20
+ out[key] = v;
21
+ }
22
+ return out;
23
+ }
24
+ /** `{ meta: { user: 'x' } }` -> `{ 'meta.user': 'x' }`. Arrays and Dates are leaves. */
25
+ export function flatten(value, prefix = '') {
26
+ const out = {};
27
+ for (const [key, v] of Object.entries(value)) {
28
+ const path = prefix ? `${prefix}.${key}` : key;
29
+ if (isRecord(v))
30
+ Object.assign(out, flatten(v, path));
31
+ else
32
+ out[path] = v;
33
+ }
34
+ return out;
35
+ }
36
+ /** A value as text: strings as they are, Dates as UTC ISO, anything else as JSON. */
37
+ export function scalarText(value, nullAs = '') {
38
+ if (value === null || value === undefined)
39
+ return nullAs;
40
+ if (value instanceof Date)
41
+ return isNaN(value.getTime()) ? 'Invalid Date' : value.toISOString();
42
+ if (typeof value === 'string')
43
+ return value;
44
+ return JSON.stringify(value);
45
+ }
46
+ /** One table cell: NULL spelled out, line breaks escaped so a row stays one line. */
47
+ export function cell(value) {
48
+ return scalarText(value, 'NULL').replace(/\r?\n/g, '\\n');
49
+ }
50
+ const NUMERIC = /^-?\d+(\.\d+)?$/;
51
+ /** Aligned text table: header, rule, rows. Columns are left-aligned except
52
+ * numeric ones, which are right-aligned like psql. */
53
+ export function formatTable(columns, rows) {
54
+ const widths = columns.map((c) => c.length);
55
+ const numeric = columns.map(() => rows.length > 0);
56
+ const text = rows.map((row) => columns.map((_, i) => {
57
+ const s = cell(row[i]);
58
+ widths[i] = Math.max(widths[i], s.length);
59
+ if (s !== 'NULL' && !NUMERIC.test(s))
60
+ numeric[i] = false;
61
+ return s;
62
+ }));
63
+ const line = (cells) => cells
64
+ .map((c, i) => (numeric[i] ? c.padStart(widths[i]) : c.padEnd(widths[i])))
65
+ .join(' ')
66
+ .trimEnd();
67
+ return [line(columns), widths.map((w) => '-'.repeat(w)).join(' '), ...text.map(line)].join('\n');
68
+ }
69
+ /** formatTable over records, one column per name. */
70
+ export function formatRecords(columns, records) {
71
+ return formatTable(columns, records.map((r) => columns.map((c) => r[c])));
72
+ }
73
+ /** `k=v` pairs for the fields of a flattened record, values JSON-quoted when
74
+ * they contain whitespace, quotes, or `=`. Greppable one-line form. */
75
+ export function pairs(record) {
76
+ return Object.entries(record)
77
+ .map(([k, v]) => {
78
+ const s = scalarText(v);
79
+ return `${k}=${/^[^\s"'=]+$/.test(s) ? s : JSON.stringify(s)}`;
80
+ })
81
+ .join(' ');
82
+ }
83
+ const DURATION = /^(\d+(?:\.\d+)?)\s*(s|m|h|d|w)$/;
84
+ const UNIT_MS = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000 };
85
+ /** `30s`, `15m`, `6h`, `3d`, `1w` -> milliseconds; null when not a duration. */
86
+ export function parseDuration(text) {
87
+ const m = DURATION.exec(text.trim());
88
+ return m ? Math.round(Number(m[1]) * UNIT_MS[m[2]]) : null;
89
+ }
90
+ /** A time flag is either a duration counted back from `now` (`6h`) or an
91
+ * absolute timestamp (`2026-09-03T21:00Z`, `2026-09-03`, parsed as UTC when
92
+ * no zone is given). */
93
+ export function parseTimeFlag(text, now) {
94
+ const ms = parseDuration(text);
95
+ if (ms !== null)
96
+ return new Date(now.getTime() - ms);
97
+ const t = text.trim();
98
+ const hasZone = /(Z|[+-]\d{2}:?\d{2})$/i.test(t);
99
+ const parsed = new Date(/^\d{4}-\d{2}-\d{2}$/.test(t) || hasZone ? t : `${t}Z`);
100
+ if (isNaN(parsed.getTime())) {
101
+ throw new Error(`not a duration (30m, 6h, 3d) or timestamp (2026-09-03T21:00Z): ${text}`);
102
+ }
103
+ return parsed;
104
+ }
@@ -0,0 +1,25 @@
1
+ import { GoogleAuth, OAuth2Client } from 'google-auth-library';
2
+ import type { Runner } from '../deploy/types.js';
3
+ export type GcpCredential = {
4
+ kind: 'adc';
5
+ path: string;
6
+ } | {
7
+ kind: 'key-file';
8
+ path: string;
9
+ account: string;
10
+ } | {
11
+ kind: 'access-token';
12
+ token: string;
13
+ account: string;
14
+ };
15
+ /** Test seams; every field defaults to the real thing. */
16
+ export interface CredentialDeps {
17
+ env?: NodeJS.ProcessEnv;
18
+ home?: string;
19
+ exists?: (path: string) => Promise<boolean>;
20
+ runner?: Runner;
21
+ }
22
+ export declare function resolveGcpCredential(deps?: CredentialDeps): Promise<GcpCredential>;
23
+ /** Short human description for the tool's progress line. */
24
+ export declare function describeCredential(cred: GcpCredential): string;
25
+ export declare function authClientFor(cred: GcpCredential): GoogleAuth | OAuth2Client;
@@ -0,0 +1,64 @@
1
+ // Google credentials for the agent's own tooling (not deployed code). The
2
+ // containers have no application-default credentials file, only gcloud's
3
+ // stored account keys, so the connector's default resolution fails there
4
+ // (after probing for a metadata server); this resolves, in order: an
5
+ // explicit or well-known ADC file, gcloud's stored key for the active
6
+ // account, and finally a gcloud-minted access token. The result is what the
7
+ // Cloud SQL connector accepts as `auth`.
8
+ import { homedir } from 'node:os';
9
+ import { join } from 'node:path';
10
+ import { GoogleAuth, OAuth2Client } from 'google-auth-library';
11
+ import { must, realRunner } from '../deploy/exec.js';
12
+ import { fileExists } from './app.js';
13
+ // Both halves of an IAM Cloud SQL connection: the admin API for the
14
+ // ephemeral certificate and the login token itself.
15
+ const CLOUD_SQL_SCOPES = [
16
+ 'https://www.googleapis.com/auth/sqlservice.admin',
17
+ 'https://www.googleapis.com/auth/sqlservice.login',
18
+ ];
19
+ export async function resolveGcpCredential(deps = {}) {
20
+ const env = deps.env ?? process.env;
21
+ const exists = deps.exists ?? fileExists;
22
+ const runner = deps.runner ?? realRunner;
23
+ const explicit = env.GOOGLE_APPLICATION_CREDENTIALS;
24
+ if (explicit) {
25
+ if (!(await exists(explicit))) {
26
+ throw new Error(`GOOGLE_APPLICATION_CREDENTIALS points at a missing file: ${explicit}`);
27
+ }
28
+ return { kind: 'adc', path: explicit };
29
+ }
30
+ const gcloudDir = env.CLOUDSDK_CONFIG ?? join(deps.home ?? homedir(), '.config', 'gcloud');
31
+ const adc = join(gcloudDir, 'application_default_credentials.json');
32
+ if (await exists(adc))
33
+ return { kind: 'adc', path: adc };
34
+ const account = (await runner('gcloud', ['config', 'get', 'account'])).stdout.trim();
35
+ if (!account) {
36
+ throw new Error('no Google credentials: no application-default credentials file and no active gcloud account');
37
+ }
38
+ const keyFile = join(gcloudDir, 'legacy_credentials', account, 'adc.json');
39
+ if (await exists(keyFile))
40
+ return { kind: 'key-file', path: keyFile, account };
41
+ const token = (await must(runner, 'gcloud', ['auth', 'print-access-token'])).stdout.trim();
42
+ if (!token)
43
+ throw new Error('gcloud auth print-access-token returned nothing');
44
+ return { kind: 'access-token', token, account };
45
+ }
46
+ /** Short human description for the tool's progress line. */
47
+ export function describeCredential(cred) {
48
+ switch (cred.kind) {
49
+ case 'adc':
50
+ return `application-default credentials (${cred.path})`;
51
+ case 'key-file':
52
+ return `gcloud stored key for ${cred.account}`;
53
+ case 'access-token':
54
+ return `gcloud access token for ${cred.account}`;
55
+ }
56
+ }
57
+ export function authClientFor(cred) {
58
+ if (cred.kind === 'access-token') {
59
+ const client = new OAuth2Client();
60
+ client.setCredentials({ access_token: cred.token });
61
+ return client;
62
+ }
63
+ return new GoogleAuth({ keyFilename: cred.path, scopes: CLOUD_SQL_SCOPES });
64
+ }
@@ -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.1",
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"