@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.
Files changed (109) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +268 -2
  3. package/dist/src/api/error.js +18 -0
  4. package/dist/src/api/postgres/catalog-worker.js +92 -0
  5. package/dist/src/api/postgres/catalog.js +477 -0
  6. package/dist/src/api/postgres/encode.js +541 -0
  7. package/dist/src/api/postgres/explain.js +143 -0
  8. package/dist/src/api/postgres/listener.js +60 -0
  9. package/dist/src/api/postgres/parser-worker.js +58 -0
  10. package/dist/src/api/postgres/parser.js +121 -0
  11. package/dist/src/api/postgres/plain.js +33 -0
  12. package/dist/src/api/postgres/protocol.js +404 -0
  13. package/dist/src/api/postgres/schema.js +54 -0
  14. package/dist/src/api/postgres/server.js +1771 -0
  15. package/dist/src/api/postgres/translate.js +248 -0
  16. package/dist/src/api/request-log.js +50 -0
  17. package/dist/src/cdc/pg-wire.js +588 -0
  18. package/dist/src/cdc/pgoutput.js +107 -0
  19. package/dist/src/cdc/poll.js +67 -0
  20. package/dist/src/cdc/source.js +65 -0
  21. package/dist/src/cli/background.js +198 -0
  22. package/dist/src/cli/command.js +173 -0
  23. package/dist/src/cli/commands.js +1277 -0
  24. package/dist/src/cli/control.js +36 -0
  25. package/dist/src/cli/demo.js +149 -0
  26. package/dist/src/cli/dev.js +669 -0
  27. package/dist/src/cli/doctor.js +260 -0
  28. package/dist/src/cli/guides.js +158 -0
  29. package/dist/src/cli/init.js +366 -0
  30. package/dist/src/cli/machine.js +162 -0
  31. package/dist/src/cli/main.js +83 -0
  32. package/dist/src/cli/mcp.js +726 -0
  33. package/dist/src/cli/pg-tls.js +100 -0
  34. package/dist/src/cli/pg-url.js +52 -0
  35. package/dist/src/cli/pin.js +36 -0
  36. package/dist/src/cli/ports.js +118 -0
  37. package/dist/src/cli/preflight.js +347 -0
  38. package/dist/src/cli/project.js +129 -0
  39. package/dist/src/cli/readiness.js +76 -0
  40. package/dist/src/cli/registry.js +220 -0
  41. package/dist/src/cli/replication-checks.js +120 -0
  42. package/dist/src/cli/replication-mode.js +148 -0
  43. package/dist/src/cli/run-server.js +311 -0
  44. package/dist/src/cli/runs.js +120 -0
  45. package/dist/src/cli/security.js +86 -0
  46. package/dist/src/cli/self-signed-cert.js +64 -0
  47. package/dist/src/cli/serve.js +301 -0
  48. package/dist/src/cli/source-edit.js +116 -0
  49. package/dist/src/cli/sources.js +564 -0
  50. package/dist/src/cli/state.js +58 -0
  51. package/dist/src/cli/style.js +187 -0
  52. package/dist/src/cli/supervise.js +248 -0
  53. package/dist/src/cli/sync.js +94 -0
  54. package/dist/src/cli/ui.js +107 -0
  55. package/dist/src/cli/user.js +229 -0
  56. package/dist/src/cli/version.js +23 -0
  57. package/dist/src/demo/seed.js +237 -0
  58. package/dist/src/demo/source.js +655 -0
  59. package/dist/src/duck/analyze.js +113 -0
  60. package/dist/src/duck/cancel.js +49 -0
  61. package/dist/src/duck/distinct-key.js +228 -0
  62. package/dist/src/duck/executor-worker.js +268 -0
  63. package/dist/src/duck/executor.js +567 -0
  64. package/dist/src/duck/explain.js +238 -0
  65. package/dist/src/duck/functions.js +819 -0
  66. package/dist/src/duck/gaps.js +94 -0
  67. package/dist/src/duck/jsonb-order.js +175 -0
  68. package/dist/src/duck/jsonb.js +188 -0
  69. package/dist/src/duck/kept-route.js +299 -0
  70. package/dist/src/duck/kept.js +964 -0
  71. package/dist/src/duck/late.js +221 -0
  72. package/dist/src/duck/libc-order.js +190 -0
  73. package/dist/src/duck/pg-eval-worker.js +34 -0
  74. package/dist/src/duck/pg-eval.js +59 -0
  75. package/dist/src/duck/pgtree.js +378 -0
  76. package/dist/src/duck/plan.js +259 -0
  77. package/dist/src/duck/publication.js +207 -0
  78. package/dist/src/duck/requests.js +209 -0
  79. package/dist/src/duck/resolved.js +248 -0
  80. package/dist/src/duck/rewrite.js +5101 -0
  81. package/dist/src/duck/store.js +907 -0
  82. package/dist/src/duck/stream.js +518 -0
  83. package/dist/src/duck/sync.js +2929 -0
  84. package/dist/src/duck/to-char.js +482 -0
  85. package/dist/src/duck/token.js +31 -0
  86. package/dist/src/duck/types.js +427 -0
  87. package/dist/src/duck/users.js +231 -0
  88. package/dist/src/duck/verified.js +1001 -0
  89. package/dist/src/duck/views.js +150 -0
  90. package/dist/src/lib/database-url.js +74 -0
  91. package/dist/src/lib/did-you-mean.js +43 -0
  92. package/dist/src/lib/dotenv.js +72 -0
  93. package/dist/src/lib/exit-when-written.js +20 -0
  94. package/dist/src/lib/float-equal.js +69 -0
  95. package/dist/src/lib/http-json.js +22 -0
  96. package/dist/src/lib/log.js +161 -0
  97. package/dist/src/lib/pg-numeric.js +489 -0
  98. package/dist/src/lib/port.js +22 -0
  99. package/dist/src/lib/project-file.js +49 -0
  100. package/dist/src/lib/project-files.js +43 -0
  101. package/dist/src/lib/project-version.js +76 -0
  102. package/dist/src/lib/runnable.js +14 -0
  103. package/dist/src/lib/shell-word.js +4 -0
  104. package/dist/src/lib/timezone.js +237 -0
  105. package/dist/src/lib/zone-names.js +114 -0
  106. package/dist/src/web/app.css +76 -0
  107. package/dist/src/web/app.js +491 -0
  108. package/dist/src/web/index.html +44 -0
  109. package/package.json +22 -4
