@mason-data/cli 0.0.0-stage → 0.7.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/LICENSE +202 -0
- package/README.md +268 -2
- package/dist/src/api/error.js +18 -0
- package/dist/src/api/postgres/catalog-worker.js +92 -0
- package/dist/src/api/postgres/catalog.js +477 -0
- package/dist/src/api/postgres/encode.js +541 -0
- package/dist/src/api/postgres/explain.js +143 -0
- package/dist/src/api/postgres/listener.js +60 -0
- package/dist/src/api/postgres/parser-worker.js +58 -0
- package/dist/src/api/postgres/parser.js +121 -0
- package/dist/src/api/postgres/plain.js +33 -0
- package/dist/src/api/postgres/protocol.js +404 -0
- package/dist/src/api/postgres/schema.js +54 -0
- package/dist/src/api/postgres/server.js +1771 -0
- package/dist/src/api/postgres/translate.js +248 -0
- package/dist/src/api/request-log.js +50 -0
- package/dist/src/cdc/pg-wire.js +588 -0
- package/dist/src/cdc/pgoutput.js +107 -0
- package/dist/src/cdc/poll.js +67 -0
- package/dist/src/cdc/source.js +65 -0
- package/dist/src/cli/background.js +198 -0
- package/dist/src/cli/command.js +173 -0
- package/dist/src/cli/commands.js +1277 -0
- package/dist/src/cli/control.js +36 -0
- package/dist/src/cli/demo.js +149 -0
- package/dist/src/cli/dev.js +669 -0
- package/dist/src/cli/doctor.js +260 -0
- package/dist/src/cli/guides.js +158 -0
- package/dist/src/cli/init.js +366 -0
- package/dist/src/cli/machine.js +162 -0
- package/dist/src/cli/main.js +83 -0
- package/dist/src/cli/mcp.js +726 -0
- package/dist/src/cli/pg-tls.js +100 -0
- package/dist/src/cli/pg-url.js +52 -0
- package/dist/src/cli/pin.js +36 -0
- package/dist/src/cli/ports.js +118 -0
- package/dist/src/cli/preflight.js +347 -0
- package/dist/src/cli/project.js +129 -0
- package/dist/src/cli/readiness.js +76 -0
- package/dist/src/cli/registry.js +220 -0
- package/dist/src/cli/replication-checks.js +120 -0
- package/dist/src/cli/replication-mode.js +148 -0
- package/dist/src/cli/run-server.js +311 -0
- package/dist/src/cli/runs.js +120 -0
- package/dist/src/cli/security.js +86 -0
- package/dist/src/cli/self-signed-cert.js +64 -0
- package/dist/src/cli/serve.js +301 -0
- package/dist/src/cli/source-edit.js +116 -0
- package/dist/src/cli/sources.js +564 -0
- package/dist/src/cli/state.js +58 -0
- package/dist/src/cli/style.js +187 -0
- package/dist/src/cli/supervise.js +248 -0
- package/dist/src/cli/sync.js +94 -0
- package/dist/src/cli/ui.js +107 -0
- package/dist/src/cli/user.js +229 -0
- package/dist/src/cli/version.js +23 -0
- package/dist/src/demo/seed.js +237 -0
- package/dist/src/demo/source.js +655 -0
- package/dist/src/duck/analyze.js +113 -0
- package/dist/src/duck/cancel.js +49 -0
- package/dist/src/duck/distinct-key.js +228 -0
- package/dist/src/duck/executor-worker.js +268 -0
- package/dist/src/duck/executor.js +567 -0
- package/dist/src/duck/explain.js +238 -0
- package/dist/src/duck/functions.js +819 -0
- package/dist/src/duck/gaps.js +94 -0
- package/dist/src/duck/jsonb-order.js +175 -0
- package/dist/src/duck/jsonb.js +188 -0
- package/dist/src/duck/kept-route.js +299 -0
- package/dist/src/duck/kept.js +964 -0
- package/dist/src/duck/late.js +221 -0
- package/dist/src/duck/libc-order.js +190 -0
- package/dist/src/duck/pg-eval-worker.js +34 -0
- package/dist/src/duck/pg-eval.js +59 -0
- package/dist/src/duck/pgtree.js +378 -0
- package/dist/src/duck/plan.js +259 -0
- package/dist/src/duck/publication.js +207 -0
- package/dist/src/duck/requests.js +209 -0
- package/dist/src/duck/resolved.js +248 -0
- package/dist/src/duck/rewrite.js +5101 -0
- package/dist/src/duck/store.js +907 -0
- package/dist/src/duck/stream.js +518 -0
- package/dist/src/duck/sync.js +2929 -0
- package/dist/src/duck/to-char.js +482 -0
- package/dist/src/duck/token.js +31 -0
- package/dist/src/duck/types.js +427 -0
- package/dist/src/duck/users.js +231 -0
- package/dist/src/duck/verified.js +1001 -0
- package/dist/src/duck/views.js +150 -0
- package/dist/src/lib/database-url.js +74 -0
- package/dist/src/lib/did-you-mean.js +43 -0
- package/dist/src/lib/dotenv.js +72 -0
- package/dist/src/lib/exit-when-written.js +20 -0
- package/dist/src/lib/float-equal.js +69 -0
- package/dist/src/lib/http-json.js +22 -0
- package/dist/src/lib/log.js +161 -0
- package/dist/src/lib/pg-numeric.js +489 -0
- package/dist/src/lib/port.js +22 -0
- package/dist/src/lib/project-file.js +49 -0
- package/dist/src/lib/project-files.js +43 -0
- package/dist/src/lib/project-version.js +76 -0
- package/dist/src/lib/runnable.js +14 -0
- package/dist/src/lib/shell-word.js +4 -0
- package/dist/src/lib/timezone.js +237 -0
- package/dist/src/lib/zone-names.js +114 -0
- package/dist/src/web/app.css +76 -0
- package/dist/src/web/app.js +491 -0
- package/dist/src/web/index.html +44 -0
- package/package.json +22 -4
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A SOURCE VIEW, ANSWERED OVER THE COPIED TABLES (the Metabase test of #517: "serve views"). DuckDB holds the tables a
|
|
3
|
+
* view reads, never the view: its definition is Postgres SQL, which means what it means only through the rewrite, and
|
|
4
|
+
* the rewrite depends on the session (its zone, the transaction's clock, the parameters). So a statement reading a view
|
|
5
|
+
* is answered as Postgres itself answers it — its rewriter replaces the view's range-table entry with the view's query
|
|
6
|
+
* — by replacing each reference with `(<definition>) AS <name>` and planning THAT statement from the start: Postgres
|
|
7
|
+
* analyses it, the rewrite and the verification hold it as any other, and the result's columns are the view's.
|
|
8
|
+
*
|
|
9
|
+
* Postgres decides which names are views: the analysis's range table (`relkind v`), and every name the statement
|
|
10
|
+
* writes is resolved by the catalog under the session's search_path. The two must agree, or nothing is expanded. The
|
|
11
|
+
* definition is printed with every name outside pg_catalog qualified (`pg_get_viewdef` under a search_path of
|
|
12
|
+
* pg_catalog alone), so it reads the same tables under the session's search_path. What the expanded statement could
|
|
13
|
+
* read otherwise than the original is refused by name (gap `view-forms`): a view's whole row (a subquery's is a
|
|
14
|
+
* record), a name both a WITH query and a view, a column qualified by its view's schema beside another item of the
|
|
15
|
+
* view's name.
|
|
16
|
+
*/
|
|
17
|
+
import { deparseSync } from "pgsql-deparser";
|
|
18
|
+
import { StatementError } from "../api/postgres/server.js";
|
|
19
|
+
import { parseSql } from "../api/postgres/parser.js";
|
|
20
|
+
import { catalogRows } from "./analyze.js";
|
|
21
|
+
import { gapMark } from "./gaps.js";
|
|
22
|
+
import { treeDifference } from "./rewrite.js";
|
|
23
|
+
/** views read through views read through views…: past this depth the statement is refused (a definition is never
|
|
24
|
+
* recursive, so only a deep stack of views reaches it) */
|
|
25
|
+
export const MAX_VIEW_DEPTH = 16;
|
|
26
|
+
const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
27
|
+
const quote = (name) => `"${name.replace(/"/g, '""')}"`;
|
|
28
|
+
const literal = (s) => `'${s.replace(/'/g, "''")}'`;
|
|
29
|
+
const sval = (n) => isObject(n) && isObject(n.String) && typeof n.String.sval === "string" ? n.String.sval : null;
|
|
30
|
+
export function viewRefused(message, hint) {
|
|
31
|
+
return new StatementError({ code: "0A000", message, hint, detail: `${gapMark("view-forms")} (docs/SQL-SUPPORT.md)` });
|
|
32
|
+
}
|
|
33
|
+
/** the views Postgres's analysis read: every range-table entry of a relation whose kind is a view, by OID */
|
|
34
|
+
export function viewsRead(tree) {
|
|
35
|
+
const out = new Set();
|
|
36
|
+
const visit = (v) => {
|
|
37
|
+
if (Array.isArray(v)) {
|
|
38
|
+
for (const x of v)
|
|
39
|
+
visit(x);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (v === null || typeof v !== "object")
|
|
43
|
+
return;
|
|
44
|
+
if (v._node === "RANGETBLENTRY" && v.rtekind === "0" && v.relkind === "v" && typeof v.relid === "string")
|
|
45
|
+
out.add(Number(v.relid));
|
|
46
|
+
for (const [k, x] of Object.entries(v))
|
|
47
|
+
if (k !== "_node")
|
|
48
|
+
visit(x);
|
|
49
|
+
};
|
|
50
|
+
visit(tree);
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
/** every `{ Kind: body }` node of the raw tree, with the object and key holding it (to be replaced in place) */
|
|
54
|
+
function nodes(root) {
|
|
55
|
+
const out = [];
|
|
56
|
+
const visit = (holder, key, v) => {
|
|
57
|
+
if (Array.isArray(v)) {
|
|
58
|
+
v.forEach((x, i) => visit(v, i, x));
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (!isObject(v))
|
|
62
|
+
return;
|
|
63
|
+
const keys = Object.keys(v);
|
|
64
|
+
if (keys.length === 1 && /^[A-Z]/.test(keys[0]) && isObject(v[keys[0]]))
|
|
65
|
+
out.push({ holder, key, kind: keys[0], body: v[keys[0]] });
|
|
66
|
+
for (const k of keys)
|
|
67
|
+
visit(v, k, v[k]);
|
|
68
|
+
};
|
|
69
|
+
visit({ root }, "root", root);
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* The statement with every view it reads replaced by its definition, printed; null when it reads no view. Throws a
|
|
74
|
+
* StatementError (gap view-forms) where the replacement could read otherwise than Postgres does.
|
|
75
|
+
*/
|
|
76
|
+
export async function expandViews(catalog, stmt, analysis, searchPathSql) {
|
|
77
|
+
const read = viewsRead(analysis.tree);
|
|
78
|
+
if (read.size === 0)
|
|
79
|
+
return null;
|
|
80
|
+
const tree = structuredClone(stmt);
|
|
81
|
+
const all = nodes(tree);
|
|
82
|
+
const ranges = all.filter((n) => n.kind === "RangeVar");
|
|
83
|
+
const ctes = new Set(all.filter((n) => n.kind === "CommonTableExpr").map((n) => n.body.ctename));
|
|
84
|
+
// each name the statement writes, resolved as Postgres resolves it under the session's search_path
|
|
85
|
+
const spelled = ranges.map((r) => [r.body.catalogname, r.body.schemaname, r.body.relname].filter((x) => typeof x === "string").map(quote).join("."));
|
|
86
|
+
const resolved = spelled.length === 0 ? [] : await catalogRows(catalog, `SET LOCAL search_path = ${searchPathSql}, pg_catalog; SELECT v.i::text, c.oid::text, n.nspname, c.reltype::text FROM (VALUES ${spelled.map((s, i) => `(${i}, ${literal(s)})`).join(", ")}) AS v(i, name) ` +
|
|
87
|
+
`JOIN pg_class c ON c.oid = to_regclass(v.name) JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.relkind = 'v'`);
|
|
88
|
+
const asView = new Map(resolved.map(([i, oid, schema, rowType]) => [Number(i), { oid: Number(oid), schema: schema, rowType: Number(rowType) }]));
|
|
89
|
+
// the names that are views must be the views Postgres read: a WITH query of a view's name is one or the other
|
|
90
|
+
for (const [i] of asView) {
|
|
91
|
+
if (ranges[i].body.schemaname === undefined && ctes.has(ranges[i].body.relname)) {
|
|
92
|
+
throw viewRefused(`Mason cannot yet answer a statement whose WITH query is named as a view it reads (${ranges[i].body.relname})`, "rename the WITH query");
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const found = new Set([...asView.values()].map((v) => v.oid));
|
|
96
|
+
if (found.size !== read.size || [...read].some((o) => !found.has(o))) {
|
|
97
|
+
throw viewRefused("Mason could not tell which of the statement's names are the views Postgres read (a Mason gap: report it, with the statement)", "write each view's name qualified by its schema");
|
|
98
|
+
}
|
|
99
|
+
// a view's whole row is the view's row type; a subquery's is a record
|
|
100
|
+
const rowTypes = new Set([...asView.values()].map((v) => v.rowType));
|
|
101
|
+
if ([...analysis.types.values()].flat().some((t) => rowTypes.has(t.type))) {
|
|
102
|
+
throw viewRefused("Mason cannot yet answer a view's whole row as a value", "name the view's columns, or write view.*");
|
|
103
|
+
}
|
|
104
|
+
const definitions = new Map((await catalogRows(catalog, `SET LOCAL search_path = pg_catalog; SELECT c.oid::text, pg_get_viewdef(c.oid) FROM pg_class c WHERE c.oid IN (${[...found].join(", ")})`))
|
|
105
|
+
.map(([oid, def]) => [Number(oid), def]));
|
|
106
|
+
const parsed = new Map();
|
|
107
|
+
for (const [oid, def] of definitions) {
|
|
108
|
+
const s = (await parseSql(def)).stmts?.[0]?.stmt;
|
|
109
|
+
if (s === undefined)
|
|
110
|
+
throw new Error(`the catalog's definition of view ${oid} did not parse: report it`);
|
|
111
|
+
parsed.set(oid, s);
|
|
112
|
+
}
|
|
113
|
+
// a column qualified by its view's schema (`public.v.id`, Metabase writes every column so) names the subquery by its
|
|
114
|
+
// alias alone: sound only where no other item of the statement has the view's name
|
|
115
|
+
const named = all.flatMap((n) => {
|
|
116
|
+
const alias = n.body.alias?.aliasname;
|
|
117
|
+
if (n.kind === "RangeVar")
|
|
118
|
+
return [typeof alias === "string" ? alias : n.body.relname];
|
|
119
|
+
if (n.kind === "CommonTableExpr")
|
|
120
|
+
return [n.body.ctename];
|
|
121
|
+
return typeof alias === "string" && ["RangeSubselect", "RangeFunction", "RangeTableFunc", "JoinExpr"].includes(n.kind) ? [alias] : [];
|
|
122
|
+
});
|
|
123
|
+
const qualified = new Map();
|
|
124
|
+
for (const [i, v] of asView) {
|
|
125
|
+
const r = ranges[i];
|
|
126
|
+
if (r.body.alias === undefined)
|
|
127
|
+
qualified.set(`${v.schema}.${r.body.relname}`, i);
|
|
128
|
+
r.holder[r.key] = { RangeSubselect: { subquery: structuredClone(parsed.get(v.oid)), alias: r.body.alias ?? { aliasname: r.body.relname } } };
|
|
129
|
+
}
|
|
130
|
+
for (const c of all.filter((n) => n.kind === "ColumnRef")) {
|
|
131
|
+
const fields = c.body.fields;
|
|
132
|
+
if (fields.length < 3)
|
|
133
|
+
continue;
|
|
134
|
+
const [schema, rel] = [sval(fields[fields.length - 3]), sval(fields[fields.length - 2])];
|
|
135
|
+
if (schema === null || rel === null || !qualified.has(`${schema}.${rel}`))
|
|
136
|
+
continue;
|
|
137
|
+
if (named.filter((n) => n === rel).length > 1) {
|
|
138
|
+
throw viewRefused(`Mason cannot yet answer a column qualified by its view's schema (${schema}.${rel}) beside another item named ${rel}`, `give the view an alias, and qualify its columns by it`);
|
|
139
|
+
}
|
|
140
|
+
c.body.fields = fields.slice(-2);
|
|
141
|
+
}
|
|
142
|
+
// THE PRINTED TEXT MUST READ BACK AS THE TREE, as the rewrite's (src/duck/plan.ts)
|
|
143
|
+
const text = deparseSync(tree, { pretty: false });
|
|
144
|
+
const back = (await parseSql(text)).stmts?.[0]?.stmt;
|
|
145
|
+
const difference = back === undefined ? "nothing" : treeDifference(back, tree);
|
|
146
|
+
if (difference !== null) {
|
|
147
|
+
throw viewRefused("Mason cannot answer this statement's views: the statement with their definitions does not read back as itself (a Mason gap: report it, with the statement)", `read back: ${difference}`);
|
|
148
|
+
}
|
|
149
|
+
return { text, stmt: back };
|
|
150
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `DATABASE_URL`, THE ONE CONNECTION STRING A MASON DEPLOYMENT IS GIVEN. An application's
|
|
3
|
+
* Postgres is almost always described by one URL (`postgres://user:password@host:5432/db?sslmode=require`),
|
|
4
|
+
* while every script here reads the `PG_*` variables. This turns
|
|
5
|
+
* the first into the second, once, at the top of the `mason` service, so every child it starts — the
|
|
6
|
+
* change reader, bootstrap's `source_pg` collection, the source-type reader — sees the same connection.
|
|
7
|
+
*
|
|
8
|
+
* An explicit `PG_*` variable WINS over the URL: it is the more specific of the two, and a deployment's `.env`
|
|
9
|
+
* may already set them. Unknown query parameters are refused rather than ignored, so a `sslrootcert=` that
|
|
10
|
+
* would have been silently dropped fails at start instead of connecting without it.
|
|
11
|
+
*
|
|
12
|
+
* Pure; dependency-free.
|
|
13
|
+
*/
|
|
14
|
+
const SSLMODES = new Set(["disable", "allow", "prefer", "require", "verify-ca", "verify-full"]);
|
|
15
|
+
const KNOWN_PARAMS = new Set(["sslmode", "application_name"]);
|
|
16
|
+
/** Parse a `postgres://` / `postgresql://` URL. Throws on anything it cannot represent faithfully. */
|
|
17
|
+
export function parseDatabaseUrl(url) {
|
|
18
|
+
let u;
|
|
19
|
+
try {
|
|
20
|
+
u = new URL(url);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
throw new Error("DATABASE_URL is not a URL (expected postgres://user:password@host:port/database)");
|
|
24
|
+
}
|
|
25
|
+
if (u.protocol !== "postgres:" && u.protocol !== "postgresql:") {
|
|
26
|
+
throw new Error(`DATABASE_URL: scheme ${u.protocol.replace(/:$/, "")} is not postgres or postgresql`);
|
|
27
|
+
}
|
|
28
|
+
for (const key of u.searchParams.keys()) {
|
|
29
|
+
if (!KNOWN_PARAMS.has(key))
|
|
30
|
+
throw new Error(`DATABASE_URL: parameter ${key} is not supported (only ${[...KNOWN_PARAMS].join(", ")})`);
|
|
31
|
+
}
|
|
32
|
+
const sslmode = u.searchParams.get("sslmode") ?? "prefer";
|
|
33
|
+
if (!SSLMODES.has(sslmode))
|
|
34
|
+
throw new Error(`DATABASE_URL: sslmode=${sslmode} is not one of ${[...SSLMODES].join(", ")}`);
|
|
35
|
+
const database = decodeURIComponent(u.pathname.replace(/^\//, ""));
|
|
36
|
+
if (!u.hostname)
|
|
37
|
+
throw new Error("DATABASE_URL has no host");
|
|
38
|
+
if (!database)
|
|
39
|
+
throw new Error("DATABASE_URL has no database name (the path after the host)");
|
|
40
|
+
if (!u.username)
|
|
41
|
+
throw new Error("DATABASE_URL has no user");
|
|
42
|
+
return {
|
|
43
|
+
// `new URL` keeps IPv6 brackets; libpq and ClickHouse's postgresql() want the bare address
|
|
44
|
+
host: u.hostname.replace(/^\[|\]$/g, ""),
|
|
45
|
+
port: u.port ? Number(u.port) : 5432,
|
|
46
|
+
database,
|
|
47
|
+
user: decodeURIComponent(u.username),
|
|
48
|
+
password: decodeURIComponent(u.password),
|
|
49
|
+
sslmode: sslmode,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Lay `DATABASE_URL` under `env`'s own `PG_*` variables: each one the environment does not already set is
|
|
54
|
+
* filled from the URL. Returns the names it filled, so the caller can log where the connection came from
|
|
55
|
+
* (never the values: the password is among them).
|
|
56
|
+
*/
|
|
57
|
+
export function applyDatabaseUrl(env) {
|
|
58
|
+
const url = env.DATABASE_URL;
|
|
59
|
+
if (!url)
|
|
60
|
+
return [];
|
|
61
|
+
const c = parseDatabaseUrl(url);
|
|
62
|
+
const values = {
|
|
63
|
+
PG_HOST: c.host, PG_PORT: String(c.port), PG_DATABASE: c.database, PG_USER: c.user,
|
|
64
|
+
PG_PASSWORD: c.password, PG_SSLMODE: c.sslmode,
|
|
65
|
+
};
|
|
66
|
+
const filled = [];
|
|
67
|
+
for (const [k, v] of Object.entries(values)) {
|
|
68
|
+
if (env[k])
|
|
69
|
+
continue;
|
|
70
|
+
env[k] = v;
|
|
71
|
+
filled.push(k);
|
|
72
|
+
}
|
|
73
|
+
return filled;
|
|
74
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "DID YOU MEAN …?" (the CLI's errors must be as clear as they can be — a coding agent fixes a file
|
|
3
|
+
* from the message alone). The closest of `candidates` to a name that is not one of them: a different case, a typo
|
|
4
|
+
* of a letter or two (edit distance, transpositions counted once), or a name it is the start of. Null when nothing is
|
|
5
|
+
* close enough to be a fair guess: a wrong suggestion is worse than none. Dependency-free.
|
|
6
|
+
*/
|
|
7
|
+
/** Optimal-string-alignment distance: insertions, deletions, substitutions and adjacent swaps, each 1. */
|
|
8
|
+
export function editDistance(a, b) {
|
|
9
|
+
const d = Array.from({ length: a.length + 1 }, (_, i) => Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)));
|
|
10
|
+
for (let i = 1; i <= a.length; i++) {
|
|
11
|
+
for (let j = 1; j <= b.length; j++) {
|
|
12
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
13
|
+
d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + cost);
|
|
14
|
+
if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1])
|
|
15
|
+
d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return d[a.length][b.length];
|
|
19
|
+
}
|
|
20
|
+
/** The candidate `name` most likely meant, or null. */
|
|
21
|
+
export function closest(name, candidates) {
|
|
22
|
+
const lower = name.toLowerCase();
|
|
23
|
+
let best = null, bestScore = Infinity;
|
|
24
|
+
for (const c of new Set(candidates)) {
|
|
25
|
+
if (c === name)
|
|
26
|
+
continue;
|
|
27
|
+
const cl = c.toLowerCase();
|
|
28
|
+
// a different case, or one is the start of the other (`created` for `created_at`, `metric` for `metrics`)
|
|
29
|
+
const score = cl === lower ? 0 : (cl.startsWith(lower) || lower.startsWith(cl)) && Math.min(cl.length, lower.length) >= 3 ? 1 : editDistance(lower, cl);
|
|
30
|
+
if (score < bestScore) {
|
|
31
|
+
best = c;
|
|
32
|
+
bestScore = score;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
// a typo is a letter or two, never a third of the word
|
|
36
|
+
const allowed = Math.max(1, Math.min(2, Math.floor(name.length / 3)));
|
|
37
|
+
return best !== null && bestScore <= allowed ? best : null;
|
|
38
|
+
}
|
|
39
|
+
/** ` — did you mean \`x\`?`, or "" when nothing is close. */
|
|
40
|
+
export function didYouMean(name, candidates) {
|
|
41
|
+
const c = closest(name, candidates);
|
|
42
|
+
return c === null ? "" : ` — did you mean \`${c}\`?`;
|
|
43
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `.env` UNDER THE ENVIRONMENT: a variable the process already carries wins, and `.env` fills in the rest — the
|
|
3
|
+
* rule docker compose applies to `${VAR}` too, so a value set in the shell means the same everywhere. (Written the
|
|
4
|
+
* other way round, `.env` only, a helper once read one deployment's settings under another's shell: this is the ONE
|
|
5
|
+
* reader, so no copy of it can have the rule backwards.)
|
|
6
|
+
*
|
|
7
|
+
* Lines are `KEY=value`, optionally quoted; blank lines and `#` comments are skipped. Dependency-free.
|
|
8
|
+
*/
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
/** The file an app's folder keeps its variables in: the one this module reads and `appendEnvLine` writes. */
|
|
12
|
+
export const ENV_FILE = ".env";
|
|
13
|
+
/** A `.env` file's text, parsed. */
|
|
14
|
+
export function parseDotenv(text) {
|
|
15
|
+
const env = {};
|
|
16
|
+
for (let line of text.split("\n")) {
|
|
17
|
+
line = line.trim();
|
|
18
|
+
if (!line || line.startsWith("#") || !line.includes("="))
|
|
19
|
+
continue;
|
|
20
|
+
const eq = line.indexOf("=");
|
|
21
|
+
env[line.slice(0, eq).trim()] = line.slice(eq + 1).trim().replace(/^['"]|['"]$/g, "");
|
|
22
|
+
}
|
|
23
|
+
return env;
|
|
24
|
+
}
|
|
25
|
+
/** `root/.env` with every variable of `processEnv` laid over it. */
|
|
26
|
+
export function readEnv(root, processEnv = process.env) {
|
|
27
|
+
const file = path.join(root, ENV_FILE);
|
|
28
|
+
const env = fs.existsSync(file) ? parseDotenv(fs.readFileSync(file, "utf8")) : {};
|
|
29
|
+
for (const [k, v] of Object.entries(processEnv))
|
|
30
|
+
if (v !== undefined)
|
|
31
|
+
env[k] = v;
|
|
32
|
+
return env;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* A VARIABLE OF AN APP'S FOLDER (`mason init`'s DATABASE_URL, and a source file's `url: ${NAME}`,
|
|
36
|
+
* src/cli/sources.ts): `name` from the folder's `.env.local`, then `.env` (an `export ` before a line is
|
|
37
|
+
* allowed, as shells write it), then the environment — the order web frameworks read them in, the app's own files
|
|
38
|
+
* first because they name the database this folder's app uses. A different question from readEnv's "this process's
|
|
39
|
+
* configuration": it reads one key of someone else's files. ONE reader for init and for every later read of the
|
|
40
|
+
* source's URL, so the URL init checked is the one the run connects to. `from` says where it was found; `file` is the
|
|
41
|
+
* folder's file it came from (null from the shell). `shellFirst` reads the environment before the files: a server's order.
|
|
42
|
+
*/
|
|
43
|
+
export function appEnvValue(root, name, processEnv = process.env, o = {}) {
|
|
44
|
+
// a server (mason serve) is configured by its environment alone: a .env in its data folder never overrides the platform's
|
|
45
|
+
if (o.shellFirst && processEnv[name])
|
|
46
|
+
return { value: processEnv[name], from: "the environment", file: null };
|
|
47
|
+
for (const file of [`${ENV_FILE}.local`, ENV_FILE]) {
|
|
48
|
+
const p = path.join(root, file);
|
|
49
|
+
if (!fs.existsSync(p))
|
|
50
|
+
continue;
|
|
51
|
+
const text = fs.readFileSync(p, "utf8").split("\n").map((l) => l.replace(/^\s*export\s+/, "")).join("\n");
|
|
52
|
+
const value = parseDotenv(text)[name];
|
|
53
|
+
if (value)
|
|
54
|
+
return { value, from: file, file };
|
|
55
|
+
}
|
|
56
|
+
const value = processEnv[name];
|
|
57
|
+
return value ? { value, from: "the shell", file: null } : null;
|
|
58
|
+
}
|
|
59
|
+
/** The app's DATABASE_URL (`appEnvValue`): what `mason init` reads when no URL is given. */
|
|
60
|
+
export function appDatabaseUrl(root, processEnv = process.env) {
|
|
61
|
+
const v = appEnvValue(root, "DATABASE_URL", processEnv);
|
|
62
|
+
return v === null ? null : { url: v.value, from: v.file ?? "the shell's DATABASE_URL", file: v.file };
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* `root/.env` with `line` (`NAME=value`) added under a `# comment`, the file made when there is none — owner-only
|
|
66
|
+
* (0600): it holds a database's password. What `mason init` writes for a source file's `url: ${NAME}`.
|
|
67
|
+
*/
|
|
68
|
+
export function appendEnvLine(root, line, comment) {
|
|
69
|
+
const file = path.join(root, ENV_FILE);
|
|
70
|
+
const before = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
|
|
71
|
+
fs.writeFileSync(file, `${before}${before === "" || before.endsWith("\n") ? "" : "\n"}${before === "" ? "" : "\n"}# ${comment}\n${line}\n`, { mode: 0o600 });
|
|
72
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exits once everything written has reached the reader. On a pipe, stdout and stderr can be asynchronous (macOS; Linux
|
|
3
|
+
* once a pipe is full) and process.exit drops what is still queued: `mason status --json | jq` read 65,536 bytes of
|
|
4
|
+
* 138,668 (#435), and bootstrap's last lines — its FAIL lines and the verdict the service reads a failed step's reason
|
|
5
|
+
* from — could be cut the same way. One exit for every process whose output a parent reads (tests/conventions.test.ts).
|
|
6
|
+
*/
|
|
7
|
+
export function exitWhenWritten(code) {
|
|
8
|
+
const streams = [process.stdout, process.stderr];
|
|
9
|
+
let waiting = streams.length;
|
|
10
|
+
for (const s of streams) {
|
|
11
|
+
let settled = false;
|
|
12
|
+
const done = () => { if (settled)
|
|
13
|
+
return; settled = true; if (--waiting === 0)
|
|
14
|
+
process.exit(code); };
|
|
15
|
+
// a reader that went away (EPIPE: `mason status --json | head -c 100`) can take nothing more, so there is nothing
|
|
16
|
+
// left to wait for on that stream; the exit code stays the process's
|
|
17
|
+
s.on("error", done);
|
|
18
|
+
s.write("", done);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TWO FLOATS ARE THE SAME VALUE WITHIN FLOAT_TOLERANCE (found by the `mason init demo` spike). A source
|
|
3
|
+
* `numeric` was read as Float64 by the ClickHouse stack this was cut from, and a Float64 sum depends on the ORDER
|
|
4
|
+
* its terms were added in: a rollup that sums per customer and then per day, and a recompute that sums the day in one
|
|
5
|
+
* pass, differ in the last bits — 316522.5499999999 against 316522.5500000003 over the same 1,042 orders. Both detectors
|
|
6
|
+
* that compare a stored value with a recomputed one called that a difference: bootstrap's `no_drift_in_clean_batch`
|
|
7
|
+
* failed every apply of a project with a `sum` over a money column, and run.ts's verification marked the routed answer
|
|
8
|
+
* `verified_different`, so the loop never reported clean. Every `mason add` writes such a metric (`sum_<column>` for each
|
|
9
|
+
* numeric column), so this was any project's first rollup.
|
|
10
|
+
*
|
|
11
|
+
* The same class as an approximate sketch (CLAUDE.md "An APPROXIMATE aggregate state cannot be compared for equality"):
|
|
12
|
+
* a value whose bits depend on how it was built. A t-digest is skipped, being a percent apart by design; a float is
|
|
13
|
+
* compared within a relative 1e-9 OR an absolute FLOAT_FLOOR, whichever is larger:
|
|
14
|
+
* - the RELATIVE bound is far above the noise of a sum of terms of one sign (n terms add at most n x 2^-53 of their
|
|
15
|
+
* magnitude: 1e-9 is 10 M terms of it) and far below any difference a row makes — one order in a day's revenue is
|
|
16
|
+
* 1e-3 of it. What it cannot see is a difference no Float64 can carry either: a cent in a total of ten million.
|
|
17
|
+
* - the ABSOLUTE floor is for a sum that CANCELS (a refund, a credit: money columns have negative amounts). The noise
|
|
18
|
+
* is relative to the terms' magnitudes, not to the result's, so a total near zero keeps it: 19.99 + 5.01 - 25 is 0
|
|
19
|
+
* in one order and -1.8e-15 in another, and no relative bound calls those one value (the review of #460). Measured
|
|
20
|
+
* on 26.8, alternating +/- amounts summed per customer then in one pass: 6e-14 apart over 1,042 terms, 8e-10 over
|
|
21
|
+
* 200,000, 4.8e-8 over 2,000,000 (2e8 of magnitude). 1e-6 holds that with a margin of twenty, and is a
|
|
22
|
+
* ten-thousandth of a cent: below any amount a row of money carries. A float column whose rows differ by less
|
|
23
|
+
* than 1e-6 (a sum of probabilities near zero) would compare as one value; none of the examples has one.
|
|
24
|
+
*
|
|
25
|
+
* Only FLOATS are compared this way: an integer (a count, an id) is exact on both sides, and a count of 2,000,000,000
|
|
26
|
+
* against 2,000,000,001 is 5e-10 apart — a difference, which a tolerance on every number would hide. A DECIMAL is exact
|
|
27
|
+
* in SQL (`differsSql` takes the float branch on a Float type only), but ClickHouse's JSON writes it as a bare number
|
|
28
|
+
* (`output_format_json_quote_decimals` is 0), so `sameJsonValue`, which sees no type, takes a non-integer Decimal for a
|
|
29
|
+
* float: two Decimals within the tolerance are one value there. The translator reads `numeric` as Float64, so only a
|
|
30
|
+
* `::numeric(p,s)` cast in a query makes a Decimal, and a difference that small is a millionth of its unit.
|
|
31
|
+
*/
|
|
32
|
+
export const FLOAT_TOLERANCE = 1e-9;
|
|
33
|
+
export const FLOAT_FLOOR = 1e-6;
|
|
34
|
+
/** Whether two finite floats are one value: within FLOAT_TOLERANCE of the larger's magnitude, or FLOAT_FLOOR. */
|
|
35
|
+
export function floatsClose(a, b) {
|
|
36
|
+
return Math.abs(a - b) <= Math.max(FLOAT_TOLERANCE * Math.max(Math.abs(a), Math.abs(b)), FLOAT_FLOOR);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Whether two values from a JSON result are one value: equal as text, or two numbers at least one of which is not an
|
|
40
|
+
* integer (a Float64 in ClickHouse's JSON; its 64-bit integers arrive quoted, or as integers) and floatsClose. A
|
|
41
|
+
* float that sums to a whole number on one side only is still a float.
|
|
42
|
+
*/
|
|
43
|
+
export function sameJsonValue(a, b) {
|
|
44
|
+
if (String(a) === String(b))
|
|
45
|
+
return true;
|
|
46
|
+
return typeof a === "number" && typeof b === "number" && Number.isFinite(a) && Number.isFinite(b) &&
|
|
47
|
+
(!Number.isInteger(a) || !Number.isInteger(b)) && floatsClose(a, b);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The SQL condition that two expressions DIFFER, NULL-safe, a float within FLOAT_TOLERANCE (or FLOAT_FLOOR) counting as
|
|
51
|
+
* the same. It is
|
|
52
|
+
* written for any column type — a picked UUID, a string, a time, an integer, a finalized state — so it never takes the
|
|
53
|
+
* difference of the values themselves: the float branch reads each through its text (ClickHouse prints the shortest
|
|
54
|
+
* text that round-trips, so a Float64 reads back exactly), and only a column whose type is Float32 or Float64 takes it.
|
|
55
|
+
* A NULL beside a value fails the float branch (coalesce to false) and is a difference, as isNotDistinctFrom says.
|
|
56
|
+
*/
|
|
57
|
+
export function differsSql(a, b) {
|
|
58
|
+
const fa = `toFloat64OrNull(toString(${a}))`, fb = `toFloat64OrNull(toString(${b}))`;
|
|
59
|
+
return `(NOT isNotDistinctFrom(${a}, ${b}) AND NOT coalesce(match(toTypeName(${a}), '^(Nullable\\\\()?Float(32|64)') ` +
|
|
60
|
+
`AND abs(${fa} - ${fb}) <= greatest(${FLOAT_TOLERANCE} * greatest(abs(${fa}), abs(${fb})), ${FLOAT_FLOOR}), false))`;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* What `differsSql` tolerates, said IN the statement it is written into (CLAUDE.md: "when a check skips something, say
|
|
64
|
+
* so IN THE ARTEFACT"): a reader of a generated drift test sees why every column carries a float branch.
|
|
65
|
+
*/
|
|
66
|
+
export const FLOAT_RULE_COMMENT = [
|
|
67
|
+
`-- A Float32/Float64 column is one value within a relative ${FLOAT_TOLERANCE} or an absolute ${FLOAT_FLOOR}: a sum's`,
|
|
68
|
+
`-- last bits depend on the order it was added in. Any other type is compared exactly (src/lib/float-equal.ts).`,
|
|
69
|
+
];
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** The request body as UTF-8, rejected with `tooLarge(message)` once it passes `maxBytes`. */
|
|
2
|
+
export function readBody(req, maxBytes, tooLarge = (m) => new Error(m)) {
|
|
3
|
+
return new Promise((resolve, reject) => {
|
|
4
|
+
const chunks = [];
|
|
5
|
+
let size = 0;
|
|
6
|
+
req.on("data", (chunk) => {
|
|
7
|
+
size += chunk.length;
|
|
8
|
+
if (size > maxBytes) {
|
|
9
|
+
reject(tooLarge(`body exceeds ${maxBytes} bytes`));
|
|
10
|
+
req.destroy();
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
chunks.push(chunk);
|
|
14
|
+
});
|
|
15
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
16
|
+
req.on("error", reject);
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
export function sendJson(res, status, body, headers = {}) {
|
|
20
|
+
res.writeHead(status, { "Content-Type": "application/json", ...headers });
|
|
21
|
+
res.end(JSON.stringify(body));
|
|
22
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LOG LINES, AS TEXT OR AS JSON. `MASON_LOG_FORMAT` is `text` (the default: `[component] message`,
|
|
3
|
+
* what a person reads in `docker compose logs`) or `json`: one object per line, which a log collector parses
|
|
4
|
+
* without a pattern of its own —
|
|
5
|
+
*
|
|
6
|
+
* {"ts":"2026-10-02T10:41:07.512Z","level":"error","component":"cdc","msg":"…","service":"mason","deployment":"app","version":"1.4.0"}
|
|
7
|
+
*
|
|
8
|
+
* `level` is `info`, `warn` or `error`. A message that spans lines (a stack) is ONE entry, its newlines escaped.
|
|
9
|
+
*
|
|
10
|
+
* EVERY LINE IS JSON under `json`, not only the ones written through `logger()`: the service starts its children
|
|
11
|
+
* with their output piped, and `JsonLines` turns any line that is not an entry already into one — the component
|
|
12
|
+
* from its `[prefix]` (else the child's name), the level from an `ERROR` / `WARNING` marker after the prefix, else
|
|
13
|
+
* from the stream (stderr is `error`). An indented line, or one that continues a stack, joins the entry before it.
|
|
14
|
+
*/
|
|
15
|
+
/** MASON_LOG_FORMAT, or a problem naming what it accepts. */
|
|
16
|
+
export function logFormatOf(env) {
|
|
17
|
+
const v = env.MASON_LOG_FORMAT;
|
|
18
|
+
if (v === undefined || v === "" || v === "text")
|
|
19
|
+
return { format: "text", problem: null };
|
|
20
|
+
if (v === "json")
|
|
21
|
+
return { format: "json", problem: null };
|
|
22
|
+
return { format: "text", problem: `MASON_LOG_FORMAT=${v} is not a log format: text (the default) or json` };
|
|
23
|
+
}
|
|
24
|
+
export function entry(level, component, msg, env = process.env, at = new Date()) {
|
|
25
|
+
return {
|
|
26
|
+
ts: at.toISOString(), level, component, msg, service: "mason",
|
|
27
|
+
...(env.DEPLOYMENT ? { deployment: env.DEPLOYMENT } : {}),
|
|
28
|
+
...(env.MASON_VERSION ? { version: env.MASON_VERSION } : {}),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* ONE ENTRY AS TEXT: `head` (the time, a `[component]`, a marker) then the message, whose every further line — a DuckDB
|
|
33
|
+
* error's `LINE 1: …` and candidates, a stack's frames — is INDENTED, its blank lines dropped. A reader takes a line that
|
|
34
|
+
* starts at the margin as an entry and an indented one as the entry's continuation (`JsonLines`, `mason logs`), so a
|
|
35
|
+
* multi-line reason is never cut to its first line, nor its rest read as entries of their own (the test server: a
|
|
36
|
+
* failed catch-up's Binder Error was logged as its first line). The DuckDB run's mason.log is written through it
|
|
37
|
+
* (src/cli/dev.ts); the ClickHouse service's `logger()` prints a statement listing as it always has (bootstrap's dry run
|
|
38
|
+
* is read line by line), and its JSON form keeps a message whole.
|
|
39
|
+
*/
|
|
40
|
+
export function textEntry(head, msg) {
|
|
41
|
+
const [first = "", ...rest] = msg.replace(/\r\n?/g, "\n").trimEnd().split("\n");
|
|
42
|
+
return [`${head}${first}`, ...rest.filter((l) => l.trim() !== "").map((l) => ` ${l.trimEnd()}`)].join("\n") + "\n";
|
|
43
|
+
}
|
|
44
|
+
/** A message that begins with `ERROR` / `FATAL` / `WARNING` / `WARN` (and a colon), in capitals: that level, the marker dropped. */
|
|
45
|
+
export function markedLevel(msg) {
|
|
46
|
+
// upper case only: this repo's markers are, and `Error: …` is an exception's own text, which keeps its word
|
|
47
|
+
const mark = /^(ERROR|FATAL|WARNING|WARN)\b:? ?/.exec(msg);
|
|
48
|
+
if (!mark)
|
|
49
|
+
return { level: null, msg };
|
|
50
|
+
return { level: mark[1] === "ERROR" || mark[1] === "FATAL" ? "error" : "warn", msg: msg.slice(mark[0].length) };
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* A component's logger. As text, `[component] message`, with `WARNING: ` / `ERROR: ` before a warning's or an
|
|
54
|
+
* error's message — the lines this repo has always printed — and the time first when `timestamp` (the runtime's).
|
|
55
|
+
* Warnings and errors go to stderr, as before. As JSON every entry goes to stdout: the level is the field.
|
|
56
|
+
*/
|
|
57
|
+
export function logger(component, opts = {}, env = process.env) {
|
|
58
|
+
const { format } = logFormatOf(env);
|
|
59
|
+
const write = (level, msg) => {
|
|
60
|
+
if (format === "json") {
|
|
61
|
+
process.stdout.write(JSON.stringify(entry(level, component, msg, env)) + "\n");
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const out = level === "info" ? process.stdout : process.stderr;
|
|
65
|
+
const mark = level === "error" ? "ERROR: " : level === "warn" ? "WARNING: " : "";
|
|
66
|
+
out.write(`[${component}] ${opts.timestamp ? `${new Date().toISOString()} ` : ""}${mark}${msg}\n`);
|
|
67
|
+
};
|
|
68
|
+
return {
|
|
69
|
+
info: (m) => write("info", m), warn: (m) => write("warn", m), error: (m) => write("error", m),
|
|
70
|
+
log: (m) => { const { level, msg } = markedLevel(m); write(level ?? "info", msg); },
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
// ------------------------------------------------------------------ a child's output as entries
|
|
74
|
+
const PREFIX = /^\[([A-Za-z][A-Za-z0-9_-]*)\] ?/;
|
|
75
|
+
/** Whether `line` is an entry already: what a child's `logger()` wrote under `json`. */
|
|
76
|
+
function isEntry(line) {
|
|
77
|
+
if (!line.startsWith("{"))
|
|
78
|
+
return false;
|
|
79
|
+
try {
|
|
80
|
+
const o = JSON.parse(line);
|
|
81
|
+
return typeof o === "object" && o !== null && typeof o.level === "string" && typeof o.msg === "string";
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// not JSON: an ordinary line that happens to start with a brace, wrapped like any other
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/** One line a child printed, as an entry: its prefix as the component, its marker or stream as the level. */
|
|
89
|
+
export function lineEntry(line, stream, fallbackComponent, env = process.env, at = new Date()) {
|
|
90
|
+
const m = PREFIX.exec(line);
|
|
91
|
+
const component = m ? m[1] : fallbackComponent;
|
|
92
|
+
const marked = markedLevel(m ? line.slice(m[0].length) : line);
|
|
93
|
+
return entry(marked.level ?? (stream === "stderr" ? "error" : "info"), component, marked.msg, env, at);
|
|
94
|
+
}
|
|
95
|
+
/** A line that belongs to the entry before it: indented (a listing's rows, a stack's ` at` frames) or a closing brace. */
|
|
96
|
+
const continues = (line) => /^(\s|\}|\])/.test(line) && line.trim() !== "";
|
|
97
|
+
/**
|
|
98
|
+
* A child's stdout and stderr, as entries, one JSON object per line on `write`, in the order they were printed.
|
|
99
|
+
* Chunks are split into lines (a partial last line waits for the rest); a continuation line joins the entry before
|
|
100
|
+
* it when that came from the same stream; an entry is written when the next one starts, after FLUSH_MS of quiet,
|
|
101
|
+
* or at `end`.
|
|
102
|
+
*/
|
|
103
|
+
export class JsonLines {
|
|
104
|
+
static FLUSH_MS = 100;
|
|
105
|
+
component;
|
|
106
|
+
write;
|
|
107
|
+
env;
|
|
108
|
+
partial = { stdout: "", stderr: "" };
|
|
109
|
+
held = null;
|
|
110
|
+
timer = null;
|
|
111
|
+
constructor(component, write, env = process.env) {
|
|
112
|
+
this.component = component;
|
|
113
|
+
this.write = write;
|
|
114
|
+
this.env = env;
|
|
115
|
+
}
|
|
116
|
+
push(stream, chunk) {
|
|
117
|
+
const lines = (this.partial[stream] + chunk).split("\n");
|
|
118
|
+
this.partial[stream] = lines.pop();
|
|
119
|
+
for (const line of lines)
|
|
120
|
+
this.line(stream, line);
|
|
121
|
+
if (this.timer)
|
|
122
|
+
clearTimeout(this.timer);
|
|
123
|
+
this.timer = setTimeout(() => this.release(), JsonLines.FLUSH_MS);
|
|
124
|
+
this.timer.unref();
|
|
125
|
+
}
|
|
126
|
+
/** Everything held, written: the child exited. */
|
|
127
|
+
end() {
|
|
128
|
+
for (const s of ["stdout", "stderr"]) {
|
|
129
|
+
if (this.partial[s]) {
|
|
130
|
+
this.line(s, this.partial[s]);
|
|
131
|
+
this.partial[s] = "";
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (this.timer) {
|
|
135
|
+
clearTimeout(this.timer);
|
|
136
|
+
this.timer = null;
|
|
137
|
+
}
|
|
138
|
+
this.release();
|
|
139
|
+
}
|
|
140
|
+
line(stream, raw) {
|
|
141
|
+
const line = raw.replace(/\r$/, "");
|
|
142
|
+
if (this.held?.stream === stream && continues(line)) {
|
|
143
|
+
this.held.entry.msg += `\n${line}`;
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
this.release();
|
|
147
|
+
if (line.trim() === "")
|
|
148
|
+
return;
|
|
149
|
+
if (isEntry(line)) {
|
|
150
|
+
this.write(line);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
this.held = { stream, entry: lineEntry(line, stream, this.component, this.env) };
|
|
154
|
+
}
|
|
155
|
+
release() {
|
|
156
|
+
if (this.held) {
|
|
157
|
+
this.write(JSON.stringify(this.held.entry));
|
|
158
|
+
this.held = null;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|