@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.
Files changed (119) hide show
  1. package/README.md +147 -69
  2. package/dist/src/api/error.js +3 -0
  3. package/dist/src/api/postgres/catalog.js +258 -14
  4. package/dist/src/api/postgres/encode.js +3 -1
  5. package/dist/src/api/postgres/explain.js +3 -2
  6. package/dist/src/api/postgres/protocol.js +39 -23
  7. package/dist/src/api/postgres/server.js +1290 -223
  8. package/dist/src/api/postgres/translate.js +81 -9
  9. package/dist/src/api/request-log.js +18 -5
  10. package/dist/src/cdc/pg-wire.js +144 -38
  11. package/dist/src/cdc/poll.js +5 -5
  12. package/dist/src/cli/background.js +55 -8
  13. package/dist/src/cli/cancel.js +75 -0
  14. package/dist/src/cli/command.js +5 -3
  15. package/dist/src/cli/commands.js +843 -272
  16. package/dist/src/cli/control.js +59 -0
  17. package/dist/src/cli/demo.js +158 -0
  18. package/dist/src/cli/dev.js +458 -125
  19. package/dist/src/cli/doctor.js +25 -12
  20. package/dist/src/cli/guides.js +103 -31
  21. package/dist/src/cli/init.js +159 -58
  22. package/dist/src/cli/machine.js +18 -12
  23. package/dist/src/cli/main.js +5 -5
  24. package/dist/src/cli/mcp.js +735 -0
  25. package/dist/src/cli/metrics.js +79 -0
  26. package/dist/src/cli/pg-tls.js +13 -3
  27. package/dist/src/cli/pg-url.js +4 -0
  28. package/dist/src/cli/pin.js +13 -12
  29. package/dist/src/cli/preflight.js +24 -14
  30. package/dist/src/cli/project.js +48 -7
  31. package/dist/src/cli/readiness.js +38 -4
  32. package/dist/src/cli/registry.js +57 -35
  33. package/dist/src/cli/replication-checks.js +7 -5
  34. package/dist/src/cli/replication-mode.js +105 -26
  35. package/dist/src/cli/request-filter.js +134 -0
  36. package/dist/src/cli/roles.js +584 -0
  37. package/dist/src/cli/run-server.js +498 -133
  38. package/dist/src/cli/runs.js +1 -1
  39. package/dist/src/cli/security.js +10 -8
  40. package/dist/src/cli/serve.js +147 -40
  41. package/dist/src/cli/share-run.js +286 -0
  42. package/dist/src/cli/share.js +286 -0
  43. package/dist/src/cli/source-edit.js +168 -0
  44. package/dist/src/cli/sources.js +290 -39
  45. package/dist/src/cli/supervise.js +12 -1
  46. package/dist/src/cli/sync.js +55 -25
  47. package/dist/src/cli/tunnel.js +200 -0
  48. package/dist/src/cli/ui.js +9 -6
  49. package/dist/src/cli/usage.js +128 -0
  50. package/dist/src/cli/user.js +496 -40
  51. package/dist/src/cli/version.js +9 -7
  52. package/dist/src/demo/seed.js +240 -0
  53. package/dist/src/demo/source.js +655 -0
  54. package/dist/src/duck/cancel.js +10 -1
  55. package/dist/src/duck/distinct-key.js +228 -0
  56. package/dist/src/duck/events.js +134 -0
  57. package/dist/src/duck/executor-worker.js +25 -11
  58. package/dist/src/duck/executor.js +107 -53
  59. package/dist/src/duck/explain.js +158 -29
  60. package/dist/src/duck/extensions.js +160 -0
  61. package/dist/src/duck/functions.js +73 -19
  62. package/dist/src/duck/gaps.js +25 -11
  63. package/dist/src/duck/grants.js +176 -0
  64. package/dist/src/duck/history.js +123 -0
  65. package/dist/src/duck/jsonb-order.js +175 -0
  66. package/dist/src/duck/jsonb.js +10 -8
  67. package/dist/src/duck/kept-route.js +82 -2
  68. package/dist/src/duck/kept.js +211 -44
  69. package/dist/src/duck/pgtree.js +40 -0
  70. package/dist/src/duck/plan.js +120 -30
  71. package/dist/src/duck/publication.js +6 -6
  72. package/dist/src/duck/requests.js +265 -93
  73. package/dist/src/duck/rewrite.js +1193 -73
  74. package/dist/src/duck/row-filter.js +649 -0
  75. package/dist/src/duck/store.js +271 -159
  76. package/dist/src/duck/stream.js +10 -5
  77. package/dist/src/duck/sync.js +907 -275
  78. package/dist/src/duck/types.js +6 -5
  79. package/dist/src/duck/usage.js +324 -0
  80. package/dist/src/duck/users.js +64 -15
  81. package/dist/src/duck/verified.js +9 -0
  82. package/dist/src/duck/views.js +2 -3
  83. package/dist/src/lib/database-name.js +62 -0
  84. package/dist/src/lib/dotenv.js +17 -4
  85. package/dist/src/lib/log-buffer.js +68 -0
  86. package/dist/src/lib/pg-numeric.js +5 -2
  87. package/dist/src/lib/project-version.js +23 -23
  88. package/dist/src/lib/table-sync.js +267 -0
  89. package/dist/src/lib/timezone.js +3 -2
  90. package/dist/src/web/app.css +706 -55
  91. package/dist/src/web/app.js +330 -258
  92. package/dist/src/web/charts.js +229 -0
  93. package/dist/src/web/index.html +84 -15
  94. package/dist/src/web/pages/queries.js +192 -0
  95. package/dist/src/web/pages/status.js +446 -0
  96. package/dist/src/web/pages/syncs.js +155 -0
  97. package/dist/src/web/pages/tables.js +434 -0
  98. package/dist/src/web/pages/usage.js +397 -0
  99. package/dist/src/web/pages/users.js +218 -0
  100. package/dist/src/web/shared.js +568 -0
  101. package/dist/src/web/vendor/README.md +25 -0
  102. package/dist/src/web/vendor/geist-1.7.2/Geist-Variable.woff2 +0 -0
  103. package/dist/src/web/vendor/geist-1.7.2/GeistMono-Variable.woff2 +0 -0
  104. package/dist/src/web/vendor/geist-1.7.2/LICENSE.txt +92 -0
  105. package/dist/src/web/vendor/lucide-1.56.0/LICENSE.txt +43 -0
  106. package/dist/src/web/vendor/lucide-1.56.0/chart-column.mjs +29 -0
  107. package/dist/src/web/vendor/lucide-1.56.0/layout-dashboard.mjs +29 -0
  108. package/dist/src/web/vendor/lucide-1.56.0/logs.mjs +34 -0
  109. package/dist/src/web/vendor/lucide-1.56.0/menu.mjs +28 -0
  110. package/dist/src/web/vendor/lucide-1.56.0/square-terminal.mjs +28 -0
  111. package/dist/src/web/vendor/lucide-1.56.0/table.mjs +29 -0
  112. package/dist/src/web/vendor/lucide-1.56.0/users.mjs +29 -0
  113. package/dist/src/web/vendor/lucide-1.56.0/x.mjs +27 -0
  114. package/dist/src/web/vendor/uplot-1.6.32/LICENSE.txt +21 -0
  115. package/dist/src/web/vendor/uplot-1.6.32/uPlot.esm.js +6140 -0
  116. package/dist/src/web/vendor/uplot-1.6.32/uPlot.min.css +1 -0
  117. package/dist/src/web/vendor/zilla-slab-5.3.0/LICENSE.txt +93 -0
  118. package/dist/src/web/vendor/zilla-slab-5.3.0/zilla-slab-latin-700-normal.woff2 +0 -0
  119. package/package.json +10 -3