@@ -0,0 +1,427 @@
1
+ /**
2
+ * A DUCKDB RESULT COLUMN ON THE POSTGRES WIRE (the prototype, docs/DESIGN.md): the OID a column of a DuckDB
3
+ * type is sent as, and its cells' bytes. Where the type is one of the Postgres API's own (src/api/postgres/encode.ts:
4
+ * integers, floats, numeric, text, bool, uuid, date, timestamp, timestamptz, jsonb) the value is handed to THAT encoder,
5
+ * text and binary, so a float, a numeric and a timestamptz are written exactly as the ClickHouse path writes them —
6
+ * Postgres's own text. The rest (time, interval, bytea, arrays) are written here, text always, binary where it is
7
+ * cheap; a binary request for one this file cannot write is refused naming it.
8
+ *
9
+ * A DuckDB type with no Postgres twin is sent as the nearest one and is a DIVERGENCE (decision 6 of the prototype's
10
+ * design): HUGEINT (DuckDB's `sum` of an integer, where Postgres's is bigint or numeric) as numeric, a STRUCT / MAP /
11
+ * UNION as its text. Each column that diverges carries the reason, and the runner logs it once per statement.
12
+ */
13
+ import { Buffer } from "node:buffer";
14
+ import { DuckDBTypeId, DuckDBDateValue, DuckDBTimestampValue, DuckDBTimestampTZValue, DuckDBDecimalValue, DuckDBUUIDValue, DuckDBListValue, DuckDBArrayValue, DuckDBIntervalValue, DuckDBBlobValue, DuckDBTimeValue, DuckDBListType, DuckDBArrayType, DuckDBTimestampSecondsValue, DuckDBTimestampMillisecondsValue, DuckDBTimestampNanosecondsValue, } from "@duckdb/node-api";
15
+ import { jsonbText } from "./jsonb.js";
16
+ import { EncodeError, encodeValue, OID, TIMESTAMP } from "../api/postgres/encode.js";
17
+ const MICROS_PER_DAY = 86400000000n;
18
+ const DATE_INFINITY = 2_147_483_647, DATE_NEG_INFINITY = -2_147_483_647;
19
+ const pad = (n, w = 2) => String(n).padStart(w, "0");
20
+ /** days from 1970 as YYYY-MM-DD (proleptic Gregorian, the year 1 and after) */
21
+ function dayText(days) {
22
+ const d = new Date(days * 86_400_000);
23
+ return `${pad(d.getUTCFullYear(), 4)}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`;
24
+ }
25
+ /** the first day of year 1 (0001-01-01) from 1970: a day before it is a BC day, which encode.ts does not write */
26
+ const FIRST_AD_DAY = -719_162, FIRST_AD_MICROS = BigInt(FIRST_AD_DAY) * 86400000000n;
27
+ const PG_EPOCH_DAYS = 10_957, PG_EPOCH_MICROS = 946684800000000n;
28
+ /** a BC day as Postgres writes it: the year counted back from 1 (astronomical 0 is 1 BC), `4713-11-24 BC` */
29
+ function bcDay(days) {
30
+ const d = new Date(days * 86_400_000);
31
+ return `${pad(1 - d.getUTCFullYear(), 4)}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`;
32
+ }
33
+ /** a BC timestamp's text: its day, its time, ` BC` (a timestamptz's in UTC, `+00` before the era) */
34
+ function bcTimestampText(micros, zoned) {
35
+ const day = -((-micros + MICROS_PER_DAY - 1n) / MICROS_PER_DAY);
36
+ const rest = micros - day * MICROS_PER_DAY;
37
+ const secs = rest / 1000000n, frac = rest % 1000000n;
38
+ const time = `${pad(secs / 3600n)}:${pad((secs % 3600n) / 60n)}:${pad(secs % 60n)}${frac === 0n ? "" : `.${pad(frac, 6).replace(/0+$/, "")}`}`;
39
+ return `${bcDay(Number(day))} ${time}${zoned ? "+00" : ""} BC`;
40
+ }
41
+ /** micros from 1970 as the wall clock `YYYY-MM-DDTHH:MM:SS.ffffff` (what encode.ts reads for a timestamp) */
42
+ function wallText(micros) {
43
+ const day = micros >= 0n ? micros / MICROS_PER_DAY : -((-micros + MICROS_PER_DAY - 1n) / MICROS_PER_DAY);
44
+ const rest = micros - day * MICROS_PER_DAY;
45
+ const secs = rest / 1000000n, frac = rest % 1000000n;
46
+ return `${dayText(Number(day))}T${pad(secs / 3600n)}:${pad((secs % 3600n) / 60n)}:${pad(secs % 60n)}${frac === 0n ? "" : `.${pad(frac, 6)}`}`;
47
+ }
48
+ /** A column sent through the Postgres API's own encoder: the DuckDB value turned into what that encoder reads. */
49
+ function viaApi(name, type, toApi, divergence = null) {
50
+ const o = type === "timestamp" ? TIMESTAMP : OID[type];
51
+ return { name, oid: o.oid, size: o.size, divergence, encode: (v, format, s) => v === null ? null : encodeValue(type, toApi(v), format, s) };
52
+ }
53
+ const str = (v) => typeof v === "string" ? v : String(v);
54
+ const bigintText = (v) => String(v);
55
+ function dateApi(v) {
56
+ if (!(v instanceof DuckDBDateValue))
57
+ throw new EncodeError(`expected a date, got ${String(v)}`);
58
+ return dayText(v.days);
59
+ }
60
+ /** A date: through encode.ts, but for ±infinity, which Postgres has and encode.ts refuses */
61
+ function dateColumn(name) {
62
+ const base = viaApi(name, "date", dateApi);
63
+ return {
64
+ ...base,
65
+ encode: (v, format, s) => {
66
+ if (v instanceof DuckDBDateValue && (v.days >= DATE_INFINITY || v.days <= DATE_NEG_INFINITY)) {
67
+ const pos = v.days > 0;
68
+ if (format === 0)
69
+ return Buffer.from(pos ? "infinity" : "-infinity", "ascii");
70
+ const b = Buffer.alloc(4);
71
+ b.writeInt32BE(pos ? 2_147_483_647 : -2_147_483_648);
72
+ return b;
73
+ }
74
+ if (v instanceof DuckDBDateValue && v.days < FIRST_AD_DAY) {
75
+ if (format === 0)
76
+ return Buffer.from(`${bcDay(v.days)} BC`, "ascii");
77
+ const b = Buffer.alloc(4);
78
+ b.writeInt32BE(v.days - PG_EPOCH_DAYS);
79
+ return b;
80
+ }
81
+ return base.encode(v, format, s);
82
+ },
83
+ };
84
+ }
85
+ function timestampMicros(v) {
86
+ if (v instanceof DuckDBTimestampValue || v instanceof DuckDBTimestampTZValue)
87
+ return v.micros;
88
+ if (v instanceof DuckDBTimestampSecondsValue)
89
+ return v.seconds * 1000000n;
90
+ if (v instanceof DuckDBTimestampMillisecondsValue)
91
+ return v.millis * 1000n;
92
+ if (v instanceof DuckDBTimestampNanosecondsValue)
93
+ return v.nanos / 1000n;
94
+ throw new EncodeError(`expected a timestamp, got ${String(v)}`);
95
+ }
96
+ /** ±infinity of a timestamp: DuckDB's max/min micros, Postgres's 'infinity' */
97
+ const TS_INFINITY = 9223372036854775807n, TS_NEG_INFINITY = -9223372036854775807n;
98
+ /** Postgres's timestamp range in Unix micros (MIN_TIMESTAMP and END_TIMESTAMP count from 2000-01-01, 946,684,800 s
99
+ * after 1970-01-01) */
100
+ const TS_MIN = -211813488000000000n + 946684800000000n, TS_END = 9223371331200000000n + 946684800000000n;
101
+ function timestampColumn(name, tz) {
102
+ const base = viaApi(name, tz ? "timestamptz" : "timestamp", (v) => tz ? `${wallText(timestampMicros(v))}Z` : wallText(timestampMicros(v)));
103
+ return {
104
+ ...base,
105
+ encode: (v, format, s) => {
106
+ if (v === null)
107
+ return null;
108
+ const m = timestampMicros(v);
109
+ if (m >= TS_INFINITY || m <= TS_NEG_INFINITY) {
110
+ if (format === 0)
111
+ return Buffer.from(m > 0n ? "infinity" : "-infinity", "ascii");
112
+ const b = Buffer.alloc(8);
113
+ b.writeBigInt64BE(m > 0n ? TS_INFINITY : -TS_INFINITY - 1n);
114
+ return b;
115
+ }
116
+ // DuckDB's range is wider than Postgres's (4714-11-24 BC to 294276 AD), where Postgres raises its error
117
+ if (m < TS_MIN || m >= TS_END)
118
+ throw new EncodeError("timestamp out of range", "22008");
119
+ if (m < FIRST_AD_MICROS) {
120
+ if (format === 1) {
121
+ const b = Buffer.alloc(8);
122
+ b.writeBigInt64BE(m - PG_EPOCH_MICROS);
123
+ return b;
124
+ }
125
+ // a zone's offset before year 1 is its local mean time, which Mason does not compute
126
+ if (tz && s.timeZone.toUpperCase() !== "UTC")
127
+ throw new EncodeError(`Mason writes a timestamptz before year 1 only in UTC, not in ${s.timeZone} (Mason gap bc-timestamptz-zone)`, "0A000");
128
+ return Buffer.from(bcTimestampText(m, tz), "ascii");
129
+ }
130
+ return base.encode(v, format, s);
131
+ },
132
+ };
133
+ }
134
+ /** `HH:MM:SS[.ffffff]` of micros since midnight */
135
+ function timeText(micros) {
136
+ const secs = micros / 1000000n, frac = micros % 1000000n;
137
+ return `${pad(secs / 3600n)}:${pad((secs % 3600n) / 60n)}:${pad(secs % 60n)}${frac === 0n ? "" : `.${pad(frac, 6).replace(/0+$/, "")}`}`;
138
+ }
139
+ /** An interval as Postgres's `interval_out` writes it under IntervalStyle postgres: `1 year 2 mons 3 days 04:05:06.7` */
140
+ export function intervalText(months, days, micros) {
141
+ const parts = [];
142
+ const years = Math.trunc(months / 12), mons = months % 12;
143
+ // EncodeInterval: a field after a negative one says its sign, `-1 mons +1 day`
144
+ let before = false;
145
+ const unit = (n, one, many) => {
146
+ if (n !== 0) {
147
+ parts.push(`${before && n > 0 ? "+" : ""}${n} ${n === 1 ? one : many}`);
148
+ before = n < 0;
149
+ }
150
+ };
151
+ unit(years, "year", "years");
152
+ unit(mons, "mon", "mons");
153
+ unit(days, "day", "days");
154
+ if (micros !== 0n || parts.length === 0) {
155
+ const neg = micros < 0n;
156
+ const abs = neg ? -micros : micros;
157
+ const t = timeText(abs);
158
+ parts.push(neg ? `-${t}` : before ? `+${t}` : t);
159
+ }
160
+ return parts.join(" ");
161
+ }
162
+ /** An element of a Postgres array literal: quoted when array_out would quote it */
163
+ /** inet_out: `10.3.6.45` for `10.3.6.45/32`, `::1` for `::1/128`; a partial mask stays */
164
+ export function inetOut(text) {
165
+ const m = /^(.*)\/(\d+)$/.exec(text);
166
+ if (m === null)
167
+ return text;
168
+ const full = m[1].includes(":") ? 128 : 32;
169
+ return Number(m[2]) === full ? m[1] : text;
170
+ }
171
+ /** inet_send: the family (2 IPv4, 3 IPv6), the mask's bits, is_cidr (0), the address's length, its bytes */
172
+ export function inetBytes(text) {
173
+ const m = /^(.*?)(?:\/(\d+))?$/.exec(text);
174
+ const addr = m[1], v6 = addr.includes(":");
175
+ const bits = m[2] !== undefined ? Number(m[2]) : v6 ? 128 : 32;
176
+ const ipv4 = (s) => {
177
+ const parts = s.split(".").map(Number);
178
+ if (parts.length !== 4 || parts.some((x) => !Number.isInteger(x) || x < 0 || x > 255))
179
+ throw new EncodeError(`an inet Mason cannot read: ${JSON.stringify(text)}`);
180
+ return parts;
181
+ };
182
+ let bytes;
183
+ if (!v6)
184
+ bytes = ipv4(addr);
185
+ else {
186
+ const [head, tail] = addr.split("::");
187
+ const words = (s) => s === "" ? [] : s.split(":").flatMap((w, i, all) => {
188
+ // an embedded IPv4 as the last group (`::ffff:10.3.6.45`)
189
+ if (w.includes(".") && i === all.length - 1) {
190
+ const b = ipv4(w);
191
+ return [(b[0] << 8) | b[1], (b[2] << 8) | b[3]];
192
+ }
193
+ if (!/^[0-9a-fA-F]{1,4}$/.test(w))
194
+ throw new EncodeError(`an inet Mason cannot read: ${JSON.stringify(text)}`);
195
+ return [parseInt(w, 16)];
196
+ });
197
+ const h = words(head), t = tail === undefined ? [] : words(tail);
198
+ if (h.length + t.length > 8 || (tail === undefined && h.length !== 8))
199
+ throw new EncodeError(`an inet Mason cannot read: ${JSON.stringify(text)}`);
200
+ const all = [...h, ...Array(8 - h.length - t.length).fill(0), ...t];
201
+ bytes = all.flatMap((w) => [w >> 8, w & 255]);
202
+ }
203
+ return Buffer.from([v6 ? 3 : 2, bits, 0, bytes.length, ...bytes]);
204
+ }
205
+ function arrayElement(text) {
206
+ if (text === null)
207
+ return "NULL";
208
+ if (text === "" || /^null$/i.test(text) || /[{},"\\\s]/.test(text))
209
+ return `"${text.replace(/["\\]/g, (c) => `\\${c}`)}"`;
210
+ return text;
211
+ }
212
+ /** Postgres's array OIDs of the element OIDs this file sends */
213
+ const ARRAY_OID = {
214
+ 16: 1000, 17: 1001, 20: 1016, 21: 1005, 23: 1007, 25: 1009, 700: 1021, 701: 1022, 1082: 1182, 1083: 1183,
215
+ 1114: 1115, 1184: 1185, 1186: 1187, 1700: 1231, 2950: 2951, 3802: 3807,
216
+ };
217
+ function custom(name, oid, size, text, binary, divergence = null) {
218
+ return {
219
+ name, oid, size, divergence,
220
+ encode: (v, format, s) => {
221
+ if (v === null)
222
+ return null;
223
+ if (format === 0)
224
+ return Buffer.from(text(v, s), "utf8");
225
+ if (binary === null)
226
+ throw new EncodeError(`column ${JSON.stringify(name)} (type OID ${oid}) cannot be sent in binary by Mason's prototype: ask for text`);
227
+ return binary(v, s);
228
+ },
229
+ };
230
+ }
231
+ /**
232
+ * An array in binary, as array_send writes it: its dimensions (none for an empty one), whether an element is NULL, the
233
+ * element's OID, each dimension's length and lower bound (1), then each element as its length (-1 for NULL) and its own
234
+ * binary form. pgjdbc asks for text[], int4[], float8[] … in binary once a statement has run prepareThreshold times.
235
+ */
236
+ function arrayBinary(items, element, s) {
237
+ const parts = items.map((x) => x === null ? null : element.encode(x, 1, s));
238
+ const head = Buffer.alloc(items.length === 0 ? 12 : 20);
239
+ head.writeInt32BE(items.length === 0 ? 0 : 1, 0);
240
+ head.writeInt32BE(parts.some((p) => p === null) ? 1 : 0, 4);
241
+ head.writeInt32BE(element.oid, 8);
242
+ if (items.length > 0) {
243
+ head.writeInt32BE(items.length, 12);
244
+ head.writeInt32BE(1, 16);
245
+ }
246
+ return Buffer.concat([head, ...parts.flatMap((p) => {
247
+ const len = Buffer.alloc(4);
248
+ len.writeInt32BE(p === null ? -1 : p.length);
249
+ return p === null ? [len] : [len, p];
250
+ })]);
251
+ }
252
+ /**
253
+ * The wire column of a DuckDB result column. `session` is what an ELEMENT of an array is written in (a timestamptz
254
+ * array's elements in the session's zone): the array column's encoder receives its own session at each call.
255
+ */
256
+ export function wireColumn(name, t) {
257
+ switch (t.typeId) {
258
+ case DuckDBTypeId.BOOLEAN: return viaApi(name, "bool", (v) => v);
259
+ case DuckDBTypeId.TINYINT:
260
+ case DuckDBTypeId.SMALLINT:
261
+ case DuckDBTypeId.UTINYINT: return viaApi(name, "int2", (v) => v);
262
+ case DuckDBTypeId.INTEGER:
263
+ case DuckDBTypeId.USMALLINT: return viaApi(name, "int4", (v) => v);
264
+ case DuckDBTypeId.BIGINT:
265
+ case DuckDBTypeId.UINTEGER: return viaApi(name, "int8", bigintText);
266
+ case DuckDBTypeId.HUGEINT:
267
+ case DuckDBTypeId.UHUGEINT:
268
+ case DuckDBTypeId.UBIGINT:
269
+ case DuckDBTypeId.BIGNUM:
270
+ return viaApi(name, "numeric", bigintText, `DuckDB's ${t.toString()} (a sum of integers is one), sent as numeric: Postgres says bigint for a sum of int4 and numeric for a sum of int8`);
271
+ case DuckDBTypeId.FLOAT: return viaApi(name, "float4", (v) => v);
272
+ case DuckDBTypeId.DOUBLE: return viaApi(name, "float8", (v) => v);
273
+ case DuckDBTypeId.DECIMAL: return viaApi(name, "numeric", (v) => v.toString());
274
+ case DuckDBTypeId.VARCHAR:
275
+ return t.alias === "JSON" ? viaApi(name, "jsonb", str) : viaApi(name, "text", str);
276
+ case DuckDBTypeId.ENUM: return viaApi(name, "text", str);
277
+ case DuckDBTypeId.UUID: return viaApi(name, "uuid", (v) => v.toString());
278
+ case DuckDBTypeId.DATE: return dateColumn(name);
279
+ case DuckDBTypeId.TIMESTAMP:
280
+ case DuckDBTypeId.TIMESTAMP_S:
281
+ case DuckDBTypeId.TIMESTAMP_MS:
282
+ case DuckDBTypeId.TIMESTAMP_NS:
283
+ return timestampColumn(name, false);
284
+ case DuckDBTypeId.TIMESTAMP_TZ: return timestampColumn(name, true);
285
+ case DuckDBTypeId.TIME:
286
+ return custom(name, 1083, 8, (v) => timeText(v.micros), (v) => { const b = Buffer.alloc(8); b.writeBigInt64BE(v.micros); return b; });
287
+ case DuckDBTypeId.INTERVAL:
288
+ return custom(name, 1186, 16, (v) => { const i = v; return intervalText(i.months, i.days, i.micros); }, (v) => { const i = v; const b = Buffer.alloc(16); b.writeBigInt64BE(i.micros, 0); b.writeInt32BE(i.days, 8); b.writeInt32BE(i.months, 12); return b; });
289
+ case DuckDBTypeId.BLOB:
290
+ return custom(name, 17, -1, (v) => `\\x${Buffer.from(v.bytes).toString("hex")}`, (v) => Buffer.from(v.bytes));
291
+ case DuckDBTypeId.LIST:
292
+ case DuckDBTypeId.ARRAY: {
293
+ const child = t instanceof DuckDBListType || t instanceof DuckDBArrayType ? t.valueType : null;
294
+ if (child === null)
295
+ break;
296
+ const element = wireColumn(name, child);
297
+ const oid = ARRAY_OID[element.oid];
298
+ if (oid === undefined || child.typeId === DuckDBTypeId.LIST || child.typeId === DuckDBTypeId.ARRAY)
299
+ break;
300
+ const itemsOf = (v) => v instanceof DuckDBListValue || v instanceof DuckDBArrayValue ? v.items : [];
301
+ // an array's elements, each as its own column would write it (a timestamptz in the session's zone)
302
+ return custom(name, oid, -1, (v, s) => `{${itemsOf(v).map((x) => arrayElement(x === null ? null : element.encode(x, 0, s).toString("utf8"))).join(",")}}`, (v, s) => arrayBinary(itemsOf(v), element, s), element.divergence);
303
+ }
304
+ default: break;
305
+ }
306
+ return custom(name, 25, -1, (v) => String(v), null, `DuckDB's ${t.toString()} has no Postgres type here: sent as its text`);
307
+ }
308
+ // ---------------------------------------------------------------------------------------------------------------------
309
+ // A COLUMN OF POSTGRES'S TYPE (docs/DESIGN.md "Postgres decides, DuckDB computes"): the catalog Postgres's
310
+ // analysis says what each result column IS (its OID and typmod: `length()` is an integer, `extract` a numeric, a
311
+ // `varchar(20)` column a varchar), and DuckDB's value is written as THAT type — the digits of DuckDB's BIGINT as an
312
+ // int4, its DECIMAL as a numeric, its JSON as jsonb's canonical text. Where DuckDB's value cannot be written as
313
+ // Postgres's type (a timestamp where Postgres says date), DuckDB's own encoding stands and the column says why.
314
+ /** pg_type.typlen of the OIDs a result column may have */
315
+ const TYPLEN = { 16: 1, 18: 1, 19: 64, 20: 8, 21: 2, 23: 4, 26: 4, 700: 4, 701: 8, 1082: 4, 1083: 8, 1114: 8, 1184: 8, 1186: 16, 2950: 16 };
316
+ const PG_TEXT = new Set([25, 1043, 1042, 19, 18]);
317
+ const PG_INT = { 21: "int2", 23: "int4", 20: "int8" };
318
+ const DUCK_EXACT = new Set([DuckDBTypeId.TINYINT, DuckDBTypeId.SMALLINT, DuckDBTypeId.INTEGER, DuckDBTypeId.BIGINT, DuckDBTypeId.HUGEINT,
319
+ DuckDBTypeId.UTINYINT, DuckDBTypeId.USMALLINT, DuckDBTypeId.UINTEGER, DuckDBTypeId.UBIGINT, DuckDBTypeId.UHUGEINT, DuckDBTypeId.DECIMAL, DuckDBTypeId.BIGNUM]);
320
+ const DUCK_FLOAT = new Set([DuckDBTypeId.FLOAT, DuckDBTypeId.DOUBLE]);
321
+ /** an exact DuckDB number's decimal text */
322
+ const exactText = (v) => v instanceof DuckDBDecimalValue ? v.toString() : String(v);
323
+ /** a decimal's text with at least `scale` decimals (never rounded) */
324
+ export function padDecimals(text, scale) {
325
+ if (scale <= 0 || !/^-?\d+(\.\d*)?$/.test(text))
326
+ return text;
327
+ const point = text.indexOf(".");
328
+ const have = point < 0 ? 0 : text.length - point - 1;
329
+ return have >= scale ? text : `${point < 0 ? `${text}.` : text}${"0".repeat(scale - have)}`;
330
+ }
331
+ /** a double as Postgres's float8 → numeric cast writes it: 15 significant digits (`float8_numeric`, DBL_DIG; a
332
+ * float4's is 6, FLT_DIG) */
333
+ export function floatAsNumeric(x, digits = 15) {
334
+ if (!Number.isFinite(x))
335
+ return Number.isNaN(x) ? "NaN" : x > 0 ? "Infinity" : "-Infinity";
336
+ const g = x.toPrecision(digits);
337
+ // positional, trailing zeros of the fraction dropped as %g drops them
338
+ const [mant, exp] = g.split("e");
339
+ let s = mant.includes(".") ? mant.replace(/0+$/, "").replace(/\.$/, "") : mant;
340
+ if (exp !== undefined) {
341
+ const e = Number(exp);
342
+ const neg = s.startsWith("-");
343
+ const digits = s.replace("-", "").replace(".", "");
344
+ const point = s.replace("-", "").indexOf(".") < 0 ? s.replace("-", "").length : s.replace("-", "").indexOf(".");
345
+ const at = point + e;
346
+ s = at <= 0 ? `0.${"0".repeat(-at)}${digits}` : at >= digits.length ? digits + "0".repeat(at - digits.length) : `${digits.slice(0, at)}.${digits.slice(at)}`;
347
+ s = (neg ? "-" : "") + s.replace(/(\.\d*?)0+$/, "$1").replace(/\.$/, "");
348
+ }
349
+ return s === "-0" ? "0" : s;
350
+ }
351
+ /** The wire column of a result column whose Postgres type the analysis gave, holding DuckDB values of type `t`. */
352
+ export function pgTypedColumn(name, oid, t, typmod = -1) {
353
+ const own = wireColumn(name, t);
354
+ const size = TYPLEN[oid] ?? -1;
355
+ const retyped = (c) => ({ ...c, name, oid, size, divergence: null });
356
+ // a numeric(p, s) column's every value has s decimals in Postgres; DuckDB may hold it with fewer (src/duck/sync.ts
357
+ // copies a numeric(65,30) at the decimals its values use)
358
+ if (oid === 1700 && typmod >= 4 && DUCK_EXACT.has(t.typeId))
359
+ return retyped(viaApi(name, "numeric", (v) => padDecimals(exactText(v), (typmod - 4) & 0xffff)));
360
+ if (own.oid === oid)
361
+ return retyped(own);
362
+ // an inet: stored as Postgres's text with its mask (`10.3.6.45/32`), sent as inet_out writes it (a full mask dropped)
363
+ if (oid === 869 && own.oid === 25)
364
+ return custom(name, 869, -1, (v) => inetOut(str(v)), (v) => inetBytes(str(v)));
365
+ // a type an extension made that DuckDB holds as text (citext), or an array of one: its bytes, under the source's OID
366
+ if (oid >= 16384 && (own.oid === 25 || own.oid === 1009))
367
+ return retyped(own);
368
+ // an anonymous row (record) is its text, written by record_out's rules (src/duck/functions.ts recordText)
369
+ if (oid === 2249 && own.oid === 25)
370
+ return retyped(own);
371
+ // an array of inet: each element as inet_out writes it
372
+ if (oid === 1041 && own.oid === 1009) {
373
+ const items = (v) => v instanceof DuckDBListValue || v instanceof DuckDBArrayValue ? v.items : [];
374
+ return custom(name, 1041, -1, (v) => `{${items(v).map((x) => arrayElement(x === null ? null : inetOut(str(x)))).join(",")}}`, null);
375
+ }
376
+ // an array of varchar, char(n) or name is DuckDB's VARCHAR[]: the same elements, Postgres's array type
377
+ if ((oid === 1015 || oid === 1014 || oid === 1003) && own.oid === 1009)
378
+ return retyped(own);
379
+ // an array of json: each element's text as it is; of jsonb: each element's canonical text
380
+ if ((oid === 199 || oid === 3807) && (own.oid === 3807 || own.oid === 1009)) {
381
+ const items = (v) => v instanceof DuckDBListValue || v instanceof DuckDBArrayValue ? v.items : [];
382
+ return custom(name, oid, -1, (v) => `{${items(v).map((x) => arrayElement(x === null ? null : oid === 3807 ? jsonbText(str(x)) : str(x))).join(",")}}`, null);
383
+ }
384
+ if (PG_TEXT.has(oid)) {
385
+ // any DuckDB value Postgres calls text: DuckDB's text of it (a varchar's bytes are the same in both formats)
386
+ return { name, oid, size, divergence: null, encode: (v, format, s) => v === null ? null : own.oid === 25 ? own.encode(v, format, s) : own.encode(v, 0, s) };
387
+ }
388
+ if (oid === 3802 && (own.oid === 25 || own.oid === 3802))
389
+ return viaApi(name, "jsonb", (v) => jsonbText(str(v)));
390
+ if (oid === 114 && (own.oid === 25 || own.oid === 3802))
391
+ return custom(name, 114, -1, str, (v) => Buffer.from(str(v), "utf8"));
392
+ const pgInt = PG_INT[oid];
393
+ if (pgInt !== undefined && (DUCK_EXACT.has(t.typeId) || DUCK_FLOAT.has(t.typeId)))
394
+ return retyped(viaApi(name, pgInt, (v) => DUCK_FLOAT.has(t.typeId) ? Number(v) : exactText(v)));
395
+ if ((oid === 700 || oid === 701) && DUCK_EXACT.has(t.typeId))
396
+ return retyped(viaApi(name, oid === 700 ? "float4" : "float8", (v) => Number(exactText(v))));
397
+ if (oid === 1700 && DUCK_EXACT.has(t.typeId))
398
+ return retyped(viaApi(name, "numeric", exactText));
399
+ if (oid === 1700 && DUCK_FLOAT.has(t.typeId)) {
400
+ // a double that overflowed is Postgres's numeric overflow (its exact arithmetic raises it)
401
+ return { ...retyped(viaApi(name, "numeric", (v) => { if (v === Infinity || v === -Infinity)
402
+ throw new EncodeError("value overflows numeric format", "22003"); return floatAsNumeric(v); })), divergence: `Postgres computes this numeric exactly; DuckDB computed it as a double (${t.toString()}), sent with 15 significant digits` };
403
+ }
404
+ return { ...own, divergence: `Postgres says type OID ${oid}; DuckDB computed ${t.toString()}, sent as its own type` };
405
+ }
406
+ /** How a cell of an exact numeric (src/duck/rewrite.ts ExactColumn) reads as a decimal text, by DuckDB's type: an exact
407
+ * number's digits, a float as Postgres's cast to numeric writes it — or null where the cell needs digits DuckDB did not
408
+ * keep (a double where the program wants exact ones). */
409
+ export function cellText(t, conv) {
410
+ // a numeric's text as Postgres's numeric_out wrote it (a text read by numeric_in, src/duck/pg-eval.ts)
411
+ if (conv === "text")
412
+ return t.typeId === DuckDBTypeId.VARCHAR ? (v) => String(v) : null;
413
+ if (DUCK_EXACT.has(t.typeId))
414
+ return exactText;
415
+ if (DUCK_FLOAT.has(t.typeId) && conv !== "exact")
416
+ return (v) => floatAsNumeric(Number(v), conv === "float4" ? 6 : 15);
417
+ // a NULL-only cell (DuckDB types it as its own NULL type)
418
+ if (t.typeId === DuckDBTypeId.SQLNULL)
419
+ return exactText;
420
+ return null;
421
+ }
422
+ /** A computed exact column: its value is the program's decimal text, written as Postgres's numeric or as text. */
423
+ export function exactColumn(name, oid) {
424
+ return oid === 1700 ? { ...viaApi(name, "numeric", str), name, size: -1 } : { ...viaApi(name, "text", str), name, oid, size: TYPLEN[oid] ?? -1 };
425
+ }
426
+ /** pg_type.typlen of a result column's OID (-1 for a variable-length one) */
427
+ export const pgTyplen = (oid) => TYPLEN[oid] ?? -1;
@@ -0,0 +1,231 @@
1
+ /**
2
+ * `mason user`: NAMED, REVOCABLE LOGINS WITH NO EXPIRY (docs/DESIGN.md "On a server", item 1 of the
3
+ * agreed plan). A BI tool stores ONE static password, and the day-long token `mason connect` minted
4
+ * (src/duck/token.ts) killed its connection the next day. A user is what Postgres and every BI tool's form call it: a
5
+ * name and a password (`CREATE USER metabase WITH PASSWORD`), never a "token" (the ClickHouse stack's Query API token was
6
+ * short-lived and scoped: a different thing) nor "credentials".
7
+ *
8
+ * THE PASSWORD is `mason_<id>_<mac>`: `<id>` 16 random hex digits, `<mac>` the first 32 hex digits of an HMAC-SHA256
9
+ * over the user's NAME and id, keyed by the project's API key (`.mason/config.json` `apiKey`, `MASON_API_KEY` under
10
+ * `mason serve`). So the user name a connection sends must be the user's (a token's session may send any), every
11
+ * statement of its session is attributed to that user, and rotating the API key removes every user at once (no MAC
12
+ * verifies under the new key). It never collides with a token: a token's MAC is 43 base64url characters.
13
+ *
14
+ * THE LIST OF LIVE USERS is a FILE in the state folder (`.mason/users.json`), never a DuckDB table: `mason user …` runs
15
+ * as a second process beside the run, which holds DuckDB's file lock. It holds each user's name, id, when it was added
16
+ * and the API key's fingerprint at the time (`key`, a MAC of a constant: no secret, it says which key a user was added
17
+ * under) — never a password. A change is made under a lock file (`users.json.lock`, O_EXCL) and written beside and
18
+ * renamed, so a reader sees the old list or the new one, never half of either, and two `mason user add` at once cannot
19
+ * both win a name. The listener RE-READS it (`LiveUsers`: a stat at every connection and before every statement of a
20
+ * user's session, the file read again when its inode, size or mtime moved), so a removal refuses the next connection
21
+ * at once and ends the sessions that user holds at their next statement, without a restart. FAIL CLOSED: a file that
22
+ * cannot be read refuses every user login with the reason (57P03, "could not tell"), never "live"; no file is no users.
23
+ */
24
+ import * as crypto from "node:crypto";
25
+ import * as fs from "node:fs";
26
+ import * as path from "node:path";
27
+ import { ApiError } from "../api/error.js";
28
+ import { tokenProblem } from "./token.js";
29
+ export const USERS_FILE = "users.json";
30
+ /** A user's name: what a BI tool's form calls the user (Postgres's NAMEDATALEN, 63), lower case so a form that
31
+ * capitalises it is told so, never another user. */
32
+ export const USER_NAME_RE = /^[a-z0-9][a-z0-9_.-]{0,62}$/;
33
+ export const USER_NAME_RULE = "1 to 63 lower case letters, digits, \"_\", \".\" or \"-\", starting with a letter or digit (e.g. metabase)";
34
+ const PASSWORD_RE = /^mason_([0-9a-f]{16})_([0-9a-f]{32})$/;
35
+ const mac = (apiKey, name, id) => crypto.createHmac("sha256", apiKey).update(`mason-user:${name}:${id}`).digest("hex").slice(0, 32);
36
+ /** Which API key a user was added under, without the key: a MAC of a constant, 16 hex digits. */
37
+ export const keyFingerprint = (apiKey) => crypto.createHmac("sha256", apiKey).update("mason-user-key").digest("hex").slice(0, 16);
38
+ /** A new user's id: 16 random hex digits. */
39
+ export const newUserId = () => crypto.randomBytes(8).toString("hex");
40
+ /** The user's password: only `mason user add` says it, once. */
41
+ export const userPassword = (apiKey, name, id) => `mason_${id}_${mac(apiKey, name, id)}`;
42
+ /** Whether a password is a user's by its shape (else it is read as a token, src/duck/token.ts). */
43
+ export const isUserPassword = (password) => PASSWORD_RE.test(password);
44
+ /** Whether a user was added under this API key (one rotated since is no longer live). */
45
+ export const underKey = (u, apiKey) => u.key === keyFingerprint(apiKey);
46
+ /** The users the file lists; none when there is no file; why not, when it cannot be read — never an empty list then. */
47
+ export function readUsers(file) {
48
+ let text;
49
+ try {
50
+ text = fs.readFileSync(file, "utf8");
51
+ }
52
+ catch (e) {
53
+ if (e.code === "ENOENT")
54
+ return { users: [] };
55
+ return { problem: `${file} cannot be read: ${e.message}` };
56
+ }
57
+ return parseUsers(file, text);
58
+ }
59
+ function parseUsers(file, text) {
60
+ let v;
61
+ try {
62
+ v = JSON.parse(text);
63
+ }
64
+ catch (e) {
65
+ return { problem: `${file} is not valid JSON (${e.message})` };
66
+ }
67
+ const list = v?.users;
68
+ if (typeof v !== "object" || v === null || !Array.isArray(list))
69
+ return { problem: `${file} is not { "users": [ … ] }` };
70
+ const users = [];
71
+ for (const [i, u] of list.entries()) {
72
+ const o = u;
73
+ if (typeof o !== "object" || o === null || typeof o.name !== "string" || !USER_NAME_RE.test(o.name) || typeof o.id !== "string" || !/^[0-9a-f]{16}$/.test(o.id) ||
74
+ typeof o.addedAt !== "string" || typeof o.key !== "string") {
75
+ return { problem: `${file}: users[${i}] is not { name, id, addedAt, key } as mason user add writes it` };
76
+ }
77
+ users.push({ name: o.name, id: o.id, addedAt: o.addedAt, key: o.key });
78
+ }
79
+ return { users };
80
+ }
81
+ const LOCK_WAIT_MS = 5000;
82
+ /** a lock older than this was left by a process that died holding it */
83
+ const STALE_LOCK_MS = 30_000;
84
+ /**
85
+ * A change to the list, made under the file's lock: `change` is handed the users as they are now and returns the list
86
+ * to write, or a refusal (returned as it is, nothing written). The list is written beside the file and renamed over it.
87
+ * Throws when the lock is held past LOCK_WAIT_MS or the file cannot be read or written: a caller says why.
88
+ */
89
+ export function changeUsers(file, change) {
90
+ fs.mkdirSync(path.dirname(file), { recursive: true });
91
+ const lock = `${file}.lock`;
92
+ const until = Date.now() + LOCK_WAIT_MS;
93
+ for (;;) {
94
+ try {
95
+ fs.closeSync(fs.openSync(lock, "wx", 0o600));
96
+ break;
97
+ }
98
+ catch (e) {
99
+ if (e.code !== "EEXIST")
100
+ throw e;
101
+ let age;
102
+ try {
103
+ age = Date.now() - fs.statSync(lock).mtimeMs;
104
+ }
105
+ catch (s) {
106
+ if (s.code === "ENOENT")
107
+ continue;
108
+ throw s;
109
+ } // released between the two: try again
110
+ if (age > STALE_LOCK_MS) {
111
+ fs.rmSync(lock, { force: true });
112
+ continue;
113
+ }
114
+ if (Date.now() > until)
115
+ throw new Error(`${lock} is held by another mason user command (for ${Math.round(age / 1000)} s): run it again once that one ends, or remove the lock file if no mason user command runs`);
116
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
117
+ }
118
+ }
119
+ try {
120
+ const now = readUsers(file);
121
+ if ("problem" in now)
122
+ throw new Error(now.problem);
123
+ const next = change(now.users);
124
+ if (!Array.isArray(next))
125
+ return next;
126
+ const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`;
127
+ try {
128
+ fs.writeFileSync(tmp, `${JSON.stringify({ users: next }, null, 2)}\n`, { mode: 0o600 });
129
+ fs.renameSync(tmp, file);
130
+ }
131
+ catch (e) {
132
+ fs.rmSync(tmp, { force: true });
133
+ throw e;
134
+ }
135
+ return next;
136
+ }
137
+ finally {
138
+ fs.rmSync(lock, { force: true });
139
+ }
140
+ }
141
+ // ------------------------------------------------------------------ the listener's view
142
+ /** The ApiError a refused user login carries: the user it asked for, so the request log names who still tries. */
143
+ function refusal(status, message, user) {
144
+ const e = new ApiError(status, message, status === 503 ? "users_unreadable" : "invalid_user");
145
+ e.userName = user;
146
+ return e;
147
+ }
148
+ /**
149
+ * THE LIVE USERS AS THE LISTENER SEES THEM: the file read again whenever it changed (its inode — a rename makes a new
150
+ * one —, size or mtime), so each check costs a stat. `login` is a connection's, `recheck` a session's before each
151
+ * statement; both throw an ApiError, 401 for a password that is no live user's, 503 when the file cannot be read.
152
+ */
153
+ export class LiveUsers {
154
+ file;
155
+ apiKey;
156
+ seen = null;
157
+ read = { users: [] };
158
+ constructor(file, apiKey) { this.file = file; this.apiKey = apiKey; }
159
+ /** the users now; why not, when the file cannot be read */
160
+ now() {
161
+ let stamp;
162
+ try {
163
+ const s = fs.statSync(this.file);
164
+ stamp = `${s.ino}:${s.size}:${s.mtimeMs}`;
165
+ }
166
+ catch (e) {
167
+ if (e.code !== "ENOENT")
168
+ return { problem: `${this.file} cannot be read: ${e.message}` };
169
+ stamp = "none";
170
+ }
171
+ if (stamp !== this.seen || "problem" in this.read) {
172
+ this.read = readUsers(this.file);
173
+ this.seen = stamp;
174
+ }
175
+ return this.read;
176
+ }
177
+ /** A connection's password, read as a user's: the session's token, or why it is refused. */
178
+ login(user, password) {
179
+ const m = PASSWORD_RE.exec(password);
180
+ if (m === null)
181
+ throw refusal(401, "not a Mason user's password: mason user add <name> makes a user and prints its password", user);
182
+ const [, id, given] = m;
183
+ const now = this.now();
184
+ if ("problem" in now)
185
+ throw refusal(503, `the list of users cannot be read, so no user can connect: ${now.problem}`, user);
186
+ const found = now.users.find((u) => u.id === id);
187
+ const verifies = (name) => crypto.timingSafeEqual(Buffer.from(mac(this.apiKey, name, id)), Buffer.from(given));
188
+ if (found === undefined) {
189
+ throw refusal(401, verifies(user) ? `the user "${user}" was removed (mason user list shows the users; mason user add ${user} makes it again, with a new password)`
190
+ : "not a password of this project's users (or the API key was rotated, which removes every user): mason user list shows them", user);
191
+ }
192
+ if (!verifies(found.name)) {
193
+ throw refusal(401, underKey(found, this.apiKey) ? "not a password of this project's users: mason user list shows them"
194
+ : `the API key was rotated since the user "${found.name}" was added, which removes every user: mason user remove ${found.name}, then mason user add ${found.name}`, user);
195
+ }
196
+ // another user's password: refused as a wrong one is. Saying whose it is handed the name — which, with the password,
197
+ // is the login — to anyone holding only the password (found on the test server)
198
+ if (found.name !== user)
199
+ throw refusal(401, "not a password of this project's users: mason user list shows them", user);
200
+ return { filters: [], subject: undefined, user: found.name, jti: found.id };
201
+ }
202
+ /** A held session's user, asked again before a statement: throws as `login` does when it is no longer live. */
203
+ recheck(token) {
204
+ if (token.user === undefined)
205
+ return;
206
+ const now = this.now();
207
+ if ("problem" in now)
208
+ throw refusal(503, `the list of users cannot be read: ${now.problem}`, token.user);
209
+ if (!now.users.some((u) => u.id === token.jti && u.name === token.user))
210
+ throw refusal(401, `the user "${token.user}" was removed`, token.user);
211
+ }
212
+ }
213
+ /**
214
+ * HOW THE DUCKDB RUN'S LISTENER TAKES A PASSWORD (src/cli/dev.ts; the tests wire the same): a user's password (its shape)
215
+ * is the users file's to judge, and its session is asked again before every statement (a stat); anything else is read as a
216
+ * token, the kind `mason connect` minted before `mason user` (src/duck/token.ts: the API key used as a password is refused by name).
217
+ */
218
+ export function listenerLogins(users, apiKey) {
219
+ return {
220
+ authenticate: (password, user) => {
221
+ if (isUserPassword(password))
222
+ return users.login(user, password);
223
+ const problem = tokenProblem(apiKey, password);
224
+ if (problem !== null)
225
+ throw new Error(problem);
226
+ return { filters: [], subject: undefined };
227
+ },
228
+ recheck: async (t) => { users.recheck(t); },
229
+ recheckMs: 0,
230
+ };
231
+ }