@mason-data/cli 0.8.0 → 0.9.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 +13 -4
- package/dist/src/api/postgres/catalog-worker.js +49 -2
- package/dist/src/api/postgres/catalog.js +166 -32
- package/dist/src/api/postgres/listener.js +11 -3
- package/dist/src/api/postgres/proxy.js +283 -0
- package/dist/src/api/postgres/schema.js +6 -0
- package/dist/src/api/postgres/server.js +505 -99
- package/dist/src/api/postgres/translate.js +47 -2
- package/dist/src/cli/background.js +18 -19
- package/dist/src/cli/bridge-stdin.js +101 -0
- package/dist/src/cli/command.js +169 -11
- package/dist/src/cli/commands.js +207 -51
- package/dist/src/cli/credentials.js +182 -0
- package/dist/src/cli/demo.js +2 -2
- package/dist/src/cli/deploy.js +574 -0
- package/dist/src/cli/dev.js +148 -27
- package/dist/src/cli/doctor.js +116 -33
- package/dist/src/cli/generated-files.js +143 -0
- package/dist/src/cli/guides.js +97 -6
- package/dist/src/cli/hosted.js +850 -0
- package/dist/src/cli/init.js +47 -11
- package/dist/src/cli/login.js +289 -0
- package/dist/src/cli/machine.js +1 -1
- package/dist/src/cli/main.js +8 -2
- package/dist/src/cli/mason.cjs +17 -0
- package/dist/src/cli/mcp.js +2 -2
- package/dist/src/cli/output-gate.js +176 -0
- package/dist/src/cli/page-share.js +96 -0
- package/dist/src/cli/pg-tls.js +1 -1
- package/dist/src/cli/pin.js +6 -2
- package/dist/src/cli/pool-size.cjs +85 -0
- package/dist/src/cli/ports.js +2 -0
- package/dist/src/cli/preflight.js +130 -39
- package/dist/src/cli/project.js +99 -0
- package/dist/src/cli/readiness.js +42 -6
- package/dist/src/cli/registry.js +12 -4
- package/dist/src/cli/replica-hosted.js +665 -0
- package/dist/src/cli/replica.js +76 -0
- package/dist/src/cli/roles.js +2 -1
- package/dist/src/cli/run-server.js +154 -7
- package/dist/src/cli/security.js +57 -10
- package/dist/src/cli/serve.js +63 -7
- package/dist/src/cli/share-run.js +51 -8
- package/dist/src/cli/share.js +3 -3
- package/dist/src/cli/source-edit.js +10 -4
- package/dist/src/cli/source-role.js +344 -0
- package/dist/src/cli/sources.js +30 -20
- package/dist/src/cli/style.js +2 -1
- package/dist/src/cli/supervise.js +15 -23
- package/dist/src/cli/token-hosted.js +337 -0
- package/dist/src/cli/ui-ssh.js +460 -0
- package/dist/src/cli/ui.js +115 -11
- package/dist/src/cli/user-hosted.js +299 -0
- package/dist/src/cli/user.js +126 -74
- package/dist/src/demo/seed.js +26 -0
- package/dist/src/duck/analyze.js +36 -20
- package/dist/src/duck/collation.js +266 -0
- package/dist/src/duck/constant-key.js +94 -0
- package/dist/src/duck/distinct-key.js +283 -26
- package/dist/src/duck/events.js +39 -8
- package/dist/src/duck/executor-worker.js +2 -2
- package/dist/src/duck/executor.js +6 -5
- package/dist/src/duck/explain.js +4 -0
- package/dist/src/duck/functions.js +448 -49
- package/dist/src/duck/gaps.js +34 -16
- package/dist/src/duck/generated-plan.js +1070 -0
- package/dist/src/duck/generated.js +906 -0
- package/dist/src/duck/grants.js +1 -1
- package/dist/src/duck/held-types.js +64 -0
- package/dist/src/duck/history.js +4 -0
- package/dist/src/duck/jsonb-order.js +9 -12
- package/dist/src/duck/kept-route.js +6 -5
- package/dist/src/duck/kept.js +9 -7
- package/dist/src/duck/late.js +1469 -40
- package/dist/src/duck/libc-bound.js +341 -0
- package/dist/src/duck/libc-order.js +892 -126
- package/dist/src/duck/pg-eval-worker.js +7 -1
- package/dist/src/duck/pg-eval.js +6 -4
- package/dist/src/duck/pgtree.js +774 -23
- package/dist/src/duck/plan.js +275 -30
- package/dist/src/duck/publication.js +14 -0
- package/dist/src/duck/requests.js +9 -5
- package/dist/src/duck/resolved.js +117 -2
- package/dist/src/duck/rewrite.js +4567 -583
- package/dist/src/duck/row-filter.js +12 -6
- package/dist/src/duck/statement.js +19 -0
- package/dist/src/duck/store.js +294 -68
- package/dist/src/duck/stream.js +9 -5
- package/dist/src/duck/sync.js +602 -126
- package/dist/src/duck/token.js +4 -1
- package/dist/src/duck/types.js +63 -7
- package/dist/src/duck/usage.js +6 -4
- package/dist/src/duck/users.js +5 -0
- package/dist/src/duck/verified.js +159 -2
- package/dist/src/duck/views.js +4 -3
- package/dist/src/lib/database-url.js +55 -10
- package/dist/src/lib/instant.js +50 -0
- package/dist/src/lib/log-buffer.js +13 -4
- package/dist/src/lib/log-read.js +196 -0
- package/dist/src/lib/pg-numeric.js +387 -49
- package/dist/src/lib/secret-mask.js +142 -0
- package/dist/src/lib/table-sync.js +24 -9
- package/dist/src/web/app.js +9 -4
- package/dist/src/web/pages/status.js +178 -13
- package/dist/src/web/pages/syncs.js +1 -1
- package/dist/src/web/pages/tables.js +8 -3
- package/dist/src/web/pages/users.js +4 -3
- package/dist/src/web/shared.js +19 -3
- package/package.json +8 -7
package/dist/src/duck/token.js
CHANGED
|
@@ -14,11 +14,14 @@ export function mintToken(apiKey, seconds, now = Date.now()) {
|
|
|
14
14
|
const expiry = Math.floor(now / 1000) + seconds;
|
|
15
15
|
return { token: `${PREFIX}_${expiry}_${mac(apiKey, expiry)}`, expiresAt: new Date(expiry * 1000).toISOString() };
|
|
16
16
|
}
|
|
17
|
+
const TOKEN_RE = /^mason_(\d{1,12})_([A-Za-z0-9_-]{43})$/;
|
|
18
|
+
/** Whether a password is a token by its shape, good or not. */
|
|
19
|
+
export const isToken = (password) => TOKEN_RE.test(password);
|
|
17
20
|
/** null when the token is good; else why not, in a sentence a person reads after "password authentication failed" */
|
|
18
21
|
export function tokenProblem(apiKey, token, now = Date.now()) {
|
|
19
22
|
if (token === apiKey)
|
|
20
23
|
return "the API key is not a password here: mason user add <name> makes a user whose password does not expire";
|
|
21
|
-
const m =
|
|
24
|
+
const m = TOKEN_RE.exec(token);
|
|
22
25
|
if (m === null)
|
|
23
26
|
return "not a Mason user's password or token: mason user add <name> makes a user whose password does not expire";
|
|
24
27
|
const expiry = Number(m[1]);
|
package/dist/src/duck/types.js
CHANGED
|
@@ -11,9 +11,11 @@
|
|
|
11
11
|
* UNION as its text. Each column that diverges carries the reason, and the runner logs it once per statement.
|
|
12
12
|
*/
|
|
13
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";
|
|
14
|
+
import { DuckDBTypeId, DuckDBDateValue, DuckDBTimestampValue, DuckDBTimestampTZValue, DuckDBDecimalValue, DuckDBUUIDValue, DuckDBListValue, DuckDBArrayValue, DuckDBIntervalValue, DuckDBBlobValue, DuckDBTimeValue, DuckDBListType, DuckDBArrayType, DuckDBTimeTZValue, DuckDBTimestampSecondsValue, DuckDBTimestampMillisecondsValue, DuckDBTimestampNanosecondsValue, } from "@duckdb/node-api";
|
|
15
15
|
import { jsonbText } from "./jsonb.js";
|
|
16
16
|
import { gapMark } from "./gaps.js";
|
|
17
|
+
import { heldType, ZONED_HELD } from "./held-types.js";
|
|
18
|
+
import { pgEvalSync } from "./pg-eval.js";
|
|
17
19
|
import { EncodeError, encodeValue, OID, TIMESTAMP } from "../api/postgres/encode.js";
|
|
18
20
|
const MICROS_PER_DAY = 86400000000n;
|
|
19
21
|
const DATE_INFINITY = 2_147_483_647, DATE_NEG_INFINITY = -2_147_483_647;
|
|
@@ -137,6 +139,12 @@ function timeText(micros) {
|
|
|
137
139
|
const secs = micros / 1000000n, frac = micros % 1000000n;
|
|
138
140
|
return `${pad(secs / 3600n)}:${pad((secs % 3600n) / 60n)}:${pad(secs % 60n)}${frac === 0n ? "" : `.${pad(frac, 6).replace(/0+$/, "")}`}`;
|
|
139
141
|
}
|
|
142
|
+
/** A timetz as timetz_out writes it: the time, then its offset as `+HH`, `+HH:MM` or `+HH:MM:SS` (DuckDB's offset, as
|
|
143
|
+
* Postgres writes it, is east of UTC; its stored zone is west) */
|
|
144
|
+
export function timetzText(v) {
|
|
145
|
+
const abs = Math.abs(v.offset), s = abs % 60, m = Math.trunc(abs / 60) % 60;
|
|
146
|
+
return `${timeText(v.micros)}${v.offset < 0 ? "-" : "+"}${pad(Math.trunc(abs / 3600))}${m !== 0 || s !== 0 ? `:${pad(m)}` : ""}${s !== 0 ? `:${pad(s)}` : ""}`;
|
|
147
|
+
}
|
|
140
148
|
/** An interval as Postgres's `interval_out` writes it under IntervalStyle postgres: `1 year 2 mons 3 days 04:05:06.7` */
|
|
141
149
|
export function intervalText(months, days, micros) {
|
|
142
150
|
const parts = [];
|
|
@@ -213,7 +221,7 @@ function arrayElement(text) {
|
|
|
213
221
|
/** Postgres's array OIDs of the element OIDs this file sends */
|
|
214
222
|
const ARRAY_OID = {
|
|
215
223
|
16: 1000, 17: 1001, 20: 1016, 21: 1005, 23: 1007, 25: 1009, 700: 1021, 701: 1022, 1082: 1182, 1083: 1183,
|
|
216
|
-
1114: 1115, 1184: 1185, 1186: 1187, 1700: 1231, 2950: 2951, 3802: 3807,
|
|
224
|
+
1114: 1115, 1184: 1185, 1186: 1187, 1266: 1270, 1700: 1231, 2950: 2951, 3802: 3807,
|
|
217
225
|
};
|
|
218
226
|
function custom(name, oid, size, text, binary, divergence = null) {
|
|
219
227
|
return {
|
|
@@ -285,6 +293,8 @@ export function wireColumn(name, t) {
|
|
|
285
293
|
case DuckDBTypeId.TIMESTAMP_TZ: return timestampColumn(name, true);
|
|
286
294
|
case DuckDBTypeId.TIME:
|
|
287
295
|
return custom(name, 1083, 8, (v) => timeText(v.micros), (v) => { const b = Buffer.alloc(8); b.writeBigInt64BE(v.micros); return b; });
|
|
296
|
+
case DuckDBTypeId.TIME_TZ:
|
|
297
|
+
return custom(name, 1266, 12, (v) => timetzText(v), (v) => { const t = v; const b = Buffer.alloc(12); b.writeBigInt64BE(t.micros, 0); b.writeInt32BE(-t.offset, 8); return b; });
|
|
288
298
|
case DuckDBTypeId.INTERVAL:
|
|
289
299
|
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; });
|
|
290
300
|
case DuckDBTypeId.BLOB:
|
|
@@ -313,7 +323,7 @@ export function wireColumn(name, t) {
|
|
|
313
323
|
// int4, its DECIMAL as a numeric, its JSON as jsonb's canonical text. Where DuckDB's value cannot be written as
|
|
314
324
|
// Postgres's type (a timestamp where Postgres says date), DuckDB's own encoding stands and the column says why.
|
|
315
325
|
/** pg_type.typlen of the OIDs a result column may have */
|
|
316
|
-
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 };
|
|
326
|
+
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, 1266: 12, 2950: 16 };
|
|
317
327
|
const PG_TEXT = new Set([25, 1043, 1042, 19, 18]);
|
|
318
328
|
const PG_INT = { 21: "int2", 23: "int4", 20: "int8" };
|
|
319
329
|
const DUCK_EXACT = new Set([DuckDBTypeId.TINYINT, DuckDBTypeId.SMALLINT, DuckDBTypeId.INTEGER, DuckDBTypeId.BIGINT, DuckDBTypeId.HUGEINT,
|
|
@@ -349,6 +359,45 @@ export function floatAsNumeric(x, digits = 15) {
|
|
|
349
359
|
}
|
|
350
360
|
return s === "-0" ? "0" : s;
|
|
351
361
|
}
|
|
362
|
+
/** the binary forms of the text-held types a driver asks for by default (pgjdbc: point, box): each a float8's bytes */
|
|
363
|
+
const HELD_BINARY = {
|
|
364
|
+
600: floats, 603: floats,
|
|
365
|
+
};
|
|
366
|
+
function floats(text) {
|
|
367
|
+
const parts = text.match(/-?(?:Infinity|NaN|[\d.]+(?:e[-+]?\d+)?)/g) ?? [];
|
|
368
|
+
const b = Buffer.alloc(parts.length * 8);
|
|
369
|
+
parts.forEach((p, i) => b.writeDoubleBE(Number(p), i * 8));
|
|
370
|
+
return b;
|
|
371
|
+
}
|
|
372
|
+
/** a time range's text in the session's zone: Postgres's own output, a value at a time, the last few remembered */
|
|
373
|
+
const zonedTexts = new Map();
|
|
374
|
+
function inZone(type, text, zone) {
|
|
375
|
+
const key = `${zone}\u0000${type}\u0000${text}`;
|
|
376
|
+
let written = zonedTexts.get(key);
|
|
377
|
+
if (written === undefined) {
|
|
378
|
+
written = pgEvalSync(`SELECT $1::${type}::text`, [text], zone)[0]?.[0] ?? text;
|
|
379
|
+
if (zonedTexts.size >= 1024)
|
|
380
|
+
zonedTexts.clear();
|
|
381
|
+
zonedTexts.set(key, written);
|
|
382
|
+
}
|
|
383
|
+
return written;
|
|
384
|
+
}
|
|
385
|
+
/** a column of a type held as its text. A value's text only: Mason writes the binary form of few of them */
|
|
386
|
+
function heldColumn(name, oid, own, size) {
|
|
387
|
+
const binary = HELD_BINARY[oid], zoned = ZONED_HELD.get(oid);
|
|
388
|
+
return {
|
|
389
|
+
name, oid, size, divergence: null,
|
|
390
|
+
encode: (v, format, s) => {
|
|
391
|
+
if (v === null)
|
|
392
|
+
return null;
|
|
393
|
+
if (format === 0)
|
|
394
|
+
return zoned !== undefined ? Buffer.from(inZone(zoned, str(v), s.timeZone), "utf8") : own.encode(v, 0, s);
|
|
395
|
+
if (binary !== undefined)
|
|
396
|
+
return binary(str(v));
|
|
397
|
+
throw new EncodeError(`column ${JSON.stringify(name)} (type OID ${oid}) is held as its text and cannot be sent in binary by Mason: ask for text (${gapMark("binary-held-type")})`, "0A000", "binary-held-type");
|
|
398
|
+
},
|
|
399
|
+
};
|
|
400
|
+
}
|
|
352
401
|
/** The wire column of a result column whose Postgres type the analysis gave, holding DuckDB values of type `t`. */
|
|
353
402
|
export function pgTypedColumn(name, oid, t, typmod = -1) {
|
|
354
403
|
const own = wireColumn(name, t);
|
|
@@ -360,6 +409,16 @@ export function pgTypedColumn(name, oid, t, typmod = -1) {
|
|
|
360
409
|
return retyped(viaApi(name, "numeric", (v) => padDecimals(exactText(v), (typmod - 4) & 0xffff)));
|
|
361
410
|
if (own.oid === oid)
|
|
362
411
|
return retyped(own);
|
|
412
|
+
// a type the copy holds as its text, or an array of one (src/duck/held-types.ts): the text, under Postgres's OID
|
|
413
|
+
const held = heldType(oid);
|
|
414
|
+
if (held !== undefined && (own.oid === 25 || own.oid === 1009))
|
|
415
|
+
return heldColumn(name, oid, own, held.oid === oid ? held.size : -1);
|
|
416
|
+
// an oid element of an oid[], held as its digits
|
|
417
|
+
if (oid === 26 && own.oid === 25)
|
|
418
|
+
return custom(name, 26, 4, str, (v) => { const b = Buffer.alloc(4); b.writeUInt32BE(Number(str(v))); return b; });
|
|
419
|
+
// an oid: DuckDB's UINTEGER
|
|
420
|
+
if (oid === 26 && DUCK_EXACT.has(t.typeId))
|
|
421
|
+
return custom(name, 26, 4, (v) => exactText(v), (v) => { const b = Buffer.alloc(4); b.writeUInt32BE(Number(exactText(v))); return b; });
|
|
363
422
|
// 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)
|
|
364
423
|
if (oid === 869 && own.oid === 25)
|
|
365
424
|
return custom(name, 869, -1, (v) => inetOut(str(v)), (v) => inetBytes(str(v)));
|
|
@@ -404,12 +463,9 @@ export function pgTypedColumn(name, oid, t, typmod = -1) {
|
|
|
404
463
|
}
|
|
405
464
|
return { ...own, divergence: `Postgres says type OID ${oid}; the engine computed ${t.toString()}, sent as its own type` };
|
|
406
465
|
}
|
|
407
|
-
/** How a cell of an exact numeric (src/duck/rewrite.ts ExactColumn) reads as a decimal text, by DuckDB's type: an exact
|
|
408
|
-
* number's digits, a float as Postgres's cast to numeric writes it — or null where the cell needs digits DuckDB did not
|
|
409
|
-
* keep (a double where the program wants exact ones). */
|
|
410
466
|
export function cellText(t, conv) {
|
|
411
467
|
// a numeric's text as Postgres's numeric_out wrote it (a text read by numeric_in, src/duck/pg-eval.ts)
|
|
412
|
-
if (conv === "text")
|
|
468
|
+
if (conv === "text" || conv === "parsed")
|
|
413
469
|
return t.typeId === DuckDBTypeId.VARCHAR ? (v) => String(v) : null;
|
|
414
470
|
if (DUCK_EXACT.has(t.typeId))
|
|
415
471
|
return exactText;
|
package/dist/src/duck/usage.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { MASON_SCHEMA } from "./store.js";
|
|
2
|
-
import { History, Streak } from "./history.js";
|
|
2
|
+
import { History, Streak, cutoff } from "./history.js";
|
|
3
3
|
import { outcomeOf } from "../api/request-log.js";
|
|
4
4
|
export const USAGE_TABLE = `${MASON_SCHEMA}.usage`;
|
|
5
5
|
export const SIZES_TABLE = `${MASON_SCHEMA}.copy_sizes`;
|
|
@@ -265,10 +265,12 @@ export class UsageSampler {
|
|
|
265
265
|
this.log(`usage: measured ${rows.length} synced tables' own size in ${Math.round(performance.now() - t0)} ms (every ${Math.round((this.options.sizeEveryMs ?? SIZE_EVERY_MS) / 60_000)} min)`);
|
|
266
266
|
}
|
|
267
267
|
}
|
|
268
|
-
/** Deletes the samples and sizes older than the retention
|
|
268
|
+
/** Deletes the samples and sizes older than the retention, counted back from the module's ONE clock (`now`, which
|
|
269
|
+
* stamps each sample), never the engine's `now()` (src/duck/events.ts `trim` says why). */
|
|
269
270
|
async trim() {
|
|
270
|
-
|
|
271
|
-
await this.history.writer.run(`DELETE FROM ${
|
|
271
|
+
const before = cutoff(this.now(), this.days);
|
|
272
|
+
await this.history.writer.run(`DELETE FROM ${USAGE_TABLE} WHERE sampled_at < $1::TIMESTAMPTZ`, [before]);
|
|
273
|
+
await this.history.writer.run(`DELETE FROM ${SIZES_TABLE} WHERE sampled_at < $1::TIMESTAMPTZ`, [before]);
|
|
272
274
|
}
|
|
273
275
|
/**
|
|
274
276
|
* The samples from `since` until `until`, oldest first; with `stepSeconds`, re-bucketed in ONE query: buckets of that
|
package/dist/src/duck/users.js
CHANGED
|
@@ -59,6 +59,8 @@ export const newUserId = () => crypto.randomBytes(8).toString("hex");
|
|
|
59
59
|
export const userPassword = (apiKey, name, id) => `mason_${id}_${mac(apiKey, name, id)}`;
|
|
60
60
|
/** Whether a password is a user's by its shape (else it is read as a token, src/duck/token.ts). */
|
|
61
61
|
export const isUserPassword = (password) => PASSWORD_RE.test(password);
|
|
62
|
+
/** What every `mason share` password begins with (src/cli/share-run.ts `newSharePassword`): no user's has it, `share` being no hex. */
|
|
63
|
+
export const SHARE_PASSWORD_PREFIX = "mason_share_";
|
|
62
64
|
/** Whether a user was added under this API key (one rotated since is no longer live). */
|
|
63
65
|
export const underKey = (u, apiKey) => u.key === keyFingerprint(apiKey);
|
|
64
66
|
/** The users the file lists; none when there is no file; why not, when it cannot be read — never an empty list then. */
|
|
@@ -263,12 +265,15 @@ export const disabledMessage = (name) => `role "${name}" is not permitted to log
|
|
|
263
265
|
* HOW THE DUCKDB RUN'S LISTENER TAKES A PASSWORD (src/cli/dev.ts; the tests wire the same): a user's password (its shape)
|
|
264
266
|
* is the users file's to judge, and its session is asked again before every statement (a stat); anything else is read as a
|
|
265
267
|
* token, the kind `mason connect` minted before `mason user` (src/duck/token.ts: the API key used as a password is refused by name).
|
|
268
|
+
* A `mason share` password is refused by name too: it was never this port's, and making a user is not the way out.
|
|
266
269
|
*/
|
|
267
270
|
export function listenerLogins(users, apiKey) {
|
|
268
271
|
return {
|
|
269
272
|
authenticate: (password, user) => {
|
|
270
273
|
if (isUserPassword(password))
|
|
271
274
|
return users.login(user, password);
|
|
275
|
+
if (password.startsWith(SHARE_PASSWORD_PREFIX))
|
|
276
|
+
throw new Error("a mason share password logs in only at the address mason share printed, never on this port");
|
|
272
277
|
const problem = tokenProblem(apiKey, password);
|
|
273
278
|
if (problem !== null)
|
|
274
279
|
throw new Error(problem);
|