@@ -0,0 +1,176 @@
1
+ /**
2
+ * ROLES: WHAT A USER MAY READ, HELD ON THE ANALYSED TREE (GitHub issue 30, stage 1: table and column grants per role).
3
+ * A role is an entry of the project's roles file (`mason/roles.yml`, src/cli/roles.ts): for each copied table, every
4
+ * column (`{}`), `columns:` or `exclude:`, the sources file's own words — or `tables: all`, every copied table and column.
5
+ * A `mason user` carries a role (src/duck/users.ts `MasonUser.role`); a user with none is held to the file's
6
+ * `default:` role, and reads nothing when the file names none (stage 1b: the FILE decides, never the code); with no
7
+ * roles file at all, every user reads every copied table. A token (no user) reads every copied table.
8
+ *
9
+ * ENFORCED ON THE TREE POSTGRES ANALYSED, NEVER ON SQL TEXT: Postgres puts one `RTEPERMISSIONINFO` per relation in the
10
+ * query tree it analyses (src/duck/analyze.ts), with `selectedCols` as its own bitmap of the columns the statement
11
+ * reads — a column read inside a CTE, a subquery, a join's USING, a window, a WHERE or a CASE is in it; `SELECT *` lists
12
+ * every column; `count(*)` reads none; a whole-row reference (`SELECT t FROM t`) is the bit for attnum 0, which means
13
+ * every column. This is the information Postgres's own executor checks privileges with (ExecCheckPermissions), so an
14
+ * alias, a subquery or a view cannot get around it: a view is expanded by Mason itself before the check
15
+ * (src/duck/views.ts), so the check sees the tables it reads — stricter than Postgres, where a grant on the view
16
+ * suffices, and said so in docs/POSTGRES-API.md.
17
+ *
18
+ * THE REFUSAL IS POSTGRES'S: 42501 `permission denied for table orders`, the words Postgres 18 answers whether the
19
+ * table or one of its columns is missing from the grants (its executor names the table only), so a tool handles it as
20
+ * it handles Postgres's. The DETAIL names the role and the first hidden column, the HINT what the role may read. The
21
+ * rules are Postgres's too: `count(*)` over a table is answered when the role has any column of it; `SELECT *` over a
22
+ * table the role has only part of is refused, never narrowed (a narrowed `*` would change the result's columns); a
23
+ * system column (xmin, ctid) needs the whole table.
24
+ */
25
+ import { StatementError } from "../api/postgres/server.js";
26
+ import { catalogRows } from "./analyze.js";
27
+ /** no roles file: every session reads every copied table, as before; one object, so the listener sees no change */
28
+ export const NO_ROLES = { file: null, grants: new Map(), problem: null, default: null };
29
+ /**
30
+ * THE CATALOG'S ROLE OF A USER THE FILE HOLDS TO NOTHING (no role of its own, no `default:`): a Postgres role with no
31
+ * grant, so its catalog requests answer as Postgres answers a login with none — information_schema lists none of the
32
+ * copied tables or views, every `has_*_privilege` is false, `set_config` is revoked (while any role exists; a session's
33
+ * own `set_config` never reaches the catalog, src/api/postgres/translate.ts setConfig).
34
+ * Named outside the file's namespace: `ROLE_NAME_RE` (src/cli/roles.ts) forbids a leading underscore, so no role of the
35
+ * file can ever be it.
36
+ */
37
+ export const NO_ROLE_CATALOG = "__mason_no_role";
38
+ /** The roles the catalog Postgres holds (src/api/postgres/catalog.ts catalogRoles): the file's, and — whenever a roles
39
+ * file exists — `NO_ROLE_CATALOG`, a role with no table, for a user the file holds to nothing. */
40
+ export function catalogRolesOf(live) {
41
+ if (live.file === null)
42
+ return live.grants;
43
+ return new Map([...live.grants, [NO_ROLE_CATALOG, { name: NO_ROLE_CATALOG, file: live.file, tables: new Map(), all: false }]]);
44
+ }
45
+ /** `FirstLowInvalidHeapAttributeNumber` is -7: a column's bit in `selectedCols` is its attnum + 7 */
46
+ const ATTNUM_OFFSET = 7;
47
+ /** ACL_SELECT in `requiredPerms` */
48
+ const ACL_SELECT = 2;
49
+ const isNode = (v) => v !== null && v !== undefined && typeof v === "object" && !Array.isArray(v);
50
+ /** Every RTEPERMISSIONINFO of the tree that asks SELECT, with its bitmap read as attnums. */
51
+ export function tableReads(tree) {
52
+ const out = [];
53
+ const visit = (v) => {
54
+ if (Array.isArray(v)) {
55
+ for (const x of v)
56
+ visit(x);
57
+ return;
58
+ }
59
+ if (!isNode(v))
60
+ return;
61
+ if (v._node === "RTEPERMISSIONINFO") {
62
+ const perms = Number(v.requiredPerms ?? 0);
63
+ const relid = Number(v.relid ?? 0);
64
+ if ((perms & ACL_SELECT) !== 0 && relid > 0) {
65
+ // a bitmapset prints as `(b 8 9)`: the `b` first, then each member
66
+ const bits = Array.isArray(v.selectedCols) ? v.selectedCols.filter((x) => typeof x === "string" && /^\d+$/.test(x)).map(Number) : [];
67
+ const read = { relid, attnums: [], wholeRow: false, system: false };
68
+ for (const b of bits) {
69
+ const attnum = b - ATTNUM_OFFSET;
70
+ if (attnum === 0)
71
+ read.wholeRow = true;
72
+ else if (attnum < 0)
73
+ read.system = true;
74
+ else
75
+ read.attnums.push(attnum);
76
+ }
77
+ out.push(read);
78
+ }
79
+ }
80
+ for (const [k, x] of Object.entries(v))
81
+ if (k !== "_node")
82
+ visit(x);
83
+ };
84
+ visit(tree);
85
+ return out;
86
+ }
87
+ /**
88
+ * The reads named through the catalog Postgres's own pg_class and pg_attribute (dropped columns left out, as Postgres
89
+ * leaves them): the copied tables only (relkind r) — a view is never here, since Mason expands it before the plan is
90
+ * made (src/duck/views.ts), and a catalog relation is not a copy.
91
+ */
92
+ export async function resolveReads(catalog, reads) {
93
+ if (reads.length === 0)
94
+ return [];
95
+ const relids = [...new Set(reads.map((r) => Math.trunc(r.relid)))];
96
+ const rows = await catalogRows(catalog, `SELECT c.oid::text, n.nspname, c.relname, a.attnum::text, a.attname FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace ` +
97
+ `LEFT JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped WHERE c.oid IN (${relids.join(", ")}) AND c.relkind = 'r' ` +
98
+ // the copied tables alone: pg_class and the rest of pg_catalog are relkind 'r' too, and PUBLIC's on Postgres (a review finding of #35)
99
+ `AND n.nspname NOT IN ('pg_catalog', 'information_schema') ORDER BY c.oid, a.attnum`);
100
+ const tables = new Map();
101
+ for (const [oid, schema, name, attnum, attname] of rows) {
102
+ const t = tables.get(Number(oid)) ?? { table: `${schema}.${name}`, columns: new Map() };
103
+ if (attnum !== null && attnum !== undefined && attname !== null && attname !== undefined)
104
+ t.columns.set(Number(attnum), attname);
105
+ tables.set(Number(oid), t);
106
+ }
107
+ const out = [];
108
+ for (const r of reads) {
109
+ const t = tables.get(r.relid);
110
+ if (t === undefined)
111
+ continue;
112
+ const all = [...t.columns.entries()].sort((a, b) => a[0] - b[0]).map(([, n]) => n);
113
+ const named = r.wholeRow ? all : [...r.attnums].sort((a, b) => a - b).map((n) => t.columns.get(n)).filter((n) => n !== undefined);
114
+ out.push({ table: t.table, columns: named, all, system: r.system });
115
+ }
116
+ return out;
117
+ }
118
+ /** a table as a tool writes it: its name alone in public, else schema.table (the sources file's own spelling) */
119
+ const shown = (table) => table.startsWith("public.") ? table.slice("public.".length) : table;
120
+ /** The 42501 of a read the role does not cover, Postgres's words first; null when the role covers every read. */
121
+ export function grantRefusal(reads, role) {
122
+ if (role.noRole !== undefined)
123
+ return reads.length === 0 ? null : noRoleRefusal(reads[0].table, role.file, role.noRole);
124
+ const tablesOf = () => {
125
+ if (role.all)
126
+ return "every synced table (tables: all)";
127
+ const names = [...role.tables.keys()].map(shown);
128
+ return names.length ? names.join(", ") : "none";
129
+ };
130
+ for (const r of reads) {
131
+ const g = role.tables.get(r.table);
132
+ const denied = (detail, hint) => new StatementError({ code: "42501", message: `permission denied for table ${r.table.replace(/^[^.]+\./, "")}`, detail, hint });
133
+ if (g === undefined) {
134
+ return denied(`the role ${role.name} has no grant on ${shown(r.table)}`, `the role ${role.name} reads: ${tablesOf()} (${role.file} names what each role reads; Mason gap roles)`);
135
+ }
136
+ if (g.all)
137
+ continue;
138
+ const may = `the role ${role.name} reads these columns of ${shown(r.table)}: ${g.columns.join(", ")} (${role.file} names what each role reads; Mason gap roles)`;
139
+ if (r.system)
140
+ return denied(`the role ${role.name} may read columns of ${shown(r.table)}, and a system column needs the whole table`, may);
141
+ const hidden = r.columns.find((c) => !g.columns.includes(c));
142
+ if (hidden !== undefined)
143
+ return denied(`the role ${role.name} may not read column ${hidden} of ${shown(r.table)}`, may);
144
+ // no column read (count(*)): any column of the table is enough, as Postgres rules it, and a grant names at least one
145
+ }
146
+ return null;
147
+ }
148
+ /**
149
+ * THE 42501 OF A USER WITH NO ROLE UNDER A FILE THAT NAMES NO DEFAULT (stage 1b): Postgres's message for the first
150
+ * table read, the reason in the DETAIL, and both ways out as the exact lines in the HINT — the file's `default:`, or
151
+ * the user made again with a role. `file`: the roles file as messages name it.
152
+ */
153
+ export function noRoleRefusal(table, file, who) {
154
+ return new StatementError({ code: "42501", message: `permission denied for table ${table.replace(/^[^.]+\./, "")}`,
155
+ detail: `the user "${who.user}" has no role and ${file} names no default`, hint: noRoleWayOut(who.user, file, who.example) });
156
+ }
157
+ /** The two ways out for a user with no role under a file with no default, as exact lines: ONE wording, for the
158
+ * refusal's HINT, `mason start`, `mason user list` and `mason user add`. */
159
+ export const noRoleWayOut = (user, file, example) => `write default: ${example} at the top of ${file}, or mason user remove ${user} and mason user add ${user} --role ${example}`;
160
+ /** A role of the file to name in a way out: one of `tables: all` where there is one (the role today's access is), else
161
+ * the first, or a placeholder where it names none. */
162
+ export const exampleRole = (live) => [...live.grants.values()].find((g) => g.all)?.name ?? [...live.grants.keys()][0] ?? "<a role>";
163
+ /**
164
+ * The statement's table reads held to the role: the tree's reads named through the catalog, then refused where the role
165
+ * does not cover one. Called by the plan (src/duck/plan.ts) after Postgres's analysis and the views' expansion, before
166
+ * the rewrite — so Postgres's own refusal (an unknown column) comes first, and a role's before a Mason gap.
167
+ */
168
+ export async function enforceGrants(catalog, tree, role) {
169
+ const reads = await resolveReads(catalog, tableReads(tree));
170
+ if (role !== null) {
171
+ const refused = grantRefusal(reads, role);
172
+ if (refused !== null)
173
+ throw refused;
174
+ }
175
+ return reads;
176
+ }
@@ -0,0 +1,123 @@
1
+ import { MASON_SCHEMA } from "./store.js";
2
+ /** How many days every history table keeps (the request log, the sync's events, the usage samples, the copies'
3
+ * sizes) unless MASON_HISTORY_DAYS says. */
4
+ export const DEFAULT_HISTORY_DAYS = 7;
5
+ const KEPT = "the request log, the sync's events, the usage samples and the synced tables' sizes";
6
+ /** The days MASON_HISTORY_DAYS keeps, or what is wrong with the text. Unset or empty: the default. */
7
+ export function historyDays(text) {
8
+ if (text === undefined || text.trim() === "")
9
+ return DEFAULT_HISTORY_DAYS;
10
+ const days = Number(text);
11
+ if (!Number.isInteger(days) || days < 1) {
12
+ return { message: `MASON_HISTORY_DAYS=${JSON.stringify(text)} is not a number of days`,
13
+ why: `it is how many days Mason keeps its history (${KEPT}): a whole number, 1 or more`,
14
+ fix: `write MASON_HISTORY_DAYS=${DEFAULT_HISTORY_DAYS} (the default, as when it is unset) or another whole number of days` };
15
+ }
16
+ return days;
17
+ }
18
+ /**
19
+ * THE HISTORY'S DAYS, AS THE ENVIRONMENT SETS THEM — read by this one function at every entrance (`mason serve`'s
20
+ * settings report, `mason start` before it spawns, and the run before anything opens), so a wrong setting is refused
21
+ * before the copy is touched, in the Failure shape. `MASON_REQUEST_LOG_DAYS` SET IS REFUSED, never run on the default
22
+ * (CLAUDE.md: a renamed setting refuses the start, naming the line to write): it kept the request log alone once, and
23
+ * the days kept are every history table's now, so it is MASON_HISTORY_DAYS. Then MASON_HISTORY_DAYS must be a number
24
+ * of days (`historyDays`).
25
+ */
26
+ export function historyDaysSetting(env) {
27
+ const v = env.MASON_REQUEST_LOG_DAYS;
28
+ if (v !== undefined && v !== "") {
29
+ return { message: `MASON_REQUEST_LOG_DAYS=${v} is no longer read`,
30
+ why: `it was renamed MASON_HISTORY_DAYS, since the days it keeps are every history's (${KEPT})`,
31
+ fix: `write MASON_HISTORY_DAYS=${v.trim()} instead, then unset MASON_REQUEST_LOG_DAYS` };
32
+ }
33
+ return historyDays(env.MASON_HISTORY_DAYS);
34
+ }
35
+ /** what is wrong, as one line of `mason serve`'s settings report */
36
+ export function historyDaysWrongText(w) {
37
+ return `${w.message}: ${w.why} — ${w.fix}`;
38
+ }
39
+ /** how often the rows past the retention are deleted (and once at open) */
40
+ export const TRIM_EVERY_MS = 60 * 60 * 1000;
41
+ /** A history table's connections and its hourly trim. */
42
+ export class History {
43
+ writer;
44
+ reader;
45
+ trimTimer = null;
46
+ constructor(writer, reader) { this.writer = writer; this.reader = reader; }
47
+ /** The writer connected, `_mason` and each of `ddl` run on it in order, then the reader connected. */
48
+ static async open(store, ddl) {
49
+ const writer = await store.instance.connect();
50
+ await writer.run(`CREATE SCHEMA IF NOT EXISTS ${MASON_SCHEMA}`);
51
+ for (const d of ddl)
52
+ await writer.run(d);
53
+ const reader = await store.instance.connect();
54
+ return new History(writer, reader);
55
+ }
56
+ /** `trim` now (its failure is the open's), then every TRIM_EVERY_MS, an hourly one that fails said by `failed`. */
57
+ async keepTrimmed(trim, failed) {
58
+ await trim();
59
+ this.trimTimer = setInterval(() => { trim().catch((e) => failed(e)); }, TRIM_EVERY_MS);
60
+ this.trimTimer.unref();
61
+ }
62
+ /** No more hourly trims (the first step of a close). */
63
+ stopTrimming() {
64
+ if (this.trimTimer !== null) {
65
+ clearInterval(this.trimTimer);
66
+ this.trimTimer = null;
67
+ }
68
+ }
69
+ /** Both connections closed (the last step of a close). */
70
+ closeConnections() {
71
+ this.writer.closeSync();
72
+ this.reader.closeSync();
73
+ }
74
+ }
75
+ /** A streak of failures, said once: `failed` says the first of a streak and counts what each cost; `succeeded` says,
76
+ * once, what the streak cost, and ends it. */
77
+ export class Streak {
78
+ lost = 0;
79
+ failed(cost, sayFirst) {
80
+ if (this.lost === 0)
81
+ sayFirst();
82
+ this.lost += cost;
83
+ }
84
+ succeeded(sayEnd) {
85
+ if (this.lost > 0) {
86
+ sayEnd(this.lost);
87
+ this.lost = 0;
88
+ }
89
+ }
90
+ }
91
+ /** Rows written in batches: `push` one; a batch is written by `write` once `rows` are waiting or after `ms`, one batch
92
+ * at a time. `write` must not throw: a failure is its own to say (a Streak). */
93
+ export class Batches {
94
+ buffer = [];
95
+ timer = null;
96
+ /** the write in flight: one at a time, so batches commit in the order they were taken */
97
+ flushing = Promise.resolve();
98
+ rows;
99
+ ms;
100
+ write;
101
+ constructor(o) { this.rows = o.rows; this.ms = o.ms; this.write = o.write; }
102
+ push(row) {
103
+ this.buffer.push(row);
104
+ if (this.buffer.length >= this.rows) {
105
+ void this.flush();
106
+ return;
107
+ }
108
+ this.timer ??= setTimeout(() => { void this.flush(); }, this.ms);
109
+ }
110
+ /** Writes what is waiting, after any write in flight. Resolves whether it was written or not (`write` says a failure). */
111
+ flush() {
112
+ if (this.timer !== null) {
113
+ clearTimeout(this.timer);
114
+ this.timer = null;
115
+ }
116
+ const rows = this.buffer;
117
+ this.buffer = [];
118
+ if (rows.length === 0)
119
+ return this.flushing;
120
+ this.flushing = this.flushing.then(() => this.write(rows));
121
+ return this.flushing;
122
+ }
123
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * JSONB'S ORDER AND EQUALITY AS BYTES (a BI tool sorts any column a user clicks: `ORDER BY "e"."tags" DESC` over a jsonb
3
+ * column was refused until this). Postgres orders and equates jsonb by `compareJsonbContainers` (jsonb_util.c), which
4
+ * walks both values token by token and stops at the first difference:
5
+ * - by TYPE first, at any depth: null < string < number < boolean < array < object (jbvType's order);
6
+ * - an array with more elements sorts higher, an object with more pairs higher; of equal counts, element by element,
7
+ * pair by pair in STORAGE order (a key shorter in bytes first, then by its bytes; a key held twice keeps its LAST
8
+ * value), each pair's key then its value;
9
+ * - a number as a numeric (`10 > 9.5`, `1.0 = 1.00`, `1e2 = 100`, `-0 = 0`), a boolean false < true, a string — a
10
+ * key's too — under the DATABASE's default collation (`varstr_cmp(..., DEFAULT_COLLATION_OID)`), whatever a
11
+ * column's own collation, ties broken by the bytes;
12
+ * - THE RAW SCALAR: a scalar at the top is stored as a one-element array marked `rawScalar`, and the two are compared
13
+ * as arrays — the marks first (a raw scalar below an array), then, overriding that, the counts — so `[] < 1 < [1]`
14
+ * (the source's comment: "a mild anomaly that an empty top level array sorts less than null"), and every top-level
15
+ * scalar sorts below every object.
16
+ * Equality is that comparison's 0 (`jsonb_eq`), so it is the same key, never the canonical text: `1.0` and `1.00` are
17
+ * one jsonb written two ways.
18
+ *
19
+ * The key is one byte string whose plain byte order (DuckDB's BLOB comparison: bytes, then the shorter first) is that
20
+ * order, and whose equality is that equality. Every value is prefix-free (it ends where its own encoding says), so a
21
+ * container is its tag, its count and its members one after another, and the first differing byte is the first token
22
+ * Postgres finds different:
23
+ * - a tag per type, in jbvType's order: null 0x10, string 0x20, number 0x30, boolean 0x40, array 0x50, object 0x60;
24
+ * - an array: its tag, its count (4 bytes, big-endian), then 0x00 for a raw scalar and 0x01 for an array (the count
25
+ * first: it decides before the mark), then each element; an object: its tag, its count, each pair's key and value;
26
+ * - a number: 0x80 for zero; else a sign byte (0x81 positive, 0x7f negative), the decimal exponent E of 0.d₁d₂… × 10^E
27
+ * (4 bytes, biased), the digits without leading or trailing zeros, a 0x00 — every byte after the sign inverted for a
28
+ * negative number, so a larger magnitude sorts lower;
29
+ * - a string: the collation's key of it, then a 0x00 (a jsonb string holds no NUL): its UTF-8 bytes under C (`bytes`);
30
+ * under ICU, ICU's sort key (DuckDB's `icu_sort_key`, which the rewrite hands in) and the bytes' hex; under a libc
31
+ * locale the measured key (src/duck/libc-order.ts `libcKey`) and the bytes' hex, as the rewrite sorts text
32
+ * (src/duck/rewrite.ts `libcKey`).
33
+ * A string's collation key is computed by DuckDB's ICU for the ICU and libc kinds, which a JavaScript function cannot
34
+ * call: the rewrite passes the document's strings' ICU keys as a list (`jsonbStrings` lists the strings, in the order
35
+ * this walk meets them; `icuKeys` the list DuckDB made of them). Held to Postgres by tests/duck-jsonb-order.test.ts
36
+ * (PGlite's order) and the oracle (tests/integration/duck-oracle.itest.ts, every collation it is run under).
37
+ */
38
+ import { Buffer } from "node:buffer";
39
+ import { parseJson, storedPairs } from "./jsonb.js";
40
+ import { libcKept, libcKey } from "./libc-order.js";
41
+ const TAG = { null: 0x10, str: 0x20, num: 0x30, bool: 0x40, arr: 0x50, obj: 0x60 };
42
+ const BIAS = 0x80000000;
43
+ /** every string of the value — each object's keys and every string value — in the order the key's walk meets them */
44
+ function eachString(v, out) {
45
+ switch (v.t) {
46
+ case "str":
47
+ out.push(v.v);
48
+ return;
49
+ case "arr":
50
+ for (const x of v.v)
51
+ eachString(x, out);
52
+ return;
53
+ case "obj":
54
+ for (const [k, x] of storedPairs(v.v)) {
55
+ out.push(k);
56
+ eachString(x, out);
57
+ }
58
+ return;
59
+ default: return;
60
+ }
61
+ }
62
+ /**
63
+ * THE FUNCTIONS A KEY IS MADE BY, per collation kind (src/duck/functions.ts registers them): the key's, and the one
64
+ * listing the strings DuckDB makes ICU keys of (none under C). A statement's key (src/duck/rewrite.ts `jsonbKey`) and a
65
+ * stored one (src/duck/kept.ts `orderKeySql`) both read this table, so the two are one computation by construction;
66
+ * a stored key calls each name with `STORED_SUFFIX`, the same function answering NULL where this one raises.
67
+ */
68
+ export const JSONB_KEY_FUNCTIONS = {
69
+ bytes: { key: "__mason_jsonb_key", strings: null },
70
+ icu: { key: "__mason_jsonb_key_icu", strings: "__mason_jsonb_strings" },
71
+ libc: { key: "__mason_jsonb_key_libc", strings: "__mason_jsonb_libc_kept" },
72
+ };
73
+ export const STORED_SUFFIX = "_stored";
74
+ /**
75
+ * THE KEY'S VERSION: a stored key (src/duck/kept.ts `orderId`) is this encoding's, so a release that changes a byte of
76
+ * it — a tag, the number's form, a string's — bumps this, and a key stored by an earlier one is dropped at the start
77
+ * and stored again (tests/duck-jsonb-order.test.ts holds the encoding; a change it catches is a change of this).
78
+ */
79
+ export const KEY_VERSION = 1;
80
+ /** The strings of a jsonb's text in the key's order (what DuckDB's ICU makes the keys of). */
81
+ export function jsonbStrings(text) {
82
+ const out = [];
83
+ eachString(parseJson(text), out);
84
+ return out;
85
+ }
86
+ /** whether a jsonb's strings hold a character a libc order does not cover: what its key refuses by name
87
+ * (`libc-uncovered`, src/duck/libc-order.ts `libcKey`), told from any other failure of the key (src/duck/kept.ts) */
88
+ export function libcUncovered(order, text) {
89
+ return jsonbStrings(text).some((s) => { for (const ch of s)
90
+ if (order.uncovered[ch.codePointAt(0)])
91
+ return true; return false; });
92
+ }
93
+ /** The strings ICU's accent and case levels read for a libc key of each (src/duck/libc-order.ts `libcKept`). */
94
+ export function jsonbLibcKept(order, text) {
95
+ return jsonbStrings(text).map((s) => libcKept(order, s));
96
+ }
97
+ const u32 = (n) => [(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff];
98
+ /**
99
+ * A JSON number's text as its key: zero, or its sign, exponent and digits (the value's, exactly: no rounding through a
100
+ * double, and the scale left out — `1.0` and `1.00` are one number). Read from the text itself, never through a
101
+ * BigInt of its digits (`1e100000` is one digit and an exponent).
102
+ */
103
+ export function numberKey(text) {
104
+ const m = /^(-?)(\d+)(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/.exec(text);
105
+ if (m === null)
106
+ throw new Error(`not a JSON number: ${JSON.stringify(text)}`);
107
+ const fraction = m[3] ?? "";
108
+ // the value is all × 10^(exponent - fraction's length), and so 0.<digits> × 10^E with E = all's length less that
109
+ const all = (m[2] + fraction).replace(/^0+/, "");
110
+ const digits = all.replace(/0+$/, "");
111
+ if (digits === "")
112
+ return [0x80];
113
+ const e = all.length - fraction.length + Number(m[4] ?? 0);
114
+ const body = [...u32(e + BIAS), ...[...digits].map((d) => d.charCodeAt(0)), 0x00];
115
+ return m[1] === "-" ? [0x7f, ...body.map((b) => 0xff - b)] : [0x81, ...body];
116
+ }
117
+ const hex = (s) => Buffer.from(s, "utf8").toString("hex");
118
+ /** The key of a jsonb's text under the database's collation (the module's header says what it encodes). */
119
+ export function jsonbKey(text, c) {
120
+ const out = [];
121
+ let at = 0;
122
+ const icuKey = () => {
123
+ if (c.kind === "bytes")
124
+ throw new Error("a C collation's key needs no ICU key");
125
+ const k = c.icuKeys[at++];
126
+ if (k === undefined)
127
+ throw new Error(`the jsonb holds more strings than the ${c.icuKeys.length} ICU keys handed in: a Mason bug (the rewrite's list and this walk disagree)`);
128
+ return k;
129
+ };
130
+ const str = (s) => {
131
+ const key = c.kind === "bytes" ? s : c.kind === "icu" ? `${icuKey()} ${hex(s)}` : `${libcKey(c.order, s, icuKey())} ${hex(s)}`;
132
+ for (const b of Buffer.from(key, "utf8"))
133
+ out.push(b);
134
+ out.push(0x00);
135
+ };
136
+ const value = (v) => {
137
+ switch (v.t) {
138
+ case "null":
139
+ out.push(TAG.null);
140
+ return;
141
+ case "bool":
142
+ out.push(TAG.bool, v.v ? 1 : 0);
143
+ return;
144
+ case "num":
145
+ out.push(TAG.num, ...numberKey(v.text));
146
+ return;
147
+ case "str":
148
+ out.push(TAG.str);
149
+ str(v.v);
150
+ return;
151
+ case "arr":
152
+ out.push(TAG.arr, ...u32(v.v.length), 0x01);
153
+ for (const x of v.v)
154
+ value(x);
155
+ return;
156
+ case "obj": {
157
+ const pairs = storedPairs(v.v);
158
+ out.push(TAG.obj, ...u32(pairs.length));
159
+ for (const [k, x] of pairs) {
160
+ str(k);
161
+ value(x);
162
+ }
163
+ return;
164
+ }
165
+ }
166
+ };
167
+ const root = parseJson(text);
168
+ // a scalar at the top is a raw scalar: a one-element array marked as one (0x00, below an array's 0x01)
169
+ if (root.t !== "arr" && root.t !== "obj")
170
+ out.push(TAG.arr, ...u32(1), 0x00);
171
+ value(root);
172
+ if (c.kind !== "bytes" && at !== c.icuKeys.length)
173
+ throw new Error(`the jsonb holds ${at} strings, ${c.icuKeys.length} ICU keys were handed in: a Mason bug (the rewrite's list and this walk disagree)`);
174
+ return Buffer.from(out);
175
+ }
@@ -158,11 +158,19 @@ export function quoteJson(s) {
158
158
  }
159
159
  return `${out}"`;
160
160
  }
161
- /** jsonb's key order: shorter first, then by the UTF-8 bytes */
161
+ /** jsonb's key order: shorter first, then by the UTF-8 bytes (lengthCompareJsonbStringValue) */
162
162
  function keyOrder(a, b) {
163
163
  const x = Buffer.from(a, "utf8"), y = Buffer.from(b, "utf8");
164
164
  return x.length - y.length || Buffer.compare(x, y);
165
165
  }
166
+ /** an object's pairs as jsonb stores them, in its key order: a key held twice keeps its LAST value
167
+ * (uniqueifyJsonbObject). What jsonb writes and what its order compares (src/duck/jsonb-order.ts) both read it. */
168
+ export function storedPairs(members) {
169
+ const last = new Map();
170
+ for (const [k, x] of members)
171
+ last.set(k, x);
172
+ return [...last.keys()].sort(keyOrder).map((k) => [k, last.get(k)]);
173
+ }
166
174
  /** a parsed value as jsonb writes it */
167
175
  export function writeJson(v) {
168
176
  switch (v.t) {
@@ -171,13 +179,7 @@ export function writeJson(v) {
171
179
  case "num": return numericText(parseNumeric(v.text));
172
180
  case "str": return quoteJson(v.v);
173
181
  case "arr": return `[${v.v.map(writeJson).join(", ")}]`;
174
- case "obj": {
175
- // a duplicate key keeps its LAST value (jsonb's uniqueifyJsonbObject)
176
- const last = new Map();
177
- for (const [k, x] of v.v)
178
- last.set(k, x);
179
- return `{${[...last.keys()].sort(keyOrder).map((k) => `${quoteJson(k)}: ${writeJson(last.get(k))}`).join(", ")}}`;
180
- }
182
+ case "obj": return `{${storedPairs(v.v).map(([k, x]) => `${quoteJson(k)}: ${writeJson(x)}`).join(", ")}}`;
181
183
  }
182
184
  }
183
185
  /** A JSON text as Postgres's jsonb writes it; throws a json syntax error for a text that is no JSON. */
@@ -32,7 +32,7 @@ export function canonicalRead(r) {
32
32
  }
33
33
  /** a read as one string: the request log's key (its `->>` or `->` included: the policy counts both for one path) */
34
34
  export const readKey = (r) => { const c = canonicalRead(r); return JSON.stringify([c.source, c.column, c.steps, c.either, c.canonical, c.asText]); };
35
- /** a read's key, back (null for a string that is not one) */
35
+ /** a read's key, back (null for a string that is not one, and for a read of a kind this Mason does not know: `knownSteps`) */
36
36
  export function readOfKey(key) {
37
37
  let v;
38
38
  try {
@@ -46,8 +46,27 @@ export function readOfKey(key) {
46
46
  const [source, column, steps, either, canonical, asText] = v;
47
47
  if (typeof source !== "string" || typeof column !== "string" || typeof steps !== "string" || typeof either !== "boolean" || typeof canonical !== "boolean" || typeof asText !== "boolean")
48
48
  return null;
49
+ if (!knownSteps(steps))
50
+ return null;
49
51
  return canonicalRead({ source, column, steps, either, canonical, asText });
50
52
  }
53
+ /**
54
+ * WHETHER A READ'S STEPS ARE A KIND THIS MASON KNOWS: a path's (a JSON array of keys and indexes) or a jsonb's key
55
+ * (`isKeyRead`). A request log or a ledger written by another release may hold a kind this one does not know (an
56
+ * earlier release met `equal:…` and `order:…` and failed every write of the table, and its status, parsing them as a
57
+ * path): such a read is INERT, never fatal — not counted (`readOfKey`), its ledger row dropped at the start with its
58
+ * column (src/duck/kept.ts `open`), and said raw (`pathText`).
59
+ */
60
+ export function knownSteps(steps) {
61
+ if (isKeyRead({ steps }))
62
+ return true;
63
+ try {
64
+ return Array.isArray(JSON.parse(steps));
65
+ }
66
+ catch {
67
+ return false;
68
+ } // not JSON: not a path, so not a kind known here
69
+ }
51
70
  /**
52
71
  * THE STORED PATH A READ IS ANSWERED FROM: its canonical read (`canonicalRead`) without `->>`. One column serves a
53
72
  * path's `->` and its `->>` (the test server: a popular key was stored twice, as `-> 'k'` and `->> 'k'`, two
@@ -72,9 +91,48 @@ export const hiddenName = (r) => `${HIDDEN_PREFIX}${createHash("sha256").update(
72
91
  * table name, so `public.orders.id` and `orders.id` still bind.
73
92
  */
74
93
  export const VISIBLE_CATALOG = "__mason_visible";
94
+ /**
95
+ * A JSONB'S ORDER, AS A READ (src/duck/jsonb-order.ts; docs/DESIGN.md "jsonb's order"): `ORDER BY j`, `GROUP BY j`,
96
+ * `j = k` compare a key of the whole document, computed per row by a function — 19 s for `ORDER BY j LIMIT 100` over
97
+ * 20 M rows under C, 83 s under a libc locale, where the same sort from a stored key takes 0.1 s. So the key is a read
98
+ * like a path's, recorded, weighed and stored by the same keeper: its `steps` is `ORDER_STEPS` (never a path's, which
99
+ * is a JSON array). The rewrite records it as `ORDER_STEPS`; the keeper stores it as `ORDER_STEPS:<the collation's
100
+ * id>` (src/duck/kept.ts `own`), because a string's key is its collation's, so a key stored under another collation is
101
+ * another column, never read.
102
+ */
103
+ export const ORDER_STEPS = "order";
104
+ /** a stored key's marks (hex, DuckDB's `unhex`): a NULL document's, and a row whose key raised, computed when read. No
105
+ * key begins with a 0x00 (src/duck/jsonb-order.ts: every key starts with an array's or an object's tag) */
106
+ export const KEY_NULL_HEX = "00";
107
+ export const KEY_RECOMPUTE_HEX = "0021";
108
+ export const isOrderRead = (r) => r.steps === ORDER_STEPS || r.steps.startsWith(`${ORDER_STEPS}:`);
109
+ /**
110
+ * A JSONB'S EQUALITY, AS A READ, under a LIBC collation alone (src/duck/rewrite.ts `equalKey`): `GROUP BY j`, `DISTINCT
111
+ * j`, `j = k`, `j IN (…)`, a join on `=`, `count(DISTINCT j)` compare the C key — a libc order's key refuses a string
112
+ * its measured order does not cover, where equality never needed an order (docs/DESIGN.md "jsonb's order"), so the
113
+ * order's stored key cannot serve them, and over 20 M rows a GROUP BY computing the C key per row took 32–45 s of CPU
114
+ * (34–77 s wall; docs/DESIGN.md "Under libc, the C key stored for equality"), 0.15–0.23 s from it. So the C key
115
+ * is a read of its own, stored beside the order's as `EQUAL_STEPS:<id>` (src/duck/kept.ts `equalId`). Under C and ICU
116
+ * the order's key equates exactly and never refuses, so equality reads IT and this read is never recorded nor stored
117
+ * (src/duck/kept.ts `own` folds one into the order's).
118
+ */
119
+ export const EQUAL_STEPS = "equal";
120
+ export const isEqualRead = (r) => r.steps === EQUAL_STEPS || r.steps.startsWith(`${EQUAL_STEPS}:`);
121
+ /** a jsonb's key, the order's or the equality's: a BLOB column of the key's bytes, its marks `KEY_NULL_HEX` and
122
+ * `KEY_RECOMPUTE_HEX`, stored under an id of what its bytes depend on */
123
+ export const isKeyRead = (r) => isOrderRead(r) || isEqualRead(r);
75
124
  /** a read as a developer writes it: `payload ->> 'customer_id'`, `payload #> '{a,0}'`; a stored path (no `asText`) as
76
- * its `->` read */
125
+ * its `->` read; a jsonb's order and equality as what reads each (under libc the order's key sorts and the C key
126
+ * equates, two columns: `mason status` names each by its own) */
77
127
  export function pathText(r) {
128
+ // a kind this Mason does not know (another release's): said as it is stored, never parsed (`knownSteps`)
129
+ if (!knownSteps(r.steps))
130
+ return `${r.column} (stored as ${r.steps}: a kind this Mason does not know)`;
131
+ if (isEqualRead(r))
132
+ return `GROUP BY ${r.column} (its jsonb equality: grouped, DISTINCT, = and IN)`;
133
+ if (isOrderRead(r))
134
+ return r.steps.startsWith(`${ORDER_STEPS}:libc:`) ? `ORDER BY ${r.column} (its jsonb order: sorted, and compared by < and >)`
135
+ : `ORDER BY ${r.column} (its jsonb order: sorted, grouped and compared)`;
78
136
  const steps = JSON.parse(r.steps);
79
137
  const op = (one) => one ? (r.asText ? "->>" : "->") : (r.asText ? "#>>" : "#>");
80
138
  if (steps.length === 1 && !r.either) {
@@ -84,6 +142,28 @@ export function pathText(r) {
84
142
  const element = (s) => typeof s === "number" || /^[A-Za-z0-9_]+$/.test(s) ? String(s) : `"${String(s).replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
85
143
  return `${r.column} ${op(false)} '{${steps.map(element).join(",").replaceAll("'", "''")}}'`;
86
144
  }
145
+ /**
146
+ * WHAT AN EXACT READ RAISES AT A MARK (src/duck/rewrite.ts `keptRead`, `keptKey`): a row marked to be computed
147
+ * (`KEPT_RECOMPUTE`, `KEY_RECOMPUTE_HEX`) written AFTER the path was counted exact — by the stream, the poll or a copy's
148
+ * side table, before the detector (src/duck/kept.ts `check`) counts it and turns the reads to the CASE — is never read
149
+ * as a value: the exact read raises at it, by the column alone. A jsonb's key under a libc collation is marked where
150
+ * its strings hold a character the measured order does not cover, the gap the statement's own key refuses by name
151
+ * (`libc-uncovered`); any other mark is a Mason bug. Once counted (a minute at most), the CASE computes the row from its
152
+ * document, and raises the function's own error (the character named) or answers. Raised by `MARKED_FUNCTIONS`, never
153
+ * DuckDB's `error()`, which is volatile (a window's ORDER BY refuses it: the eager oracle's `rank() OVER (ORDER BY j)`).
154
+ */
155
+ export function markedError(r, libc) {
156
+ if (isOrderRead(r) && libc) {
157
+ return { code: "0A000", message: "a jsonb compared or sorted here holds a string Mason cannot order as the database's collation does (a combining mark, a Thai or " +
158
+ "Lao vowel written before its consonant, a private-use or unassigned code point), in a row written since its stored keys were counted; asked again " +
159
+ "in a minute, the error names the character (Mason gap libc-uncovered)" };
160
+ }
161
+ return { code: "XX000", message: `a stored value of ${pathText(r)} could not be computed when its row was written (a Mason bug: report it, with mason logs); ` +
162
+ "asked again in a minute, it is computed from the row's document" };
163
+ }
164
+ /** the functions that raise `markedError` at a marked row (src/duck/functions.ts): of the column read (a key as its hex,
165
+ * so neither is a constant DuckDB would fold), the code and the message; a key's answers a BLOB, a path's a VARCHAR */
166
+ export const MARKED_FUNCTIONS = { key: "__mason_marked_key", value: "__mason_marked_value" };
87
167
  const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
88
168
  const kindOf = (n) => isObject(n) && Object.keys(n).length === 1 && /^[A-Z]/.test(Object.keys(n)[0]) ? Object.keys(n)[0] : null;
89
169
  const bodyOf = (n) => n[kindOf(n)];