@mason-data/cli 0.6.2 → 0.8.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 +147 -69
- package/dist/src/api/error.js +3 -0
- package/dist/src/api/postgres/catalog.js +258 -14
- package/dist/src/api/postgres/encode.js +3 -1
- package/dist/src/api/postgres/explain.js +3 -2
- package/dist/src/api/postgres/protocol.js +39 -23
- package/dist/src/api/postgres/server.js +1290 -223
- package/dist/src/api/postgres/translate.js +81 -9
- package/dist/src/api/request-log.js +18 -5
- package/dist/src/cdc/pg-wire.js +144 -38
- package/dist/src/cdc/poll.js +5 -5
- package/dist/src/cli/background.js +55 -8
- package/dist/src/cli/cancel.js +75 -0
- package/dist/src/cli/command.js +5 -3
- package/dist/src/cli/commands.js +843 -272
- package/dist/src/cli/control.js +59 -0
- package/dist/src/cli/demo.js +158 -0
- package/dist/src/cli/dev.js +458 -125
- package/dist/src/cli/doctor.js +25 -12
- package/dist/src/cli/guides.js +103 -31
- package/dist/src/cli/init.js +159 -58
- package/dist/src/cli/machine.js +18 -12
- package/dist/src/cli/main.js +5 -5
- package/dist/src/cli/mcp.js +735 -0
- package/dist/src/cli/metrics.js +79 -0
- package/dist/src/cli/pg-tls.js +13 -3
- package/dist/src/cli/pg-url.js +4 -0
- package/dist/src/cli/pin.js +13 -12
- package/dist/src/cli/preflight.js +24 -14
- package/dist/src/cli/project.js +48 -7
- package/dist/src/cli/readiness.js +38 -4
- package/dist/src/cli/registry.js +57 -35
- package/dist/src/cli/replication-checks.js +7 -5
- package/dist/src/cli/replication-mode.js +105 -26
- package/dist/src/cli/request-filter.js +134 -0
- package/dist/src/cli/roles.js +584 -0
- package/dist/src/cli/run-server.js +498 -133
- package/dist/src/cli/runs.js +1 -1
- package/dist/src/cli/security.js +10 -8
- package/dist/src/cli/serve.js +147 -40
- package/dist/src/cli/share-run.js +286 -0
- package/dist/src/cli/share.js +286 -0
- package/dist/src/cli/source-edit.js +168 -0
- package/dist/src/cli/sources.js +290 -39
- package/dist/src/cli/supervise.js +12 -1
- package/dist/src/cli/sync.js +55 -25
- package/dist/src/cli/tunnel.js +200 -0
- package/dist/src/cli/ui.js +9 -6
- package/dist/src/cli/usage.js +128 -0
- package/dist/src/cli/user.js +496 -40
- package/dist/src/cli/version.js +9 -7
- package/dist/src/demo/seed.js +240 -0
- package/dist/src/demo/source.js +655 -0
- package/dist/src/duck/cancel.js +10 -1
- package/dist/src/duck/distinct-key.js +228 -0
- package/dist/src/duck/events.js +134 -0
- package/dist/src/duck/executor-worker.js +25 -11
- package/dist/src/duck/executor.js +107 -53
- package/dist/src/duck/explain.js +158 -29
- package/dist/src/duck/extensions.js +160 -0
- package/dist/src/duck/functions.js +73 -19
- package/dist/src/duck/gaps.js +25 -11
- package/dist/src/duck/grants.js +176 -0
- package/dist/src/duck/history.js +123 -0
- package/dist/src/duck/jsonb-order.js +175 -0
- package/dist/src/duck/jsonb.js +10 -8
- package/dist/src/duck/kept-route.js +82 -2
- package/dist/src/duck/kept.js +211 -44
- package/dist/src/duck/pgtree.js +40 -0
- package/dist/src/duck/plan.js +120 -30
- package/dist/src/duck/publication.js +6 -6
- package/dist/src/duck/requests.js +265 -93
- package/dist/src/duck/rewrite.js +1193 -73
- package/dist/src/duck/row-filter.js +649 -0
- package/dist/src/duck/store.js +271 -159
- package/dist/src/duck/stream.js +10 -5
- package/dist/src/duck/sync.js +907 -275
- package/dist/src/duck/types.js +6 -5
- package/dist/src/duck/usage.js +324 -0
- package/dist/src/duck/users.js +64 -15
- package/dist/src/duck/verified.js +9 -0
- package/dist/src/duck/views.js +2 -3
- package/dist/src/lib/database-name.js +62 -0
- package/dist/src/lib/dotenv.js +17 -4
- package/dist/src/lib/log-buffer.js +68 -0
- package/dist/src/lib/pg-numeric.js +5 -2
- package/dist/src/lib/project-version.js +23 -23
- package/dist/src/lib/table-sync.js +267 -0
- package/dist/src/lib/timezone.js +3 -2
- package/dist/src/web/app.css +706 -55
- package/dist/src/web/app.js +330 -258
- package/dist/src/web/charts.js +229 -0
- package/dist/src/web/index.html +84 -15
- package/dist/src/web/pages/queries.js +192 -0
- package/dist/src/web/pages/status.js +446 -0
- package/dist/src/web/pages/syncs.js +155 -0
- package/dist/src/web/pages/tables.js +434 -0
- package/dist/src/web/pages/usage.js +397 -0
- package/dist/src/web/pages/users.js +218 -0
- package/dist/src/web/shared.js +568 -0
- package/dist/src/web/vendor/README.md +25 -0
- package/dist/src/web/vendor/geist-1.7.2/Geist-Variable.woff2 +0 -0
- package/dist/src/web/vendor/geist-1.7.2/GeistMono-Variable.woff2 +0 -0
- package/dist/src/web/vendor/geist-1.7.2/LICENSE.txt +92 -0
- package/dist/src/web/vendor/lucide-1.56.0/LICENSE.txt +43 -0
- package/dist/src/web/vendor/lucide-1.56.0/chart-column.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/layout-dashboard.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/logs.mjs +34 -0
- package/dist/src/web/vendor/lucide-1.56.0/menu.mjs +28 -0
- package/dist/src/web/vendor/lucide-1.56.0/square-terminal.mjs +28 -0
- package/dist/src/web/vendor/lucide-1.56.0/table.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/users.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/x.mjs +27 -0
- package/dist/src/web/vendor/uplot-1.6.32/LICENSE.txt +21 -0
- package/dist/src/web/vendor/uplot-1.6.32/uPlot.esm.js +6140 -0
- package/dist/src/web/vendor/uplot-1.6.32/uPlot.min.css +1 -0
- package/dist/src/web/vendor/zilla-slab-5.3.0/LICENSE.txt +93 -0
- package/dist/src/web/vendor/zilla-slab-5.3.0/zilla-slab-latin-700-normal.woff2 +0 -0
- package/package.json +10 -3
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHAT A STATEMENT IS, from Postgres 18's own parse tree (parser.ts), decided by node kind, never by text:
|
|
3
|
+
*
|
|
4
|
+
* - a SELECT that reads any table outside pg_catalog / information_schema: DuckDB's (`duckdb`), planned by the
|
|
5
|
+
* catalog Postgres and rewritten (src/duck/plan.ts) — the text here is only what the plan is made from;
|
|
6
|
+
* - a CATALOG statement (one that reads only `pg_catalog` / `information_schema`, or no table at all: `SELECT
|
|
7
|
+
* version()`, `SELECT 1`): forwarded by the server to the Postgres inside the process (catalog.ts), which answers it
|
|
8
|
+
* as Postgres;
|
|
9
|
+
* - a SESSION statement (`SET`, `SHOW`, `BEGIN` / `COMMIT`, `DISCARD`, `DEALLOCATE`): the server's;
|
|
10
|
+
* - an EXPLAIN: the statement it explains, classified as it would be alone (src/api/postgres/explain.ts);
|
|
11
|
+
* - anything else: REFUSED BY NAME (`Unsupported`), with its position in the statement, a SQLSTATE and the way
|
|
12
|
+
* out, never passed through on a guess. The switch has no default branch that accepts.
|
|
13
|
+
*/
|
|
14
|
+
import { gapMark } from "../../duck/gaps.js";
|
|
1
15
|
/** A statement, or a part of one, this listener does not answer: named, placed, with a SQLSTATE and the way out. */
|
|
2
16
|
export class Unsupported extends Error {
|
|
3
17
|
/** 0-based offset into the statement's text, or null when the whole statement is meant */
|
|
@@ -5,11 +19,14 @@ export class Unsupported extends Error {
|
|
|
5
19
|
/** `0A000` feature not supported; `42601` syntax; `25006` read only; `22023` an invalid value */
|
|
6
20
|
sqlstate;
|
|
7
21
|
hint;
|
|
8
|
-
|
|
22
|
+
/** the registry entry that refused it (src/duck/gaps.ts): the request log's `gap`; null for any other refusal */
|
|
23
|
+
gap;
|
|
24
|
+
constructor(message, offset, sqlstate = "0A000", hint, gap = null) {
|
|
9
25
|
super(message);
|
|
10
26
|
this.offset = offset;
|
|
11
27
|
this.sqlstate = sqlstate;
|
|
12
28
|
this.hint = hint;
|
|
29
|
+
this.gap = gap;
|
|
13
30
|
}
|
|
14
31
|
}
|
|
15
32
|
const kindOf = (n) => Object.keys(n)[0] ?? "";
|
|
@@ -40,6 +57,14 @@ function collect(n, kind, out = []) {
|
|
|
40
57
|
}
|
|
41
58
|
const INFORMATION_SCHEMA = "information_schema";
|
|
42
59
|
const PG_CATALOG = "pg_catalog";
|
|
60
|
+
/** Postgres's IsTransactionExitStmt: what a failed block still accepts. `ROLLBACK TO` and `PREPARE TRANSACTION` are among
|
|
61
|
+
* them, each then refused here for being what it is, a savepoint or a prepared transaction, never for the block. */
|
|
62
|
+
export function endsFailedBlock(stmt) {
|
|
63
|
+
if (kindOf(stmt) !== "TransactionStmt")
|
|
64
|
+
return false;
|
|
65
|
+
const k = bodyOf(stmt).kind;
|
|
66
|
+
return k === "TRANS_STMT_COMMIT" || k === "TRANS_STMT_ROLLBACK" || k === "TRANS_STMT_ROLLBACK_TO" || k === "TRANS_STMT_PREPARE";
|
|
67
|
+
}
|
|
43
68
|
// ------------------------------------------------------------------ entry
|
|
44
69
|
/** A statement other than a SELECT or an EXPLAIN (`duckTranslate` takes those): the session's, or refused by name. */
|
|
45
70
|
function sessionStatement(stmt) {
|
|
@@ -51,10 +76,10 @@ function sessionStatement(stmt) {
|
|
|
51
76
|
const k = bodyOf(stmt).kind;
|
|
52
77
|
const tag = k === "TRANS_STMT_BEGIN" ? "BEGIN" : k === "TRANS_STMT_START" ? "START TRANSACTION" : k === "TRANS_STMT_COMMIT" ? "COMMIT" : k === "TRANS_STMT_ROLLBACK" ? "ROLLBACK" : null;
|
|
53
78
|
if (tag === null)
|
|
54
|
-
throw new Unsupported(`${String(k).replace("TRANS_STMT_", "").toLowerCase()}: this connection is read-only and has no transactions; BEGIN, COMMIT and ROLLBACK are accepted and
|
|
79
|
+
throw new Unsupported(`${String(k).replace("TRANS_STMT_", "").toLowerCase()}: this connection is read-only and has no savepoints or prepared transactions; BEGIN, COMMIT and ROLLBACK are accepted, and a ROLLBACK undoes the block's SETs`, null);
|
|
55
80
|
return { kind: "transaction", tag };
|
|
56
81
|
}
|
|
57
|
-
case "DiscardStmt": return { kind: "discard" };
|
|
82
|
+
case "DiscardStmt": return { kind: "discard", target: String(bodyOf(stmt).target ?? "DISCARD_ALL").replace(/^DISCARD_/, "") };
|
|
58
83
|
case "DeallocateStmt": return { kind: "deallocate" };
|
|
59
84
|
case "InsertStmt":
|
|
60
85
|
case "UpdateStmt":
|
|
@@ -73,7 +98,7 @@ function sessionStatement(stmt) {
|
|
|
73
98
|
case "CreateFunctionStmt":
|
|
74
99
|
case "CreateSchemaStmt":
|
|
75
100
|
case "VacuumStmt":
|
|
76
|
-
throw new Unsupported(`${kind.replace(/Stmt$/, "").replace(/([a-z])([A-Z])/g, "$1 $2").toUpperCase()}:
|
|
101
|
+
throw new Unsupported(`${kind.replace(/Stmt$/, "").replace(/([a-z])([A-Z])/g, "$1 $2").toUpperCase()}: these are the database's tables, synced and read-only; only SELECT, SET, SHOW and the transaction no-ops are accepted`, null, "25006");
|
|
77
102
|
case "PrepareStmt":
|
|
78
103
|
case "ExecuteStmt": throw new Unsupported(`${kind === "PrepareStmt" ? "PREPARE" : "EXECUTE"} is not supported: use the protocol's prepared statements (every driver does)`, null);
|
|
79
104
|
default:
|
|
@@ -90,6 +115,9 @@ function sessionStatement(stmt) {
|
|
|
90
115
|
* (a review finding).
|
|
91
116
|
*/
|
|
92
117
|
export function duckTranslate(stmt, text) {
|
|
118
|
+
const configured = setConfig(stmt);
|
|
119
|
+
if (configured !== null)
|
|
120
|
+
return configured;
|
|
93
121
|
const kind = kindOf(stmt);
|
|
94
122
|
// an EXPLAIN is the statement it explains (src/api/postgres/explain.ts, src/duck/explain.ts): its options read, then
|
|
95
123
|
// that statement classified as it would be alone, by the server
|
|
@@ -109,6 +137,47 @@ export function duckTranslate(stmt, text) {
|
|
|
109
137
|
return { kind: "catalog" };
|
|
110
138
|
return { kind: "duckdb", sql: text, names: pgColumnNames(s) };
|
|
111
139
|
}
|
|
140
|
+
/**
|
|
141
|
+
* `set_config(name, value, is_local)` IS A SET, and a session's SET is the listener's: forwarded, it ran on the catalog's
|
|
142
|
+
* ONE connection, as its superuser, outside any transaction, so its value stayed for EVERY session — `set_config(
|
|
143
|
+
* 'search_path', 'zzz', false)` moved the catalog's resolution of `current_setting` and `version()` to Postgres's own,
|
|
144
|
+
* and a `myapp.x` one session set another read (a review finding of #46). So every call of it is the session's or
|
|
145
|
+
* refused, wherever it is in the statement (an EXPLAIN ANALYZE's included): alone in a SELECT of no table, its
|
|
146
|
+
* arguments literals (what a tool sends: pgAdmin's `bytea_output`, an ORM's `search_path`), it is `SET name = value`,
|
|
147
|
+
* or `SET LOCAL` under `is_local`, answered as the function's row; any other form is the `set-config-forms` gap. null:
|
|
148
|
+
* no call of it.
|
|
149
|
+
*/
|
|
150
|
+
function setConfig(stmt) {
|
|
151
|
+
const named = (n) => {
|
|
152
|
+
const parts = strings(bodyOf(n).funcname);
|
|
153
|
+
return parts[parts.length - 1] === "set_config" && (parts.length === 1 || (parts.length === 2 && parts[0] === PG_CATALOG));
|
|
154
|
+
};
|
|
155
|
+
const calls = collect(stmt, "FuncCall").filter(named);
|
|
156
|
+
if (calls.length === 0)
|
|
157
|
+
return null;
|
|
158
|
+
const refuse = (at) => {
|
|
159
|
+
throw new Unsupported(`set_config() is answered by Mason only alone in a SELECT, with literal arguments (${gapMark("set-config-forms")})`, at, "0A000", "write SET name = 'value', or SELECT set_config('name', 'value', false) on its own", "set-config-forms");
|
|
160
|
+
};
|
|
161
|
+
const at = loc(calls[0]);
|
|
162
|
+
if (kindOf(stmt) !== "SelectStmt")
|
|
163
|
+
return refuse(at);
|
|
164
|
+
const s = bodyOf(stmt);
|
|
165
|
+
if (Object.keys(s).some((k) => k !== "targetList" && k !== "limitOption" && k !== "op") || s.op !== "SETOP_NONE")
|
|
166
|
+
return refuse(at);
|
|
167
|
+
const targets = (s.targetList ?? []);
|
|
168
|
+
const target = targets.length === 1 ? bodyOf(targets[0]) : null;
|
|
169
|
+
if (target?.val === undefined || kindOf(target.val) !== "FuncCall" || !named(target.val))
|
|
170
|
+
return refuse(at);
|
|
171
|
+
const call = bodyOf(target.val);
|
|
172
|
+
if (Object.keys(call).some((k) => k !== "funcname" && k !== "args" && k !== "funcformat" && k !== "location"))
|
|
173
|
+
return refuse(at);
|
|
174
|
+
const args = (call.args ?? []).map((a) => kindOf(a) === "A_Const" ? bodyOf(a) : null);
|
|
175
|
+
const text = (c) => typeof c?.sval?.sval === "string" ? c.sval.sval : null;
|
|
176
|
+
const [name, value, local] = [text(args[0] ?? null), text(args[1] ?? null), args[2]?.boolval];
|
|
177
|
+
if (args.length !== 3 || name === null || value === null || local === undefined)
|
|
178
|
+
return refuse(at);
|
|
179
|
+
return { kind: "set_config", name: name.toLowerCase(), value, local: local.boolval === true, column: target.name ?? "set_config" };
|
|
180
|
+
}
|
|
112
181
|
/**
|
|
113
182
|
* THE NAMES POSTGRES GIVES A SELECT'S RESULT COLUMNS (`FigureColname`, src/backend/parser/parse_target.c), where DuckDB
|
|
114
183
|
* gives others (`count(*)` is `count_star()`, `sum(x)` is `sum(x)`; Postgres says `count`, `sum`): an alias as written,
|
|
@@ -212,12 +281,15 @@ function figureColname(n) {
|
|
|
212
281
|
}
|
|
213
282
|
function setStatement(s) {
|
|
214
283
|
const name = String(s.name ?? "").toLowerCase();
|
|
215
|
-
if (s.kind === "VAR_RESET"
|
|
216
|
-
return { kind: "set", name, value: null, local:
|
|
284
|
+
if (s.kind === "VAR_RESET")
|
|
285
|
+
return { kind: "set", name, value: null, local: false, tag: "RESET" };
|
|
286
|
+
if (s.kind === "VAR_SET_DEFAULT")
|
|
287
|
+
return { kind: "set", name, value: null, local: s.is_local === true, tag: "SET" };
|
|
217
288
|
if (s.kind === "VAR_RESET_ALL")
|
|
218
|
-
return { kind: "set", name: "all", value: null, local: false };
|
|
289
|
+
return { kind: "set", name: "all", value: null, local: false, tag: "RESET" };
|
|
290
|
+
// SET TRANSACTION is for the transaction alone, whichever way it is spelt; SET SESSION CHARACTERISTICS is the session's
|
|
219
291
|
if (s.kind === "VAR_SET_MULTI")
|
|
220
|
-
return { kind: "set", name, value: "", local:
|
|
292
|
+
return { kind: "set", name, value: "", local: name === "transaction", tag: "SET" };
|
|
221
293
|
if (s.kind !== "VAR_SET_VALUE")
|
|
222
294
|
throw new Unsupported(`SET ${name}: ${String(s.kind)} is not supported`, s.location ?? null);
|
|
223
295
|
const parts = (s.args ?? []).map((a) => {
|
|
@@ -230,7 +302,7 @@ function setStatement(s) {
|
|
|
230
302
|
}
|
|
231
303
|
throw new Unsupported(`SET ${name}: a value of kind ${kindOf(a)} is not supported (a literal is)`, loc(a));
|
|
232
304
|
});
|
|
233
|
-
return { kind: "set", name, value: parts.join(", "), local: s.is_local === true };
|
|
305
|
+
return { kind: "set", name, value: parts.join(", "), local: s.is_local === true, tag: "SET" };
|
|
234
306
|
}
|
|
235
307
|
function constText(c) {
|
|
236
308
|
if (c.sval !== undefined)
|
|
@@ -8,17 +8,30 @@
|
|
|
8
8
|
import * as crypto from "node:crypto";
|
|
9
9
|
/** Fail closed on the storage side: never let one request bloat the ledger. */
|
|
10
10
|
export const MAX_STORED_PAYLOAD_BYTES = 64 * 1024;
|
|
11
|
+
/** The SQLSTATE of a cancelled statement: a CancelRequest, `mason cancel`, or a statement_timeout (Postgres's 57014). */
|
|
12
|
+
export const CANCELLED_SQLSTATE = "57014";
|
|
13
|
+
/** The SQLSTATE classes whose fault is Mason's or the machine's: XX internal, 53 resources, 57 an operator's, 58 system. */
|
|
14
|
+
export const FAILED_CLASSES = ["XX", "53", "57", "58"];
|
|
15
|
+
export const OUTCOMES = ["ok", "refused", "failed", "cancelled"];
|
|
11
16
|
/**
|
|
12
|
-
* A Postgres-listener statement's outcome in a word, from the SQLSTATE it was sent: `
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* (
|
|
17
|
+
* A Postgres-listener statement's outcome in a word, from the SQLSTATE it was sent: `cancelled` for 57014 (a cancel or a
|
|
18
|
+
* timeout), `failed` where the fault is Mason's or the machine's (FAILED_CLASSES), `refused` for the statement's own,
|
|
19
|
+
* `ok` for none. Read by the listener's refusal line (server.ts `refusalLine`) and the request log (src/duck/requests.ts)
|
|
20
|
+
* alike; the ledger's filter and summary read the SAME rule as SQL (`outcomeSql`), held equal by tests/duck-requests.test.ts.
|
|
16
21
|
*/
|
|
17
22
|
export function outcomeOf(sqlstate) {
|
|
18
23
|
if (sqlstate === null || sqlstate === undefined || sqlstate === "")
|
|
19
24
|
return "ok";
|
|
20
|
-
|
|
25
|
+
if (sqlstate === CANCELLED_SQLSTATE)
|
|
26
|
+
return "cancelled";
|
|
27
|
+
return FAILED_CLASSES.some((c) => sqlstate.startsWith(c)) ? "failed" : "refused";
|
|
21
28
|
}
|
|
29
|
+
/** `outcomeOf` as a SQL expression over a SQLSTATE column: the one rule, for the ledger's filters and summary. */
|
|
30
|
+
export function outcomeSql(column) {
|
|
31
|
+
return `CASE WHEN ${column} IS NULL OR ${column} = '' THEN 'ok' WHEN ${column} = '${CANCELLED_SQLSTATE}' THEN 'cancelled' ` +
|
|
32
|
+
`WHEN left(${column}, 2) IN (${FAILED_CLASSES.map((c) => `'${c}'`).join(", ")}) THEN 'failed' ELSE 'refused' END`;
|
|
33
|
+
}
|
|
34
|
+
export const statusOf = (o) => o === "ok" ? "completed" : o === "cancelled" ? "cancelled" : "failed";
|
|
22
35
|
/**
|
|
23
36
|
* The caller's identity WITHOUT the credential: a truncated SHA-256 of what was presented. Distinct callers stay
|
|
24
37
|
* distinguishable and repeat callers stay correlatable, while the secret itself is never written down — the log must
|
package/dist/src/cdc/pg-wire.js
CHANGED
|
@@ -18,10 +18,13 @@ import * as tls from "node:tls";
|
|
|
18
18
|
export class PgError extends Error {
|
|
19
19
|
code;
|
|
20
20
|
detail;
|
|
21
|
+
/** the ErrorResponse's own fields, by their one-letter codes (M the message alone, H the hint, P the position) */
|
|
22
|
+
fields;
|
|
21
23
|
constructor(fields) {
|
|
22
24
|
super(`${fields.S ?? "ERROR"}: ${fields.M ?? "unknown error"}${fields.D ? ` (${fields.D})` : ""}`);
|
|
23
25
|
this.code = fields.C ?? "";
|
|
24
26
|
this.detail = fields.D;
|
|
27
|
+
this.fields = fields;
|
|
25
28
|
}
|
|
26
29
|
}
|
|
27
30
|
/** Microseconds between the Unix epoch and Postgres's (2000-01-01). */
|
|
@@ -72,45 +75,39 @@ export class PgConnection {
|
|
|
72
75
|
}
|
|
73
76
|
/** TCP connect, the SSL negotiation and the startup exchange; `hold` is told the live socket, for a timeout to destroy. */
|
|
74
77
|
async handshake(hold) {
|
|
75
|
-
const
|
|
76
|
-
const mode = o.sslmode ?? "prefer";
|
|
77
|
-
const raw = await new Promise((resolve, reject) => {
|
|
78
|
-
const s = net.connect({ host: o.host, port: o.port });
|
|
79
|
-
hold(s);
|
|
80
|
-
s.once("connect", () => resolve(s));
|
|
81
|
-
s.once("error", (e) => reject(new Error(`connecting to ${o.host}:${o.port}: ${socketErrorText(e)}`)));
|
|
82
|
-
});
|
|
83
|
-
let sock = raw;
|
|
84
|
-
if (mode !== "disable" && mode !== "allow") {
|
|
85
|
-
// SSLRequest: length 8, code 80877103; the server answers one byte, S or N
|
|
86
|
-
raw.write(Buffer.from([0, 0, 0, 8, 0x04, 0xd2, 0x16, 0x2f]));
|
|
87
|
-
const answer = await new Promise((resolve, reject) => {
|
|
88
|
-
raw.once("data", (d) => resolve(String.fromCharCode(d[0])));
|
|
89
|
-
raw.once("error", reject);
|
|
90
|
-
});
|
|
91
|
-
if (answer === "S") {
|
|
92
|
-
const verify = mode === "verify-ca" || mode === "verify-full";
|
|
93
|
-
sock = await new Promise((resolve, reject) => {
|
|
94
|
-
const t = tls.connect({
|
|
95
|
-
socket: raw, rejectUnauthorized: verify,
|
|
96
|
-
...(mode === "verify-full" ? { servername: o.host } : { checkServerIdentity: () => undefined }),
|
|
97
|
-
});
|
|
98
|
-
hold(t);
|
|
99
|
-
t.once("secureConnect", () => resolve(t));
|
|
100
|
-
t.once("error", reject);
|
|
101
|
-
});
|
|
102
|
-
}
|
|
103
|
-
else if (mode !== "prefer") {
|
|
104
|
-
raw.destroy();
|
|
105
|
-
throw new Error(`sslmode=${mode}, but ${o.host} does not accept TLS`);
|
|
106
|
-
}
|
|
107
|
-
}
|
|
78
|
+
const sock = await transport(this.opts, hold);
|
|
108
79
|
this.socket = sock;
|
|
109
80
|
sock.on("data", (d) => this.onData(d));
|
|
110
81
|
sock.on("error", (e) => this.fail(e));
|
|
111
82
|
sock.on("close", () => this.fail(new Error("connection closed by the server")));
|
|
112
83
|
await this.startup();
|
|
113
84
|
}
|
|
85
|
+
/** the server's key for this session (BackendKeyData), for a CancelRequest */
|
|
86
|
+
key = null;
|
|
87
|
+
/**
|
|
88
|
+
* ASK THE SERVER TO CANCEL THIS SESSION'S RUNNING STATEMENT (Postgres's CancelRequest, on a connection of its own,
|
|
89
|
+
* TLS as this one's sslmode): the statement ends with 57014 on this connection, which stays usable. Resolves once
|
|
90
|
+
* the request is written.
|
|
91
|
+
*/
|
|
92
|
+
async cancel() {
|
|
93
|
+
const key = this.key;
|
|
94
|
+
if (key === null)
|
|
95
|
+
throw new Error("the server sent no key for this session: it cannot be cancelled");
|
|
96
|
+
let held = null;
|
|
97
|
+
const timer = setTimeout(() => { held?.destroy(); }, this.opts.connectTimeoutMs ?? 15_000);
|
|
98
|
+
try {
|
|
99
|
+
const s = await transport(this.opts, (x) => { held = x; });
|
|
100
|
+
const b = Buffer.alloc(16);
|
|
101
|
+
b.writeInt32BE(16, 0);
|
|
102
|
+
b.writeInt32BE(80877102, 4);
|
|
103
|
+
b.writeInt32BE(key.pid, 8);
|
|
104
|
+
b.writeInt32BE(key.secret, 12);
|
|
105
|
+
await new Promise((resolve, reject) => { s.once("error", reject); s.end(b, () => resolve()); });
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
clearTimeout(timer);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
114
111
|
fail(e) {
|
|
115
112
|
if (this.closedWith)
|
|
116
113
|
return;
|
|
@@ -167,6 +164,16 @@ export class PgConnection {
|
|
|
167
164
|
if (k !== undefined)
|
|
168
165
|
this.parameters[k] = v ?? "";
|
|
169
166
|
}
|
|
167
|
+
/** the next message of a statement's reply; when the server closed the connection after an ErrorResponse it sent
|
|
168
|
+
* (a FATAL: a user removed, a shutdown) and before ReadyForQuery, that error — never the bare "connection closed" */
|
|
169
|
+
async reply(error) {
|
|
170
|
+
try {
|
|
171
|
+
return await this.next();
|
|
172
|
+
}
|
|
173
|
+
catch (e) {
|
|
174
|
+
throw error ?? e;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
170
177
|
next() {
|
|
171
178
|
const m = this.queue.shift();
|
|
172
179
|
if (m) {
|
|
@@ -195,7 +202,7 @@ export class PgConnection {
|
|
|
195
202
|
const params = [
|
|
196
203
|
["user", o.user], ["database", o.database], ["client_encoding", "UTF8"],
|
|
197
204
|
// Text values in the forms the reader parses: ISO dates in UTC, floats round-trippable
|
|
198
|
-
["DateStyle", "ISO, YMD"], ["TimeZone", "UTC"], ["extra_float_digits", "3"], ["IntervalStyle", "iso_8601"],
|
|
205
|
+
...(o.textForms ?? [["DateStyle", "ISO, YMD"], ["TimeZone", "UTC"], ["extra_float_digits", "3"], ["IntervalStyle", "iso_8601"]]).map(([k, v]) => [k, v]),
|
|
199
206
|
["application_name", o.applicationName ?? "mason"],
|
|
200
207
|
];
|
|
201
208
|
if (o.replication)
|
|
@@ -216,8 +223,10 @@ export class PgConnection {
|
|
|
216
223
|
await this.auth(m.body);
|
|
217
224
|
continue;
|
|
218
225
|
}
|
|
219
|
-
if (m.type === "K")
|
|
220
|
-
|
|
226
|
+
if (m.type === "K") {
|
|
227
|
+
this.key = { pid: m.body.readInt32BE(0), secret: m.body.readInt32BE(4) };
|
|
228
|
+
continue;
|
|
229
|
+
} // BackendKeyData
|
|
221
230
|
if (m.type === "Z")
|
|
222
231
|
return;
|
|
223
232
|
throw new Error(`unexpected message ${m.type} during startup`);
|
|
@@ -288,7 +297,7 @@ export class PgConnection {
|
|
|
288
297
|
let result = { fields: [], rows: [], command: "" };
|
|
289
298
|
let error = null;
|
|
290
299
|
for (;;) {
|
|
291
|
-
const m = await this.
|
|
300
|
+
const m = await this.reply(error);
|
|
292
301
|
switch (m.type) {
|
|
293
302
|
case "T":
|
|
294
303
|
result = { fields: rowDescription(m.body), rows: [], command: "" };
|
|
@@ -321,7 +330,7 @@ export class PgConnection {
|
|
|
321
330
|
this.send("Q", cstr(sql));
|
|
322
331
|
let error = null;
|
|
323
332
|
for (;;) {
|
|
324
|
-
const m = await this.
|
|
333
|
+
const m = await this.reply(error);
|
|
325
334
|
switch (m.type) {
|
|
326
335
|
case "D":
|
|
327
336
|
if (error === null)
|
|
@@ -338,6 +347,52 @@ export class PgConnection {
|
|
|
338
347
|
}
|
|
339
348
|
}
|
|
340
349
|
}
|
|
350
|
+
/**
|
|
351
|
+
* ONE STATEMENT THROUGH THE EXTENDED PROTOCOL (`mason mcp`'s query): Parse, Bind, Describe and Execute of the unnamed
|
|
352
|
+
* portal, then Sync — so the server takes exactly one statement (a second is Postgres's 42601), and `maxRows` is the
|
|
353
|
+
* Execute's row count: the server stops at it and says so (PortalSuspended), so a query an agent leaves unbounded is
|
|
354
|
+
* never sent whole. 0 is every row. Every value is text.
|
|
355
|
+
*/
|
|
356
|
+
async extended(sql, maxRows) {
|
|
357
|
+
const int16 = (n) => { const b = Buffer.alloc(2); b.writeInt16BE(n); return b; };
|
|
358
|
+
this.send("P", Buffer.concat([cstr(""), cstr(sql), int16(0)]));
|
|
359
|
+
// no parameters, every result column in text
|
|
360
|
+
this.send("B", Buffer.concat([cstr(""), cstr(""), int16(0), int16(0), int16(0)]));
|
|
361
|
+
this.send("D", Buffer.concat([Buffer.from("P"), cstr("")]));
|
|
362
|
+
this.send("E", Buffer.concat([cstr(""), int32(maxRows)]));
|
|
363
|
+
this.send("S", Buffer.alloc(0));
|
|
364
|
+
const result = { fields: [], rows: [], suspended: false, command: "", empty: false };
|
|
365
|
+
let error = null;
|
|
366
|
+
for (;;) {
|
|
367
|
+
const m = await this.reply(error);
|
|
368
|
+
switch (m.type) {
|
|
369
|
+
case "T":
|
|
370
|
+
result.fields = rowFields(m.body);
|
|
371
|
+
break;
|
|
372
|
+
case "D":
|
|
373
|
+
if (error === null)
|
|
374
|
+
result.rows.push(dataRow(m.body));
|
|
375
|
+
break;
|
|
376
|
+
case "s":
|
|
377
|
+
result.suspended = true;
|
|
378
|
+
break;
|
|
379
|
+
case "C":
|
|
380
|
+
result.command = cstrings(m.body)[0] ?? "";
|
|
381
|
+
break;
|
|
382
|
+
case "E":
|
|
383
|
+
error = new PgError(errorFields(m.body));
|
|
384
|
+
break;
|
|
385
|
+
case "I":
|
|
386
|
+
result.empty = true;
|
|
387
|
+
break;
|
|
388
|
+
case "Z":
|
|
389
|
+
if (error)
|
|
390
|
+
throw error;
|
|
391
|
+
return result;
|
|
392
|
+
default: break; // ParseComplete, BindComplete, NoData
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
341
396
|
// ------------------------------------------------------------------ replication
|
|
342
397
|
/** START_REPLICATION, then an async stream of XLogData and keepalives until the connection ends. */
|
|
343
398
|
async *replicate(command) {
|
|
@@ -392,6 +447,42 @@ export class PgConnection {
|
|
|
392
447
|
}
|
|
393
448
|
}
|
|
394
449
|
// ------------------------------------------------------------------ helpers
|
|
450
|
+
/** TCP connect and the SSL negotiation (libpq's sslmode): the socket a startup, or a CancelRequest, is written to. */
|
|
451
|
+
async function transport(o, hold) {
|
|
452
|
+
const mode = o.sslmode ?? "prefer";
|
|
453
|
+
const raw = await new Promise((resolve, reject) => {
|
|
454
|
+
const s = net.connect({ host: o.host, port: o.port });
|
|
455
|
+
hold(s);
|
|
456
|
+
s.once("connect", () => resolve(s));
|
|
457
|
+
s.once("error", (e) => reject(new Error(`connecting to ${o.host}:${o.port}: ${socketErrorText(e)}`)));
|
|
458
|
+
});
|
|
459
|
+
let sock = raw;
|
|
460
|
+
if (mode !== "disable" && mode !== "allow") {
|
|
461
|
+
// SSLRequest: length 8, code 80877103; the server answers one byte, S or N
|
|
462
|
+
raw.write(Buffer.from([0, 0, 0, 8, 0x04, 0xd2, 0x16, 0x2f]));
|
|
463
|
+
const answer = await new Promise((resolve, reject) => {
|
|
464
|
+
raw.once("data", (d) => resolve(String.fromCharCode(d[0])));
|
|
465
|
+
raw.once("error", reject);
|
|
466
|
+
});
|
|
467
|
+
if (answer === "S") {
|
|
468
|
+
const verify = mode === "verify-ca" || mode === "verify-full";
|
|
469
|
+
sock = await new Promise((resolve, reject) => {
|
|
470
|
+
const t = tls.connect({
|
|
471
|
+
socket: raw, rejectUnauthorized: verify,
|
|
472
|
+
...(mode === "verify-full" ? { servername: o.host } : { checkServerIdentity: () => undefined }),
|
|
473
|
+
});
|
|
474
|
+
hold(t);
|
|
475
|
+
t.once("secureConnect", () => resolve(t));
|
|
476
|
+
t.once("error", reject);
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
else if (mode !== "prefer") {
|
|
480
|
+
raw.destroy();
|
|
481
|
+
throw new Error(`sslmode=${mode}, but ${o.host} does not accept TLS`);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
return sock;
|
|
485
|
+
}
|
|
395
486
|
/** `16/B374D848` -> its 64-bit position; throws on anything else. */
|
|
396
487
|
export function parseLsn(text) {
|
|
397
488
|
const m = /^([0-9A-Fa-f]{1,8})\/([0-9A-Fa-f]{1,8})$/.exec(text.trim());
|
|
@@ -452,6 +543,21 @@ function rowDescription(b) {
|
|
|
452
543
|
}
|
|
453
544
|
return names;
|
|
454
545
|
}
|
|
546
|
+
/** A RowDescription's columns with their types (rowDescription keeps the names alone). */
|
|
547
|
+
function rowFields(b) {
|
|
548
|
+
const n = b.readInt16BE(0);
|
|
549
|
+
const out = [];
|
|
550
|
+
let i = 2;
|
|
551
|
+
for (let k = 0; k < n; k++) {
|
|
552
|
+
const end = b.indexOf(0, i);
|
|
553
|
+
const name = b.toString("utf8", i, end);
|
|
554
|
+
i = end + 1;
|
|
555
|
+
// table oid (4), column number (2), type oid (4), type size (2), type modifier (4), format (2)
|
|
556
|
+
out.push({ name, typeOid: b.readUInt32BE(i + 6), typmod: b.readInt32BE(i + 12) });
|
|
557
|
+
i += 18;
|
|
558
|
+
}
|
|
559
|
+
return out;
|
|
560
|
+
}
|
|
455
561
|
/** onData pauses the socket above this many queued bytes, and next() resumes it below a quarter of it. */
|
|
456
562
|
const QUEUE_PAUSE_BYTES = 16 * 1024 * 1024;
|
|
457
563
|
function dataRow(b) {
|
package/dist/src/cdc/poll.js
CHANGED
|
@@ -56,11 +56,11 @@ export function xminBeforeHolds(current, watermark) {
|
|
|
56
56
|
return current >= watermark && current - watermark < 2n ** 30n;
|
|
57
57
|
}
|
|
58
58
|
/**
|
|
59
|
-
* The wait before the next poll:
|
|
60
|
-
*
|
|
61
|
-
* (the test server: a cap of a minute polled a 22 M-row production table every 74 s for 6.5
|
|
62
|
-
*
|
|
63
|
-
* and a slow poll's freshness is said by its own timing line.
|
|
59
|
+
* The wait before the next poll: the table's interval (its sync:, src/lib/table-sync.ts), or four times what the last
|
|
60
|
+
* poll took when that is longer, so a table that takes a while to read costs at most a fifth of the source's time.
|
|
61
|
+
* Never sooner than either (the test server: a cap of a minute polled a 22 M-row production table every 74 s for 6.5
|
|
62
|
+
* hours under an hourly interval, and a poll of 20 s every 60 s is a third of the source's time): the database's load
|
|
63
|
+
* decides, and a slow poll's freshness is said by its own timing line.
|
|
64
64
|
*/
|
|
65
65
|
export function nextPollDelayMs(pollMs, lastPollMs) {
|
|
66
66
|
return Math.max(pollMs, 4 * lastPollMs);
|
|
@@ -19,10 +19,15 @@ import { fileURLToPath } from "node:url";
|
|
|
19
19
|
import { runnable } from "../lib/runnable.js";
|
|
20
20
|
import { READY, STEP_MARK } from "./dev.js";
|
|
21
21
|
import { liveRun } from "./runs.js";
|
|
22
|
-
import { theProject } from "./project.js";
|
|
22
|
+
import { projectSource, startSettingRefusal, theProject } from "./project.js";
|
|
23
23
|
import { runListenerSettings } from "./pg-tls.js";
|
|
24
|
+
import { demoChangesRefusal, demoPort } from "./demo.js";
|
|
25
|
+
import { recordRefusal } from "./replication-mode.js";
|
|
26
|
+
import { shownPath } from "./sources.js";
|
|
24
27
|
import { endText, failed, jsonText, nextBlock } from "./command.js";
|
|
28
|
+
import { historyDaysSetting } from "../duck/history.js";
|
|
25
29
|
import { DEV } from "./registry.js";
|
|
30
|
+
import { UI } from "./ui.js";
|
|
26
31
|
import { out as styleOut, stepRenderer } from "./style.js";
|
|
27
32
|
import { postgresLink, withoutPlaceholders } from "./pg-url.js";
|
|
28
33
|
const say = (m) => { process.stdout.write(`${m}\n`); };
|
|
@@ -31,13 +36,27 @@ const SUPERVISE = path.join(path.dirname(fileURLToPath(import.meta.url)), "super
|
|
|
31
36
|
/** The commands to run next, as `mason start` prints them once the run is up: one a line, what each does beside it
|
|
32
37
|
* (not `mason status | mason logs | mason stop` on one line). */
|
|
33
38
|
export const NEXT_STEPS = [
|
|
39
|
+
// the web page first: a first-time user's most useful next step (the run serves it; mason ui opens it, signed in)
|
|
40
|
+
["mason ui", UI.summary],
|
|
34
41
|
["mason tables", "the database's tables, and what you can add"],
|
|
35
|
-
["mason add <table>", "
|
|
42
|
+
["mason add <table>", "sync a table into Mason"],
|
|
36
43
|
["mason user add <name>", "a user for a BI tool or your app: its password and URL, printed once"],
|
|
37
44
|
["mason status", "what Mason is doing"],
|
|
38
|
-
["mason logs", "its log: every
|
|
45
|
+
["mason logs", "its log: every sync, poll and statement"],
|
|
39
46
|
["mason stop", "stop Mason; the next mason start resumes where it stopped"],
|
|
40
47
|
];
|
|
48
|
+
/** The ports a demo project's database takes (its URL's port, read as the run reads it), for the API's choice to avoid;
|
|
49
|
+
* none for any other project. A sources file or a URL that cannot be read gives none HERE: the run refuses it in its
|
|
50
|
+
* own words (its source step, its demo step), shown by this start as every other refusal of the run's is. */
|
|
51
|
+
function demoPortsOf(project) {
|
|
52
|
+
if (project.config.demo !== true)
|
|
53
|
+
return new Set();
|
|
54
|
+
const src = projectSource(project, process.env);
|
|
55
|
+
if ("message" in src)
|
|
56
|
+
return new Set();
|
|
57
|
+
const at = demoPort(src.resolved, src.from);
|
|
58
|
+
return new Set("port" in at ? [at.port] : []);
|
|
59
|
+
}
|
|
41
60
|
/** `args` are `mason start`'s own, passed on to the run with --attach. */
|
|
42
61
|
export async function devInBackground(args, o) {
|
|
43
62
|
const t = theProject(process.cwd());
|
|
@@ -55,9 +74,31 @@ export async function devInBackground(args, o) {
|
|
|
55
74
|
say(nextBlock([["mason status", "what it is doing"], ["mason stop", "stop it"]]));
|
|
56
75
|
return 0;
|
|
57
76
|
}
|
|
77
|
+
// a demo project whose sources file says sync: replication (src/cli/demo.ts): refused here too, where it is read,
|
|
78
|
+
// before a run is spawned — the sources file as the run reads it (a file it cannot read is the run's to say, in its checklist)
|
|
79
|
+
const src = projectSource(t.project);
|
|
80
|
+
const file = "message" in src ? null : src;
|
|
81
|
+
const shown = file === null ? "" : shownPath(t.project.root, file.file);
|
|
82
|
+
const demoRefused = demoChangesRefusal(t.project.config, file, shown);
|
|
83
|
+
if (demoRefused)
|
|
84
|
+
return failed("start", demoRefused, { json: o.json });
|
|
85
|
+
// a setting a start refuses, here where the person (or --json) reads it, by the run's own list (src/cli/project.ts)
|
|
86
|
+
const setting = startSettingRefusal(t.project, process.env, { serve: false });
|
|
87
|
+
if (setting !== null)
|
|
88
|
+
return failed("start", setting, { json: o.json });
|
|
89
|
+
// the history's days (MASON_HISTORY_DAYS, a number of days; the old MASON_REQUEST_LOG_DAYS refused), by the run's own
|
|
90
|
+
// check (src/duck/history.ts), refused here as in the run
|
|
91
|
+
const days = historyDaysSetting(process.env);
|
|
92
|
+
if (typeof days !== "number")
|
|
93
|
+
return failed("start", { code: "refused", ...days }, { json: o.json });
|
|
94
|
+
// what the config records and the file does not say (src/cli/replication-mode.ts), in the run's order: settings first
|
|
95
|
+
const unsaid = file === null ? null : recordRefusal(t.project.config, file, process.env, shown, "mason start");
|
|
96
|
+
if (unsaid !== null)
|
|
97
|
+
return failed("start", unsaid, { json: o.json });
|
|
58
98
|
// a MASON_PG_PORT that is no port, or is held, is refused HERE, where the person (or --json) reads it, before a run is
|
|
59
|
-
// spawned; the run checks again for itself (choosePgPort, src/cli/ports.ts), since a port may be taken in between
|
|
60
|
-
|
|
99
|
+
// spawned; the run checks again for itself (choosePgPort, src/cli/ports.ts), since a port may be taken in between.
|
|
100
|
+
// A demo project's database port (its URL's, src/cli/demo.ts demoPort) is avoided as the run avoids it
|
|
101
|
+
const pg = await choosePgPort(process.env.MASON_PG_PORT, demoPortsOf(t.project), t.project.config.pgPort ?? null);
|
|
61
102
|
if ("problem" in pg)
|
|
62
103
|
return failed("start", pg, { json: o.json });
|
|
63
104
|
// and so is a listener off loopback without TLS, by the run's own check (src/cli/pg-tls.ts): read, never written
|
|
@@ -140,15 +181,21 @@ export async function devInBackground(args, o) {
|
|
|
140
181
|
steps.fail(l.slice(STEP_MARK.fail.length));
|
|
141
182
|
continue;
|
|
142
183
|
}
|
|
143
|
-
// the run's line (src/cli/dev.ts: "Postgres <url>")
|
|
144
|
-
const pgLine = /^\s+Postgres\s+(
|
|
184
|
+
// the run's line (src/cli/dev.ts: "Postgres <url>", the URL in its shown form, `withPlaceholders`: postgres://<user>:<password>@…)
|
|
185
|
+
const pgLine = /^\s+Postgres\s+(postgres:\/\/<user>:<password>@\S+)/.exec(l);
|
|
145
186
|
if (pgLine) {
|
|
146
187
|
postgres = postgresLink(withoutPlaceholders(pgLine[1]));
|
|
147
188
|
steps.print(` ${st.dim("Postgres API")} ${pgLine[1]} ${st.dim("(mason user add <name> makes a user)")}`);
|
|
148
189
|
continue;
|
|
149
190
|
}
|
|
150
191
|
if (/^\s+Web UI\s+mason ui\b/.test(l)) {
|
|
151
|
-
steps.print(` ${st.dim("Web UI".padEnd("Postgres API".length))} ${st.cyan("mason ui")} ${st.dim(
|
|
192
|
+
steps.print(` ${st.dim("Web UI".padEnd("Postgres API".length))} ${st.cyan("mason ui")} ${st.dim(`(${UI.summary})`)}`);
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
// a demo project's line (src/cli/dev.ts: "Demo a new order lands every <n> s: …"), in the same column
|
|
196
|
+
const demoLine = /^\s+Demo\s{2}(.+)$/.exec(l);
|
|
197
|
+
if (demoLine) {
|
|
198
|
+
steps.print(` ${st.dim("Demo".padEnd("Postgres API".length))} ${demoLine[1]}`);
|
|
152
199
|
continue;
|
|
153
200
|
}
|
|
154
201
|
if (l.trim())
|