@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
|
@@ -31,9 +31,13 @@
|
|
|
31
31
|
* The instance's TimeZone is UTC (the API treats every time as UTC, §4.8); its search_path is Postgres's default, so
|
|
32
32
|
* `public."orders"` and `orders` both resolve, as a tool expects.
|
|
33
33
|
*/
|
|
34
|
+
import { randomBytes } from "node:crypto";
|
|
34
35
|
import { Worker } from "node:worker_threads";
|
|
35
36
|
import { fileURLToPath } from "node:url";
|
|
36
37
|
import { runnable } from "../../lib/runnable.js";
|
|
38
|
+
import { NO_ROLE_CATALOG } from "../../duck/grants.js";
|
|
39
|
+
import { renderWhere, tableSql } from "../../duck/row-filter.js";
|
|
40
|
+
import { cstr, frame, splitFrames } from "./protocol.js";
|
|
37
41
|
import { PG_API_TYPE_SPELLING, typeNameParts } from "./schema.js";
|
|
38
42
|
/** Ten minutes: long enough to outlive a tool's startup burst and a person's pause between two queries. */
|
|
39
43
|
const DEFAULT_IDLE_MS = 10 * 60_000;
|
|
@@ -46,6 +50,7 @@ export const DEFAULT_STATEMENT_MS = 10_000;
|
|
|
46
50
|
export const MIN_START_MS = 30_000;
|
|
47
51
|
/** A SQL identifier, double-quoted with an inner quote doubled. */
|
|
48
52
|
const ident = (name) => `"${name.replaceAll('"', '""')}"`;
|
|
53
|
+
const quoteLiteral = (text) => `'${text.replaceAll("'", "''")}'`;
|
|
49
54
|
/** A SQL string literal, single-quoted with an inner quote doubled. */
|
|
50
55
|
const literal = (text) => `'${text.replaceAll("'", "''")}'`;
|
|
51
56
|
/** a table's name in the catalog: its schema (Mason's source schema, else public) and name */
|
|
@@ -70,11 +75,11 @@ function isForeignKey(schema, table, join) {
|
|
|
70
75
|
}
|
|
71
76
|
const joinNote = (join) => `joins ${join.table} on ${join.on[1]} (${join.relationship})`;
|
|
72
77
|
/**
|
|
73
|
-
* THE CATALOG'S DATABASE IS THE ONE A TOOL CONNECTED TO (
|
|
78
|
+
* THE CATALOG'S DATABASE IS THE ONE A TOOL CONNECTED TO (src/lib/database-name.ts): current_database(), pg_database — pgjdbc's
|
|
74
79
|
* getCatalogs and every TABLE_CAT, DBeaver's navigator — once said the sidecar's own `postgres`. The current
|
|
75
80
|
* database's row is renamed (ALTER DATABASE refuses the current one; the catalog row does not), after moving aside any
|
|
76
81
|
* OTHER database already holding the name — PGlite's `template0` / `template1` — since pg_database's names are unique
|
|
77
|
-
* (#484's review: a
|
|
82
|
+
* (#484's review: a name of `template1` failed on pg_database_datname_index and the catalog never started).
|
|
78
83
|
*/
|
|
79
84
|
export function databaseNameSql(name) {
|
|
80
85
|
const q = `'${name.replaceAll("'", "''")}'`;
|
|
@@ -163,7 +168,42 @@ export function catalogDdl(schema) {
|
|
|
163
168
|
statements.push(`COMMENT ON COLUMN ${qualified(table)}.${ident(c.name)} IS ${literal(parts.join("; "))};`);
|
|
164
169
|
}
|
|
165
170
|
}
|
|
166
|
-
return [...statements, ...constraints];
|
|
171
|
+
return [...statements, ...constraints, ...sourceTypeNames(schema)];
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* THE NAME OF A TYPE BY THE OID THE WIRE SENDS FOR IT. A column of a type the source made (an enum, a citext) is
|
|
175
|
+
* sent with the SOURCE's OID (src/duck/plan.ts `userTypes`: what the source's own clients cached), which in this
|
|
176
|
+
* catalog means another type or none (both count from 16384): `format_type(<that oid>, …)` answered `???`, or another
|
|
177
|
+
* table's row type. Two readers ask by two OID spaces, and one OID can be in both:
|
|
178
|
+
* - `public.mason_wire_type(oid, integer)` names an OID the WIRE sent: a source OID as its type here, every other as
|
|
179
|
+
* Postgres does. `mason mcp` names a RowDescription's columns by it; this map is the one place a wire OID is named;
|
|
180
|
+
* - the catalog's `format_type(oid, integer)` — the `version()` override's way, search_path listing pg_catalog after
|
|
181
|
+
* public — keeps the catalog's meaning for an OID the catalog has (a reader of pg_attribute.atttypid asks by those)
|
|
182
|
+
* and names any other by the wire's map. `pg_catalog.format_type`, written out, is Postgres's own.
|
|
183
|
+
*/
|
|
184
|
+
export function sourceTypeNames(schema) {
|
|
185
|
+
const names = new Map();
|
|
186
|
+
for (const t of schema.tables) {
|
|
187
|
+
if (t.rowTypeOid !== undefined)
|
|
188
|
+
names.set(t.rowTypeOid, qualified(t));
|
|
189
|
+
for (const c of t.columns) {
|
|
190
|
+
if (c.typeOid === undefined || c.spelled === undefined)
|
|
191
|
+
continue;
|
|
192
|
+
// an array column's own OID is its array type's; an element's array type is the element's spelling with []
|
|
193
|
+
names.set(c.typeOid, c.spelled);
|
|
194
|
+
if (c.typeArrayOid !== undefined && !c.spelled.endsWith("[]"))
|
|
195
|
+
names.set(c.typeArrayOid, `${c.spelled}[]`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// with no type the source made, every OID the wire sends is Postgres's: the wire's name is format_type's
|
|
199
|
+
if (names.size === 0)
|
|
200
|
+
return [`CREATE OR REPLACE FUNCTION public.mason_wire_type(oid, integer) RETURNS text LANGUAGE sql STABLE AS $$ SELECT pg_catalog.format_type($1, $2) $$;`];
|
|
201
|
+
const values = [...names].map(([oid, spelled]) => `(${oid}::oid, ${literal(spelled)})`).join(", ");
|
|
202
|
+
return [`CREATE OR REPLACE FUNCTION public.mason_wire_type(oid, integer) RETURNS text LANGUAGE sql STABLE AS $$
|
|
203
|
+
SELECT coalesce((SELECT pg_catalog.format_type(pg_catalog.to_regtype(v.spelled), $2) FROM (VALUES ${values}) AS v(source, spelled) WHERE v.source = $1),
|
|
204
|
+
pg_catalog.format_type($1, $2)) $$;`,
|
|
205
|
+
`CREATE OR REPLACE FUNCTION public.format_type(oid, integer) RETURNS text LANGUAGE sql STABLE AS $$
|
|
206
|
+
SELECT CASE WHEN EXISTS (SELECT FROM pg_catalog.pg_type t WHERE t.oid = $1) THEN pg_catalog.format_type($1, $2) ELSE public.mason_wire_type($1, $2) END $$;`];
|
|
167
207
|
}
|
|
168
208
|
/**
|
|
169
209
|
* The source's column defaults, one statement each, run after the schema's DDL by
|
|
@@ -199,15 +239,189 @@ export function catalogDefaults(schema) {
|
|
|
199
239
|
* The source's views, one statement each beside `catalogDefaults`'s: a view reading a table
|
|
200
240
|
* Mason has not copied (or a function the catalog lacks) is said in the log and left out. Each definition qualifies
|
|
201
241
|
* every name outside pg_catalog (src/cli/preflight.ts VIEWS_SQL), so it reads the same tables here; a view over a view
|
|
202
|
-
* is created once the other is (the worker runs these in passes).
|
|
242
|
+
* is created once the other is (the worker runs these in passes). Every role may SEE every view (`catalogRoles`: the
|
|
243
|
+
* grant is in the view's own statement, since the view must exist first): reading one is held to the tables it reads.
|
|
203
244
|
*/
|
|
204
|
-
export function catalogViews(schema) {
|
|
245
|
+
export function catalogViews(schema, roles = new Map()) {
|
|
205
246
|
return (schema.views ?? []).map((v) => {
|
|
206
247
|
const name = `${ident(v.schema)}.${ident(v.name)}`;
|
|
207
248
|
const comment = v.description ? ` COMMENT ON VIEW ${name} IS ${literal(v.description)};` : "";
|
|
208
|
-
|
|
249
|
+
// every role of the file may see a view; the catalog's role of a user held to nothing sees none (as a login with no
|
|
250
|
+
// grant on Postgres)
|
|
251
|
+
const granted = [...roles.keys()].filter((r) => r !== NO_ROLE_CATALOG).map((r) => ` GRANT USAGE ON SCHEMA ${ident(v.schema)} TO ${ident(r)}; GRANT SELECT ON ${name} TO ${ident(r)};`).join("");
|
|
252
|
+
return `CREATE SCHEMA IF NOT EXISTS ${ident(v.schema)}; CREATE VIEW ${name} AS ${v.definition.replace(/;\s*$/, "")};${comment}${granted}`;
|
|
209
253
|
});
|
|
210
254
|
}
|
|
255
|
+
/**
|
|
256
|
+
* THE CATALOG'S ROLES MIRROR THE ROLES FILE (GitHub issue 30; src/cli/roles.ts, src/duck/grants.ts): one Postgres role
|
|
257
|
+
* per Mason role, NOLOGIN, with `GRANT SELECT` on each table the role reads whole and `GRANT SELECT (columns)` on
|
|
258
|
+
* each it reads in part, and USAGE on every schema the copied tables are in. A session with a role runs its catalog
|
|
259
|
+
* statements under `SET ROLE` (`asRole`), so `information_schema.tables` / `.columns`, `has_table_privilege` and
|
|
260
|
+
* `has_column_privilege` answer for the role as Postgres's own do — and `mason mcp`'s `tables` and `describe`, which
|
|
261
|
+
* ask them, list a role what it may read. `pg_catalog` itself (pg_class, pg_attribute) does not filter by privilege
|
|
262
|
+
* on Postgres either; it still lists every synced column, said in docs/POSTGRES-API.md. A table the role names that the
|
|
263
|
+
* schema does not hold yet (added, not copied) is granted at the rebuild its copy brings; a column the copy lacks is
|
|
264
|
+
* left out (the roles file was held to the copy already, src/cli/roles.ts). Rebuilt with the schema: the server
|
|
265
|
+
* replaces the catalog when the roles move, as it does when the tables do. **`set_config` is revoked from PUBLIC
|
|
266
|
+
* while roles exist** (the review of #35): the catalog's one connection is the superuser's with `SET ROLE` on top, so
|
|
267
|
+
* inside one statement `set_config('role', 'none', false)` was a permitted RESET ROLE and the statement's later
|
|
268
|
+
* targets ran as the superuser (`information_schema` listed every hidden column's name); revoked, a role's session is
|
|
269
|
+
* refused it (42501 `permission denied for function set_config`) and the superuser keeps it. A role of `tables: all`
|
|
270
|
+
* (stage 1b) is resolved to a whole-table grant on every copied table (src/cli/roles.ts resolveRoles), so it is written
|
|
271
|
+
* here as per-table `GRANT SELECT`s, never `GRANT SELECT ON ALL TABLES IN SCHEMA`: the catalog is rebuilt from the
|
|
272
|
+
* resolved grants whenever the copied tables move, so a table added later is granted at that rebuild. The file's
|
|
273
|
+
* `default:` role is one of these roles: a user with no role of its own runs its catalog requests under it; under a
|
|
274
|
+
* file with no default it runs them as `NO_ROLE_CATALOG` (src/duck/grants.ts catalogRolesOf), a role with no grant.
|
|
275
|
+
*/
|
|
276
|
+
export function catalogRoles(schema, roles) {
|
|
277
|
+
const out = [];
|
|
278
|
+
if (roles.size === 0)
|
|
279
|
+
return out;
|
|
280
|
+
out.push("REVOKE EXECUTE ON FUNCTION pg_catalog.set_config(text, text, boolean) FROM PUBLIC;");
|
|
281
|
+
const schemas = [...new Set(schema.tables.map((t) => t.schema ?? "public"))];
|
|
282
|
+
const byName = new Map(schema.tables.map((t) => [`${t.schema ?? "public"}.${t.name}`, t]));
|
|
283
|
+
for (const r of roles.values()) {
|
|
284
|
+
const role = ident(r.name);
|
|
285
|
+
out.push(`CREATE ROLE ${role} NOLOGIN;`);
|
|
286
|
+
for (const s of schemas)
|
|
287
|
+
out.push(`GRANT USAGE ON SCHEMA ${ident(s)} TO ${role};`);
|
|
288
|
+
for (const g of r.tables.values()) {
|
|
289
|
+
const t = byName.get(g.table);
|
|
290
|
+
if (t === undefined)
|
|
291
|
+
continue;
|
|
292
|
+
if (g.all) {
|
|
293
|
+
out.push(`GRANT SELECT ON ${qualified(t)} TO ${role};`);
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
const columns = g.columns.filter((c) => t.columns.some((x) => x.name === c));
|
|
297
|
+
if (columns.length > 0)
|
|
298
|
+
out.push(`GRANT SELECT (${columns.map(ident).join(", ")}) ON ${qualified(t)} TO ${role};`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return out;
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* THE ROLES' ROW FILTERS AS THE CATALOG'S OWN ROW SECURITY (src/duck/row-filter.ts): every copied table some role filters
|
|
305
|
+
* gets `ENABLE ROW LEVEL SECURITY` and one permissive SELECT policy per role that reads it — its `where:` with each
|
|
306
|
+
* attribute read as `current_setting('mason.attr.<name>')::<type>` (a list `string_to_array(…, ',')::<type>`), the type
|
|
307
|
+
* Postgres inferred for it (`filterTypes`, carried on the roles as `filterTypes`), each request carrying the session's
|
|
308
|
+
* attributes as settings (`asSession`); a `through:` as `EXISTS (SELECT 1 FROM <parent> …)`, the parent's own policy
|
|
309
|
+
* applying inside; `true` for a role that reads it unfiltered (row security with no policy hides every row). So
|
|
310
|
+
* `pg_policies`, `relrowsecurity` and the catalog's own analysis see what Postgres would. The catalog holds no row: the
|
|
311
|
+
* rows Mason answers are filtered on the copy (src/duck/plan.ts), never here. A filter whose types are not known (its
|
|
312
|
+
* check failed: the roles' problem refuses every user held to them) gets no policy. Idempotent: each policy dropped first.
|
|
313
|
+
*/
|
|
314
|
+
export function catalogPolicies(schema, roles) {
|
|
315
|
+
const filtered = new Set([...roles.values()].flatMap((g) => [...(g.filters?.keys() ?? [])]));
|
|
316
|
+
const out = [];
|
|
317
|
+
for (const t of schema.tables) {
|
|
318
|
+
const name = `${t.schema ?? "public"}.${t.name}`;
|
|
319
|
+
if (!filtered.has(name))
|
|
320
|
+
continue;
|
|
321
|
+
out.push(`ALTER TABLE ${qualified(t)} ENABLE ROW LEVEL SECURITY`);
|
|
322
|
+
for (const g of roles.values()) {
|
|
323
|
+
if (!g.tables.has(name))
|
|
324
|
+
continue;
|
|
325
|
+
const f = g.filters?.get(name);
|
|
326
|
+
let using;
|
|
327
|
+
if (f === undefined)
|
|
328
|
+
using = "true";
|
|
329
|
+
else if (f.through !== undefined)
|
|
330
|
+
using = `EXISTS (SELECT 1 FROM ${tableSql(f.through.parent)} AS __mason_parent WHERE __mason_parent.${ident(f.through.parentColumn)} = ${qualified(t)}.${ident(f.through.column)})`;
|
|
331
|
+
else {
|
|
332
|
+
const types = g.filterTypes?.get(name);
|
|
333
|
+
if (types === undefined || f.where === undefined)
|
|
334
|
+
continue;
|
|
335
|
+
const shapes = g.attributes ?? new Map();
|
|
336
|
+
using = renderWhere(f.where.parts, (a) => {
|
|
337
|
+
const read = `current_setting('mason.attr.${a}')`;
|
|
338
|
+
return shapes.get(a) === "list" ? `string_to_array(${read}, ',')::${types.get(a) ?? "text[]"}` : `${read}::${types.get(a) ?? "text"}`;
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
out.push(`DROP POLICY IF EXISTS ${ident(`mason_${g.name}`)} ON ${qualified(t)}`);
|
|
342
|
+
out.push(`CREATE POLICY ${ident(`mason_${g.name}`)} ON ${qualified(t)} AS PERMISSIVE FOR SELECT TO ${ident(g.name)} USING (${using})`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return out;
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* A SESSION'S CATALOG REQUEST AS ITS ROLE: `SET ROLE` before the request's frames and `RESET ROLE` after, in ONE
|
|
349
|
+
* message — the catalog runs one request at a time (`PgCatalog`'s chain) and a message whole, so no other session's
|
|
350
|
+
* request runs under this role, and none of this session's runs without it. A Sync is added before the RESET where
|
|
351
|
+
* the request has none: after an error Postgres ignores every message until a Sync (ignore_till_sync), the RESET
|
|
352
|
+
* included, and a role left set would be every session's.
|
|
353
|
+
*/
|
|
354
|
+
export function asRole(message, role) {
|
|
355
|
+
const own = Buffer.from(message.buffer, message.byteOffset, message.byteLength);
|
|
356
|
+
const synced = splitFrames(own).some((f) => f.type === "S");
|
|
357
|
+
const set = `SET ROLE ${ident(role)}`;
|
|
358
|
+
return Buffer.concat([frame("Q", cstr(set)), own, ...(synced ? [] : [frame("S")]), frame("Q", cstr("RESET ROLE"))]);
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* The reply to an `asRole` message, less the SET's and the RESET's own replies (each a CommandComplete and a
|
|
362
|
+
* ReadyForQuery; the Sync added by `asRole` leaves its ReadyForQuery, which every caller drops). `setRoleRefused`: the
|
|
363
|
+
* SET ROLE's ErrorResponse, when the catalog has no such role (its roles are a rebuild behind the file).
|
|
364
|
+
*/
|
|
365
|
+
export function unwrapRole(frames) {
|
|
366
|
+
const first = frames[0];
|
|
367
|
+
if (first === undefined)
|
|
368
|
+
return { frames: [], setRoleRefused: null };
|
|
369
|
+
if (first.type === "E")
|
|
370
|
+
return { frames: [], setRoleRefused: first.frame };
|
|
371
|
+
// the SET's CommandComplete and ReadyForQuery first; the RESET's CommandComplete and ReadyForQuery last
|
|
372
|
+
const inner = frames.slice(first.type === "C" && frames[1]?.type === "Z" ? 2 : 0);
|
|
373
|
+
const n = inner.length;
|
|
374
|
+
const end = n >= 2 && inner[n - 1].type === "Z" && inner[n - 2].type === "C" ? n - 2 : n;
|
|
375
|
+
return { frames: inner.slice(0, end), setRoleRefused: null };
|
|
376
|
+
}
|
|
377
|
+
/** the placeholder parameter a session's settings ride in, for one request of its (`asSession`): named anew by each
|
|
378
|
+
* process, so no name a tool writes is it — `current_setting('mason.session')` answered the carrier's JSON where
|
|
379
|
+
* Postgres (and `SHOW`, and the same beside a copied table) answers 42704 (a review finding of #46). A placeholder
|
|
380
|
+
* stays defined once SET, so the name, not a RESET, is what keeps it out of reach. */
|
|
381
|
+
export const SESSION_SETTINGS = `mason_${randomBytes(8).toString("hex")}.session`;
|
|
382
|
+
/**
|
|
383
|
+
* THE CATALOG'S `current_setting(name[, missing_ok])` IS THE SESSION'S (GitHub issue 40: after `SET TimeZone` it answered
|
|
384
|
+
* the catalog's UTC, where `SHOW TimeZone` answered the session's): the value the session holds under that name, from
|
|
385
|
+
* the request's `SESSION_SETTINGS` (`asSession`: every parameter the listener's `sessionSetting` answers), else
|
|
386
|
+
* Postgres's own (`block_size`; its 42704, or NULL under missing_ok, for a name nobody knows). The `version()`
|
|
387
|
+
* override's way: search_path lists pg_catalog after public, so a statement reading no table and calling it is
|
|
388
|
+
* forwarded as ever; one that writes `pg_catalog.current_setting` is spliced to this one (server.ts
|
|
389
|
+
* userSpliced: Postgres's built-in runs its C function by OID, so its catalog row cannot be replaced). Plain
|
|
390
|
+
* SQL (plpgsql would load its library, and SHOW ALL list its `plpgsql.*`); STRICT, as Postgres's.
|
|
391
|
+
*/
|
|
392
|
+
export function currentSettingSql() {
|
|
393
|
+
const held = `nullif(pg_catalog.current_setting('${SESSION_SETTINGS}', true), '')::jsonb ->> lower($1)`;
|
|
394
|
+
return [
|
|
395
|
+
`CREATE OR REPLACE FUNCTION public.current_setting(text) RETURNS text LANGUAGE sql STABLE STRICT AS $$ SELECT coalesce(${held}, pg_catalog.current_setting($1)) $$`,
|
|
396
|
+
`CREATE OR REPLACE FUNCTION public.current_setting(text, boolean) RETURNS text LANGUAGE sql STABLE STRICT AS $$ SELECT coalesce(${held}, pg_catalog.current_setting($1, $2)) $$`,
|
|
397
|
+
];
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* A SESSION'S CATALOG REQUEST WITH ITS SETTINGS: `SET <SESSION_SETTINGS> = '<its settings as JSON>'` before the request's
|
|
401
|
+
* frames and `RESET` after, in ONE message, as `asRole` wraps a role (around it, when the session has one) — the catalog
|
|
402
|
+
* runs one request at a time and a message whole, so no other session's request reads these settings, and none of this
|
|
403
|
+
* session's runs without them. A Sync is added before the RESET where the request has none (ignore_till_sync: a
|
|
404
|
+
* setting left set would be every session's). `settings` are the listener's `sessionSetting`, never read here. The
|
|
405
|
+
* catalog's search_path is pinned in the same SET (`public, pg_catalog`): the overrides of `current_setting` and
|
|
406
|
+
* `version()` win only while public comes first, and the request must never depend on what the catalog was left with.
|
|
407
|
+
*/
|
|
408
|
+
export function asSession(message, settings) {
|
|
409
|
+
const own = Buffer.from(message.buffer, message.byteOffset, message.byteLength);
|
|
410
|
+
const frames = splitFrames(own);
|
|
411
|
+
const synced = frames.some((f) => f.type === "S") || frames.at(-1)?.type === "Q";
|
|
412
|
+
const json = JSON.stringify(Object.fromEntries(settings));
|
|
413
|
+
return Buffer.concat([frame("Q", cstr(`SET search_path = public, pg_catalog; SET ${SESSION_SETTINGS} = ${quoteLiteral(json)}`)), own, ...(synced ? [] : [frame("S")]), frame("Q", cstr(`RESET ${SESSION_SETTINGS}`))]);
|
|
414
|
+
}
|
|
415
|
+
/** The reply to an `asSession` message, less the SETs' and the RESET's own replies (two CommandCompletes and a
|
|
416
|
+
* ReadyForQuery first, a CommandComplete and a ReadyForQuery last). A SET the catalog refused is a Mason bug, said. */
|
|
417
|
+
export function unwrapSession(frames) {
|
|
418
|
+
const ok = frames[0]?.type === "C" && frames[1]?.type === "C" && frames[2]?.type === "Z";
|
|
419
|
+
const n = frames.length;
|
|
420
|
+
if (!ok || n < 5 || frames[n - 1].type !== "Z" || frames[n - 2].type !== "C") {
|
|
421
|
+
throw new Error(`the catalog Postgres did not take the session's settings around a request (${frames.map((f) => f.type).join(", ")}): report it`);
|
|
422
|
+
}
|
|
423
|
+
return frames.slice(3, n - 2);
|
|
424
|
+
}
|
|
211
425
|
/**
|
|
212
426
|
* WHAT A LEFT-OUT DEFAULT OR VIEW IS SAID AS, in one line (the test server's log printed every left-out source
|
|
213
427
|
* view's whole definition at each start of the instance — 7 multi-line entries per start, most of a 190 KB log, their
|
|
@@ -222,7 +436,7 @@ export function leftOutLine(r) {
|
|
|
222
436
|
return `catalog: a source default left out (${r.message}): ${r.statement}`;
|
|
223
437
|
const missing = /^relation "([^"]+)" does not exist$/.exec(r.message)?.[1];
|
|
224
438
|
const name = `${unquote(view[1])}.${unquote(view[2])}`;
|
|
225
|
-
return missing !== undefined ? `catalog: view ${name} left out: it reads ${missing}, which is not
|
|
439
|
+
return missing !== undefined ? `catalog: view ${name} left out: it reads ${missing}, which is not synced` : `catalog: view ${name} left out (${r.message})`;
|
|
226
440
|
}
|
|
227
441
|
const errorMessage = (e) => (e instanceof Error ? e.message : String(e));
|
|
228
442
|
/** One worker thread holding one PGlite: requests one at a time (PgCatalog's chain), each with a deadline. */
|
|
@@ -268,7 +482,8 @@ class CatalogWorker {
|
|
|
268
482
|
return new Promise((resolve, reject) => {
|
|
269
483
|
const id = this.#next++;
|
|
270
484
|
const timer = setTimeout(() => {
|
|
271
|
-
|
|
485
|
+
// the message reaches the client on the wire and the request log: Mason's words, never the catalog's engine's name
|
|
486
|
+
this.#die(new Error(`the catalog did not answer ${what} within ${ms / 1000} s, so Mason started it again (the next statement is answered as usual; MASON_PG_CATALOG_TIMEOUT_MS raises the deadline)`));
|
|
272
487
|
void this.#worker.terminate();
|
|
273
488
|
}, ms);
|
|
274
489
|
timer.unref();
|
|
@@ -283,6 +498,7 @@ class CatalogWorker {
|
|
|
283
498
|
}
|
|
284
499
|
export class PgCatalog {
|
|
285
500
|
#schema;
|
|
501
|
+
#roles;
|
|
286
502
|
#idleMs;
|
|
287
503
|
#statementMs;
|
|
288
504
|
#log;
|
|
@@ -296,8 +512,12 @@ export class PgCatalog {
|
|
|
296
512
|
#chain = Promise.resolve();
|
|
297
513
|
#idleTimer = null;
|
|
298
514
|
#lastUsed = 0;
|
|
515
|
+
/** the roles' row security policies (`catalogPolicies`), written once the listener checked the filters: run on the
|
|
516
|
+
* instance up now and at every start after, until the schema or the roles are replaced */
|
|
517
|
+
#policies = [];
|
|
299
518
|
constructor(schema, opts = {}) {
|
|
300
519
|
this.#schema = schema;
|
|
520
|
+
this.#roles = opts.roles ?? new Map();
|
|
301
521
|
this.#idleMs = opts.idleMs ?? DEFAULT_IDLE_MS;
|
|
302
522
|
this.#statementMs = opts.statementMs ?? DEFAULT_STATEMENT_MS;
|
|
303
523
|
this.#log = opts.log ?? ((line) => console.error(line));
|
|
@@ -332,13 +552,36 @@ export class PgCatalog {
|
|
|
332
552
|
stop() {
|
|
333
553
|
return this.#enqueue(() => this.#close("stopped"));
|
|
334
554
|
}
|
|
335
|
-
/** Swaps in a new schema (after an apply)
|
|
336
|
-
* DDL. Resolves once the old instance is gone; a caller need not wait for it. */
|
|
337
|
-
replace(schema) {
|
|
555
|
+
/** Swaps in a new schema (after an apply), and the roles with it (the roles file moved): the running instance is
|
|
556
|
+
* stopped and the next `run` starts with the new DDL. Resolves once the old instance is gone; a caller need not wait for it. */
|
|
557
|
+
replace(schema, roles = this.#roles) {
|
|
338
558
|
this.#schema = schema;
|
|
559
|
+
this.#roles = roles;
|
|
560
|
+
this.#policies = [];
|
|
339
561
|
this.#said.clear();
|
|
340
562
|
return this.#enqueue(() => this.#close("replaced"));
|
|
341
563
|
}
|
|
564
|
+
/**
|
|
565
|
+
* THE ROLES' ROW SECURITY POLICIES (`catalogPolicies`), written now (the instance started where needed) and at every
|
|
566
|
+
* start after, until the next `replace`. A statement the catalog refuses is said, never fatal: the rows are the copy's.
|
|
567
|
+
*/
|
|
568
|
+
policies(ddl) {
|
|
569
|
+
return this.#enqueue(async () => {
|
|
570
|
+
this.#policies = ddl;
|
|
571
|
+
if (ddl.length === 0)
|
|
572
|
+
return;
|
|
573
|
+
const db = await this.#ensureStarted();
|
|
574
|
+
for (const sql of ddl) {
|
|
575
|
+
try {
|
|
576
|
+
await this.#ask(db, { op: "query", sql }, "a row security policy");
|
|
577
|
+
}
|
|
578
|
+
catch (e) {
|
|
579
|
+
this.#log(`catalog: a row security policy left out (${errorMessage(e)}): ${sql}`);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
this.#touch();
|
|
583
|
+
});
|
|
584
|
+
}
|
|
342
585
|
#enqueue(fn) {
|
|
343
586
|
const result = this.#chain.then(fn);
|
|
344
587
|
// the chain only ORDERS calls: a rejection reaches the caller through `result`, and the next call must still run
|
|
@@ -366,11 +609,12 @@ export class PgCatalog {
|
|
|
366
609
|
if (this.#db !== null && this.#db.alive)
|
|
367
610
|
return this.#db;
|
|
368
611
|
this.#db = null;
|
|
369
|
-
const schema = this.#schema;
|
|
612
|
+
const schema = this.#schema, roles = this.#roles;
|
|
370
613
|
const t0 = Date.now();
|
|
371
614
|
const db = new CatalogWorker();
|
|
372
615
|
try {
|
|
373
|
-
const started = await this.#ask(db, { op: "init", statements: ["SET TimeZone = 'UTC'", ...catalogDdl(schema), ...
|
|
616
|
+
const started = await this.#ask(db, { op: "init", statements: ["SET TimeZone = 'UTC'", ...catalogDdl(schema), ...catalogRoles(schema, roles), ...this.#setup],
|
|
617
|
+
optional: [...catalogDefaults(schema), ...catalogViews(schema, roles), ...this.#policies] }, "its start");
|
|
374
618
|
// each left-out default or view: one line, once per schema (the instance starts again after every idle stop)
|
|
375
619
|
for (const r of started.refused ?? []) {
|
|
376
620
|
const line = leftOutLine(r);
|
|
@@ -388,7 +632,7 @@ export class PgCatalog {
|
|
|
388
632
|
throw new Error(`PGlite could not start: ${errorMessage(e)}`, { cause: e });
|
|
389
633
|
}
|
|
390
634
|
this.#db = db;
|
|
391
|
-
this.#log(`catalog: PGlite started in ${Date.now() - t0} ms with ${schema.tables.length} table(s)`);
|
|
635
|
+
this.#log(`catalog: PGlite started in ${Date.now() - t0} ms with ${schema.tables.length} table(s)${roles.size > 0 ? ` and ${roles.size} role(s)` : ""}`);
|
|
392
636
|
this.#touch();
|
|
393
637
|
return db;
|
|
394
638
|
}
|
|
@@ -82,7 +82,9 @@ export function fieldOf(name, type, format) {
|
|
|
82
82
|
export class EncodeError extends Error {
|
|
83
83
|
/** the SQLSTATE Postgres raises for the same value (a timestamp past its range is 22008), else XX000: Mason's gap */
|
|
84
84
|
code;
|
|
85
|
-
|
|
85
|
+
/** the registry entry that refused the value (src/duck/gaps.ts): the request log's `gap`; null for any other error */
|
|
86
|
+
gap;
|
|
87
|
+
constructor(message, code = "XX000", gap = null) { super(message); this.code = code; this.gap = gap; }
|
|
86
88
|
}
|
|
87
89
|
const refuse = (type, value, why) => {
|
|
88
90
|
throw new EncodeError(`cannot send ${JSON.stringify(value)} as ${type}: ${why}`);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { parseSql, PostgresSyntaxError } from "./parser.js";
|
|
2
2
|
import { Unsupported } from "./translate.js";
|
|
3
|
+
import { gapMark } from "../../duck/gaps.js";
|
|
3
4
|
const bodyOf = (n) => Object.values(n)[0];
|
|
4
5
|
/** Postgres's defGetBoolean: no value is true; true / false / on / off / 1 / 0 */
|
|
5
6
|
function booleanOf(name, arg, at) {
|
|
@@ -84,7 +85,7 @@ export function explainOptions(options) {
|
|
|
84
85
|
}
|
|
85
86
|
case "generic_plan":
|
|
86
87
|
if (booleanOf(name, b.arg, at))
|
|
87
|
-
throw new Unsupported(`EXPLAIN (GENERIC_PLAN) is not supported by Mason (
|
|
88
|
+
throw new Unsupported(`EXPLAIN (GENERIC_PLAN) is not supported by Mason (${gapMark("explain-option")})`, at, "0A000", "bind the parameters' values and EXPLAIN that: Mason plans the statement with them", "explain-option");
|
|
88
89
|
break;
|
|
89
90
|
case "format": {
|
|
90
91
|
const v = stringOf(name, b.arg, at);
|
|
@@ -93,7 +94,7 @@ export function explainOptions(options) {
|
|
|
93
94
|
break;
|
|
94
95
|
}
|
|
95
96
|
if (v === "xml" || v === "yaml")
|
|
96
|
-
throw new Unsupported(`EXPLAIN (FORMAT ${v.toUpperCase()}) is not supported by Mason (
|
|
97
|
+
throw new Unsupported(`EXPLAIN (FORMAT ${v.toUpperCase()}) is not supported by Mason (${gapMark("explain-option")})`, at, "0A000", "write FORMAT TEXT or FORMAT JSON", "explain-option");
|
|
97
98
|
throw new Unsupported(`unrecognized value for EXPLAIN option "format": "${v}"`, at, "22023");
|
|
98
99
|
}
|
|
99
100
|
default: throw new Unsupported(`unrecognized EXPLAIN option "${name}"`, at, "42601");
|
|
@@ -75,12 +75,10 @@ class BodyReader {
|
|
|
75
75
|
rest() { return Buffer.from(this.#buf.subarray(this.#off)); }
|
|
76
76
|
get done() { return this.#off >= this.#buf.length; }
|
|
77
77
|
}
|
|
78
|
-
/** The
|
|
79
|
-
*
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
function frameLength(buf, headerLength, limit) {
|
|
83
|
-
const len = buf.readInt32BE(headerLength - 4);
|
|
78
|
+
/** The length field of the frame at `off` in `buf` (type byte excluded, the field itself included), checked against
|
|
79
|
+
* `limit`; `headerLength` is 5 with a type byte, 4 in the startup phase. */
|
|
80
|
+
function frameLength(buf, off, headerLength, limit) {
|
|
81
|
+
const len = buf.readInt32BE(off + headerLength - 4);
|
|
84
82
|
if (len < 4)
|
|
85
83
|
throw new ProtocolError(`malformed message: a length of ${len} (the length counts itself, so it is at least 4)`);
|
|
86
84
|
if (len > limit)
|
|
@@ -89,8 +87,10 @@ function frameLength(buf, headerLength, limit) {
|
|
|
89
87
|
}
|
|
90
88
|
/** Decodes what a frontend sends. Push every chunk the socket delivers; call `next()` until it returns null. */
|
|
91
89
|
export class FrontendDecoder {
|
|
92
|
-
/** the bytes
|
|
90
|
+
/** the chunks joined so far: consumed bytes before `#off`, then the frames not yet read, the last perhaps partial */
|
|
93
91
|
#buf = Buffer.alloc(0);
|
|
92
|
+
/** where the next frame starts in `#buf`: a frame consumed moves this, never the bytes behind it (BackendDecoder) */
|
|
93
|
+
#off = 0;
|
|
94
94
|
/** chunks received since, concatenated ONCE, when the frame they complete is whole (never per chunk: a frame in
|
|
95
95
|
* 64 KiB pieces re-copied everything received so far on every piece — review of #480) */
|
|
96
96
|
#pending = [];
|
|
@@ -106,13 +106,16 @@ export class FrontendDecoder {
|
|
|
106
106
|
this.#pending.push(Buffer.from(chunk));
|
|
107
107
|
this.#pendingLength += chunk.length;
|
|
108
108
|
}
|
|
109
|
-
/** Makes `#buf` hold at least `need` bytes when that many have arrived, moving the pending chunks in with
|
|
109
|
+
/** Makes `#buf` hold at least `need` bytes past `#off` when that many have arrived, moving the pending chunks in with
|
|
110
|
+
* one copy, which leaves the consumed bytes behind. */
|
|
110
111
|
#assemble(need) {
|
|
111
|
-
|
|
112
|
+
const held = this.#buf.length - this.#off;
|
|
113
|
+
if (held >= need)
|
|
112
114
|
return true;
|
|
113
|
-
if (
|
|
115
|
+
if (held + this.#pendingLength < need)
|
|
114
116
|
return false;
|
|
115
|
-
this.#buf = Buffer.concat([this.#buf, ...this.#pending]);
|
|
117
|
+
this.#buf = Buffer.concat([this.#buf.subarray(this.#off), ...this.#pending]);
|
|
118
|
+
this.#off = 0;
|
|
116
119
|
this.#pending = [];
|
|
117
120
|
this.#pendingLength = 0;
|
|
118
121
|
return true;
|
|
@@ -126,12 +129,18 @@ export class FrontendDecoder {
|
|
|
126
129
|
#whole(headerLength) {
|
|
127
130
|
if (!this.#assemble(headerLength))
|
|
128
131
|
return null;
|
|
129
|
-
const len = frameLength(this.#buf, headerLength, this.#startup ? Math.min(this.maxLength, MAX_STARTUP_LENGTH) : this.maxLength);
|
|
132
|
+
const len = frameLength(this.#buf, this.#off, headerLength, this.#startup ? Math.min(this.maxLength, MAX_STARTUP_LENGTH) : this.maxLength);
|
|
130
133
|
return this.#assemble(headerLength - 4 + len) ? len : null;
|
|
131
134
|
}
|
|
135
|
+
/** The frame's body, a view of `#buf` that lives as long as its BodyReader: every value a reader returns is a copy. */
|
|
132
136
|
#take(headerLength, len) {
|
|
133
|
-
const body = this.#buf.subarray(headerLength, headerLength - 4 + len);
|
|
134
|
-
this.#
|
|
137
|
+
const body = this.#buf.subarray(this.#off + headerLength, this.#off + headerLength - 4 + len);
|
|
138
|
+
this.#off += headerLength - 4 + len;
|
|
139
|
+
// wholly consumed, it is let go: a connection idle on a frame boundary holds no bytes of what it last sent
|
|
140
|
+
if (this.#off === this.#buf.length) {
|
|
141
|
+
this.#buf = Buffer.alloc(0);
|
|
142
|
+
this.#off = 0;
|
|
143
|
+
}
|
|
135
144
|
return body;
|
|
136
145
|
}
|
|
137
146
|
#nextStartup() {
|
|
@@ -165,7 +174,7 @@ export class FrontendDecoder {
|
|
|
165
174
|
const len = this.#whole(5);
|
|
166
175
|
if (len === null)
|
|
167
176
|
return null;
|
|
168
|
-
const code = String.fromCharCode(this.#buf[
|
|
177
|
+
const code = String.fromCharCode(this.#buf[this.#off]);
|
|
169
178
|
const r = new BodyReader(this.#take(5, len), `'${code}'`);
|
|
170
179
|
switch (code) {
|
|
171
180
|
case "p": return { type: "Password", password: r.cstring() };
|
|
@@ -353,22 +362,29 @@ function decodeBackendBody(code, body) {
|
|
|
353
362
|
/** Decodes what a backend sends, chunk by chunk, as FrontendDecoder does for the other direction. */
|
|
354
363
|
export class BackendDecoder {
|
|
355
364
|
#buf = Buffer.alloc(0);
|
|
365
|
+
/** WHERE THE NEXT FRAME STARTS IN `#buf`: `next()` moves this and `push` drops what is behind it. Copying the rest off
|
|
366
|
+
* after every message was quadratic in a reply: a 30 MB reply of 150,000 rows took about six minutes on CI (issue
|
|
367
|
+
* 77). A new buffer is always allocated, never written over, and no decoded message keeps a view of one (BodyReader
|
|
368
|
+
* copies). */
|
|
369
|
+
#off = 0;
|
|
356
370
|
push(chunk) {
|
|
357
|
-
|
|
371
|
+
const rest = this.#buf.subarray(this.#off);
|
|
372
|
+
this.#buf = rest.length === 0 ? Buffer.from(chunk) : Buffer.concat([rest, chunk]);
|
|
373
|
+
this.#off = 0;
|
|
358
374
|
}
|
|
359
375
|
next() {
|
|
360
|
-
if (this
|
|
376
|
+
if (this.pending < 5)
|
|
361
377
|
return null;
|
|
362
|
-
const len = frameLength(this.#buf, 5, MAX_MESSAGE_LENGTH);
|
|
363
|
-
if (this
|
|
378
|
+
const len = frameLength(this.#buf, this.#off, 5, MAX_MESSAGE_LENGTH);
|
|
379
|
+
if (this.pending < 1 + len)
|
|
364
380
|
return null;
|
|
365
|
-
const code = String.fromCharCode(this.#buf[
|
|
366
|
-
const body = this.#buf.subarray(5, 1 + len);
|
|
367
|
-
this.#
|
|
381
|
+
const code = String.fromCharCode(this.#buf[this.#off]);
|
|
382
|
+
const body = this.#buf.subarray(this.#off + 5, this.#off + 1 + len);
|
|
383
|
+
this.#off += 1 + len;
|
|
368
384
|
return decodeBackendBody(code, body);
|
|
369
385
|
}
|
|
370
386
|
/** bytes pushed and not yet consumed by a whole message */
|
|
371
|
-
get pending() { return this.#buf.length; }
|
|
387
|
+
get pending() { return this.#buf.length - this.#off; }
|
|
372
388
|
}
|
|
373
389
|
/** Every message of a buffer that holds whole messages only (a `execProtocolRaw` reply, a builder's output). A
|
|
374
390
|
* trailing partial message throws: the caller promised a complete reply. */
|