@mason-data/cli 0.0.0-stage → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +268 -2
- package/dist/src/api/error.js +18 -0
- package/dist/src/api/postgres/catalog-worker.js +92 -0
- package/dist/src/api/postgres/catalog.js +477 -0
- package/dist/src/api/postgres/encode.js +541 -0
- package/dist/src/api/postgres/explain.js +143 -0
- package/dist/src/api/postgres/listener.js +60 -0
- package/dist/src/api/postgres/parser-worker.js +58 -0
- package/dist/src/api/postgres/parser.js +121 -0
- package/dist/src/api/postgres/plain.js +33 -0
- package/dist/src/api/postgres/protocol.js +404 -0
- package/dist/src/api/postgres/schema.js +54 -0
- package/dist/src/api/postgres/server.js +1771 -0
- package/dist/src/api/postgres/translate.js +248 -0
- package/dist/src/api/request-log.js +50 -0
- package/dist/src/cdc/pg-wire.js +588 -0
- package/dist/src/cdc/pgoutput.js +107 -0
- package/dist/src/cdc/poll.js +67 -0
- package/dist/src/cdc/source.js +65 -0
- package/dist/src/cli/background.js +198 -0
- package/dist/src/cli/command.js +173 -0
- package/dist/src/cli/commands.js +1277 -0
- package/dist/src/cli/control.js +36 -0
- package/dist/src/cli/demo.js +149 -0
- package/dist/src/cli/dev.js +669 -0
- package/dist/src/cli/doctor.js +260 -0
- package/dist/src/cli/guides.js +158 -0
- package/dist/src/cli/init.js +366 -0
- package/dist/src/cli/machine.js +162 -0
- package/dist/src/cli/main.js +83 -0
- package/dist/src/cli/mcp.js +726 -0
- package/dist/src/cli/pg-tls.js +100 -0
- package/dist/src/cli/pg-url.js +52 -0
- package/dist/src/cli/pin.js +36 -0
- package/dist/src/cli/ports.js +118 -0
- package/dist/src/cli/preflight.js +347 -0
- package/dist/src/cli/project.js +129 -0
- package/dist/src/cli/readiness.js +76 -0
- package/dist/src/cli/registry.js +220 -0
- package/dist/src/cli/replication-checks.js +120 -0
- package/dist/src/cli/replication-mode.js +148 -0
- package/dist/src/cli/run-server.js +311 -0
- package/dist/src/cli/runs.js +120 -0
- package/dist/src/cli/security.js +86 -0
- package/dist/src/cli/self-signed-cert.js +64 -0
- package/dist/src/cli/serve.js +301 -0
- package/dist/src/cli/source-edit.js +116 -0
- package/dist/src/cli/sources.js +564 -0
- package/dist/src/cli/state.js +58 -0
- package/dist/src/cli/style.js +187 -0
- package/dist/src/cli/supervise.js +248 -0
- package/dist/src/cli/sync.js +94 -0
- package/dist/src/cli/ui.js +107 -0
- package/dist/src/cli/user.js +229 -0
- package/dist/src/cli/version.js +23 -0
- package/dist/src/demo/seed.js +237 -0
- package/dist/src/demo/source.js +655 -0
- package/dist/src/duck/analyze.js +113 -0
- package/dist/src/duck/cancel.js +49 -0
- package/dist/src/duck/distinct-key.js +228 -0
- package/dist/src/duck/executor-worker.js +268 -0
- package/dist/src/duck/executor.js +567 -0
- package/dist/src/duck/explain.js +238 -0
- package/dist/src/duck/functions.js +819 -0
- package/dist/src/duck/gaps.js +94 -0
- package/dist/src/duck/jsonb-order.js +175 -0
- package/dist/src/duck/jsonb.js +188 -0
- package/dist/src/duck/kept-route.js +299 -0
- package/dist/src/duck/kept.js +964 -0
- package/dist/src/duck/late.js +221 -0
- package/dist/src/duck/libc-order.js +190 -0
- package/dist/src/duck/pg-eval-worker.js +34 -0
- package/dist/src/duck/pg-eval.js +59 -0
- package/dist/src/duck/pgtree.js +378 -0
- package/dist/src/duck/plan.js +259 -0
- package/dist/src/duck/publication.js +207 -0
- package/dist/src/duck/requests.js +209 -0
- package/dist/src/duck/resolved.js +248 -0
- package/dist/src/duck/rewrite.js +5101 -0
- package/dist/src/duck/store.js +907 -0
- package/dist/src/duck/stream.js +518 -0
- package/dist/src/duck/sync.js +2929 -0
- package/dist/src/duck/to-char.js +482 -0
- package/dist/src/duck/token.js +31 -0
- package/dist/src/duck/types.js +427 -0
- package/dist/src/duck/users.js +231 -0
- package/dist/src/duck/verified.js +1001 -0
- package/dist/src/duck/views.js +150 -0
- package/dist/src/lib/database-url.js +74 -0
- package/dist/src/lib/did-you-mean.js +43 -0
- package/dist/src/lib/dotenv.js +72 -0
- package/dist/src/lib/exit-when-written.js +20 -0
- package/dist/src/lib/float-equal.js +69 -0
- package/dist/src/lib/http-json.js +22 -0
- package/dist/src/lib/log.js +161 -0
- package/dist/src/lib/pg-numeric.js +489 -0
- package/dist/src/lib/port.js +22 -0
- package/dist/src/lib/project-file.js +49 -0
- package/dist/src/lib/project-files.js +43 -0
- package/dist/src/lib/project-version.js +76 -0
- package/dist/src/lib/runnable.js +14 -0
- package/dist/src/lib/shell-word.js +4 -0
- package/dist/src/lib/timezone.js +237 -0
- package/dist/src/lib/zone-names.js +114 -0
- package/dist/src/web/app.css +76 -0
- package/dist/src/web/app.js +491 -0
- package/dist/src/web/index.html +44 -0
- package/package.json +22 -4
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHAT THE DUCKDB ENDPOINT KNOWS IT DOES NOT DO AS POSTGRES DOES: a list of what is known to be unsupported, and the
|
|
3
|
+
* steps that keep the list true. Every entry is one of:
|
|
4
|
+
* - `refused`: a statement using it is refused by name — SQLSTATE 0A000, the gap's id in the error's detail (or, for a
|
|
5
|
+
* value only the data shows, in the message), and the way out in the hint;
|
|
6
|
+
* - `differs`: answered, but not as Postgres answers (a decided difference: the oracle's KNOWN, which must still differ);
|
|
7
|
+
* - `limit`: not about a statement — what the prototype cannot do yet (reach, access, the poll's cost).
|
|
8
|
+
*
|
|
9
|
+
* HOW IT IS KEPT TRUE (tests/duck-gaps.test.ts, tests/integration/duck-oracle.itest.ts):
|
|
10
|
+
* - every refusal names an entry: `Unrewritable` takes a `GapId` (or Postgres's own refusal, which is not a gap), and a
|
|
11
|
+
* runtime refusal's message says `Mason gap <id>` of an id this file has;
|
|
12
|
+
* - the oracle accepts a refusal where Postgres answers ONLY when it names a `refused` entry, and fails when a
|
|
13
|
+
* `refused` or `differs` entry is met by none of its statements (a dead entry is removed, or its test named in
|
|
14
|
+
* `heldBy` instead, a test that names the id);
|
|
15
|
+
* - docs/SQL-SUPPORT.md is rendered from this file and the verified list (src/duck/verified.ts), and must be current.
|
|
16
|
+
* Anything not in src/duck/verified.ts is refused as `unverified`: this list is what is known BEYOND that.
|
|
17
|
+
*/
|
|
18
|
+
export const GAPS = {
|
|
19
|
+
// ---------------------------------------------------------------------------------------------- refused
|
|
20
|
+
unverified: { kind: "refused", what: "a function, operator, cast, type or construct the oracle has not held to Postgres", postgres: "answers", mason: "refused, naming each piece", wayOut: "write the statement without it; report it to have it verified", heldBy: "tests/duck-wire.test.ts" },
|
|
21
|
+
"explain-option": { kind: "refused", what: "EXPLAIN (FORMAT XML), (FORMAT YAML), (GENERIC_PLAN)", postgres: "the plan in XML or YAML; a plan for unbound parameters", mason: "refused by name: EXPLAIN is DuckDB's plan, in Postgres's text or JSON (docs/DESIGN.md)", wayOut: "FORMAT TEXT or FORMAT JSON; bind the parameters' values and EXPLAIN that" },
|
|
22
|
+
"setop-ordinal-star": { kind: "refused", what: "ORDER BY a column number of a UNION / INTERSECT / EXCEPT whose first SELECT lists *", postgres: "sorts by that column", mason: "refused", wayOut: "ORDER BY the column's name" },
|
|
23
|
+
"with-ties-distinct": { kind: "refused", what: "FETCH FIRST … WITH TIES over SELECT DISTINCT", postgres: "answers", mason: "refused (DuckDB has no WITH TIES; Mason's rank over DISTINCT is not Postgres's)", wayOut: "select the DISTINCT rows in a subquery and FETCH … WITH TIES from it" },
|
|
24
|
+
"collation-unknown": { kind: "refused", what: "COLLATE with a collation other than C, POSIX, default or an ICU locale DuckDB has", postgres: "sorts and compares under it", mason: "refused", wayOut: "COLLATE \"C\" for byte order, or no COLLATE for the database's" },
|
|
25
|
+
"like-any-array": { kind: "refused", what: "LIKE / ILIKE ANY (or ALL) over an array that is not written out", postgres: "answers", mason: "refused", wayOut: "write the patterns as ARRAY['…', '…'], or OR the LIKEs" },
|
|
26
|
+
"similar-to-forms": { kind: "refused", what: "SIMILAR TO with a pattern or ESCAPE that is not a literal, its substring markers, or substring(… SIMILAR … ESCAPE …)", postgres: "answers", mason: "refused", wayOut: "a literal pattern, or ~ with a POSIX regular expression" },
|
|
27
|
+
"array-remove-type": { kind: "refused", what: "array_remove of an array of a type other than text, integers, floats, booleans, dates or timestamps", postgres: "answers", mason: "refused", wayOut: "array_remove an array of one of those types" },
|
|
28
|
+
"json-agg-distinct": { kind: "refused", what: "json_agg / jsonb_agg (DISTINCT …)", postgres: "answers", mason: "refused", wayOut: "select the DISTINCT values in a subquery and aggregate them" },
|
|
29
|
+
"json-rows-in-select": { kind: "refused", what: "json(b)_array_elements / json(b)_each in the SELECT list", postgres: "a row per element", mason: "refused", wayOut: "read it in FROM: SELECT … FROM t, jsonb_array_elements(t.j) AS e" },
|
|
30
|
+
"regexp-flags": { kind: "refused", what: "regexp_replace with a start position or a count (its five- and six-argument forms)", postgres: "answers", mason: "refused", wayOut: "regexp_replace(text, pattern, replacement[, flags]): flags g for every match" },
|
|
31
|
+
"regexp-functions": { kind: "refused", what: "regexp_split_to_table / regexp_split_to_array / regexp_count / regexp_instr / regexp_substr / regexp_like", postgres: "answers", mason: "refused", wayOut: "~ to test, substring(text from pattern) to take a part, regexp_replace to replace" },
|
|
32
|
+
"regexp-matches-forms": { kind: "refused", what: "regexp_matches in FROM", postgres: "answers", mason: "refused", wayOut: "regexp_matches in the SELECT list" },
|
|
33
|
+
"json-of-type": { kind: "refused", what: "to_json / array_to_json / json_build_object of a value (or an array of elements) of a type Mason does not write as json", postgres: "answers", mason: "refused", wayOut: "cast the value to text, a number, a boolean, a timestamp or json first" },
|
|
34
|
+
"extract-julian": { kind: "refused", what: "extract(julian FROM …)", postgres: "answers", mason: "refused", wayOut: "extract(epoch FROM …) / 86400 + 2440587.5" },
|
|
35
|
+
"with-ordinality": { kind: "refused", what: "a function in FROM … WITH ORDINALITY", postgres: "numbers the rows", mason: "refused", wayOut: "row_number() OVER () instead" },
|
|
36
|
+
"array-subquery-element": { kind: "refused", what: "ARRAY(SELECT x …) whose x is a numeric, an array or a row; x = ANY or ALL (ARRAY(SELECT …))", postgres: "a numeric[] computed exactly, a multi-dimensional array (2202E on an empty element, 22004 on a NULL one), a record[]", mason: "refused (DuckDB makes a list of doubles, of lists, of structs; it binds ANY (ARRAY(SELECT …)) as a comparison of its own)", wayOut: "ARRAY(SELECT x::text …), array_agg(x ORDER BY …) in a subquery, or x = ANY (SELECT …) without the ARRAY" },
|
|
37
|
+
"multidim-array": { kind: "refused", what: "a multi-dimensional array literal", postgres: "answers", mason: "refused", wayOut: "a one-dimensional array" },
|
|
38
|
+
"format-specifiers": { kind: "refused", what: "format() with a specifier other than %s (%I, %L, widths, positions)", postgres: "answers", mason: "refused", wayOut: "concatenate with ||, or %s only" },
|
|
39
|
+
"to-char-patterns": { kind: "refused", what: "to_char of an interval or a time; its TZ, SP, EEEE, RN, V, B, L patterns; a day or month name padded to 9 characters in a parse", postgres: "answers", mason: "refused", wayOut: "the patterns listed in the hint; extract(… FROM interval) for an interval's parts" },
|
|
40
|
+
"to-timestamp-format": { kind: "refused", what: "to_timestamp / to_date with a format that is not a literal", postgres: "answers", mason: "refused", wayOut: "write the format as a string literal" },
|
|
41
|
+
"libc-uncovered": { kind: "refused", what: "ORDER BY, <, >, min / max of text (or of a jsonb, by its strings) holding a combining mark (e + U+0301), a Thai or Lao vowel written before its consonant, a private-use or unassigned code point, under a libc collation", postgres: "answers, in glibc's order", mason: "refused when a value holding one is ordered (the order is read from the database, and glibc orders these otherwise than Mason can follow)", wayOut: "normalize the text (normalize(x, NFC)), or ORDER BY x COLLATE \"C\" for byte order" },
|
|
42
|
+
"libc-symbol-punctuation": { kind: "differs", what: "text ordered under a libc collation where punctuation or a space follows a digit or a symbol and a later letter differs only in case ('1-X' beside '1x')", postgres: "the punctuation outranks the case: 1-X < 1x", mason: "the case outranks the punctuation: 1x < 1-X", wayOut: "ORDER BY lower(x), x when the case must not decide first" },
|
|
43
|
+
"percentile-text-libc": { kind: "refused", what: "percentile_disc / mode() WITHIN GROUP (ORDER BY text) under a libc collation", postgres: "answers", mason: "refused", wayOut: "order by a number, or ORDER BY … LIMIT in a subquery" },
|
|
44
|
+
"infinite-extract": { kind: "refused", what: "extract(year, decade, century, millennium, isoyear or epoch FROM) an infinite date or timestamp", postgres: "Infinity (a numeric)", mason: "refused when a row holds one (DuckDB's DECIMAL has no Infinity)", wayOut: "filter the infinite values out (isfinite(x)), or date_part(…), which is a double" },
|
|
45
|
+
"time-date-trunc": { kind: "refused", what: "date_trunc of a time (Postgres truncates it as an interval)", postgres: "an interval", mason: "refused", wayOut: "date_trunc of a timestamp, or extract the parts" },
|
|
46
|
+
"quote-ident": { kind: "refused", what: "quote_ident()", postgres: "quotes an identifier when it must be", mason: "refused (it needs Postgres's keyword list)", wayOut: "quote it in the statement, or format('%s', …)" },
|
|
47
|
+
"div-numeric": { kind: "refused", what: "div() of a value that is not an integer", postgres: "the integer quotient of two numerics", mason: "refused", wayOut: "trunc(a / b)" },
|
|
48
|
+
"jsonb-set": { kind: "refused", what: "jsonb_set, jsonb_insert, the #- operator, jsonb - of a text[] or an index", postgres: "answers", mason: "refused", wayOut: "build the value with jsonb_build_object" },
|
|
49
|
+
"jsonb-compare": { kind: "refused", what: "a jsonb compared by GREATEST / LEAST, WITHIN GROUP (ORDER BY …), IN or = ANY of a subquery or of an array not written out, BETWEEN SYMMETRIC, JOIN … USING or NATURAL JOIN, UNION / INTERSECT / EXCEPT (but UNION ALL), SELECT DISTINCT *, GROUPING SETS / ROLLUP / CUBE, or an aggregate's DISTINCT but count's; ORDER BY the number of a jsonb column a * lists; a row holding a jsonb compared, grouped or sorted; a jsonb[] sorted, grouped, compared, contained (@>, <@, &&) or searched (array_position, array_remove)", postgres: "answers, by jsonb's own order and equality", mason: "refused (ORDER BY, GROUP BY, DISTINCT [ON], =, <, >, IN (…), = ANY (ARRAY[…]), BETWEEN, IS DISTINCT FROM, NULLIF, CASE x WHEN, PARTITION BY and count(DISTINCT …) of a jsonb are answered in its order)", wayOut: "compare the jsonb in one of the answered forms, a member (j ->> 'k'), or j::text for the canonical text" },
|
|
50
|
+
"text-array-order": { kind: "refused", what: "ORDER BY, GROUP BY, DISTINCT, min, max or <, > over a text[] under the database's collation", postgres: "compares the elements under the collation", mason: "refused", wayOut: "sort by an element (a[1]), or by array_to_string(a, ',')" },
|
|
51
|
+
"sum-real": { kind: "refused", what: "sum() of a real (float4), as an aggregate or a window", postgres: "accumulates in single precision", mason: "refused (DuckDB accumulates in double precision)", wayOut: "sum(x::double precision)" },
|
|
52
|
+
"interval-aggregates": { kind: "refused", what: "sum() or avg() of an interval", postgres: "adds months, days and time apart", mason: "refused", wayOut: "sum(extract(epoch FROM x)) for the seconds" },
|
|
53
|
+
"bc-timestamptz-zone": { kind: "refused", what: "a timestamptz before year 1 written in a zone other than UTC", postgres: "the zone's local mean time", mason: "refused when a row holds one", wayOut: "SET TimeZone = 'UTC', or filter those rows out", heldBy: "tests/duck-wire.test.ts" },
|
|
54
|
+
"width-bucket-forms": { kind: "refused", what: "width_bucket with its bounds reversed, or of an array of thresholds", postgres: "answers", mason: "refused", wayOut: "low bound first" },
|
|
55
|
+
"inet-order": { kind: "refused", what: "ORDER BY, <, >, min or max over an inet", postgres: "orders addresses by network, then host", mason: "refused (equality, IS NULL and the value itself are answered)", wayOut: "sort or compare another column" },
|
|
56
|
+
"citext-forms": { kind: "refused", what: "GROUP BY, DISTINCT, min, max, a set operation, or = ANY (an array) over a citext", postgres: "groups and sorts without regard to case, answering one spelling", mason: "refused (=, <>, <, >, IN (…), LIKE and ORDER BY are answered, case-insensitively)", wayOut: "GROUP BY lower(x), or x::text where case matters" },
|
|
57
|
+
"bytea-forms": { kind: "refused", what: "encode / decode to a format other than hex, base64 or escape, or one not written as a literal; convert_to / convert_from of an encoding other than UTF8", postgres: "answers", mason: "refused", wayOut: "encode(x, 'hex'), 'base64' or 'escape'; the UTF8 encoding" },
|
|
58
|
+
"row-value": { kind: "refused", what: "a row as a value outside the SELECT list, a cast to text and the json functions (compared, tested with IS NULL, grouped, sorted); a row of a row; a row of a table Mason copied in part", postgres: "compares, tests and groups a row by its columns", mason: "refused", wayOut: "compare, test or sort its columns" },
|
|
59
|
+
"zone-abbreviation": { kind: "refused", what: "a time zone written as an abbreviation or a POSIX zone (`CST`, `IST`, `UTC+3`, `+05`) in AT TIME ZONE or date_trunc", postgres: "a fixed offset (`CST` is -06 all year; `UTC+3` is three hours WEST)", mason: "refused, written or held by a row (DuckDB reads several as another zone: `CST` as Chicago's, `IST` as India's, `UTC+3` as east)", wayOut: "an IANA zone name (America/Chicago, Asia/Kolkata), in any case; UT, Z and UTC are UTC", heldBy: "tests/duck-wire.test.ts" },
|
|
60
|
+
"volatile-window": { kind: "refused", what: "a window ordered or partitioned by random() or another volatile function (a random row per group)", postgres: "a new value per row", mason: "refused (DuckDB refuses a volatile expression there, and computes a deterministic one once for every row)", wayOut: "SELECT …, row_number() OVER (ORDER BY r) FROM (SELECT *, random() AS r FROM t) AS s" },
|
|
61
|
+
"numeric-special": { kind: "refused", what: "arithmetic on a numeric NaN or Infinity read from a text", postgres: "NaN and ±Infinity by its own rules (NaN + 1 is NaN)", mason: "refused when a row holds one", wayOut: "filter the special values out first (x <> 'NaN')" },
|
|
62
|
+
"numeric-exact-sum": { kind: "refused", what: "sum() or avg() of a numeric Postgres's own function computes here — a text read as an unconstrained numeric (Metabase's JSON number, `(j ->> 'k')::numeric`), ln, exp, sqrt, power — holding a value of more than 20 integer digits or 36 decimals besides trailing zeros, or summing past 20 integer digits", postgres: "adds every digit", mason: "refused when a row or the sum does (each value is summed exactly, in two DECIMAL(38,18) parts)", wayOut: "cast to numeric(p, s), or round(x, 18) first" },
|
|
63
|
+
"numeric-math": { kind: "refused", what: "ln, log, exp, sqrt or power of a numeric expression whose scale its types do not decide (a division, an unconstrained numeric's arithmetic)", postgres: "digits at a scale from the argument's own", mason: "refused (a column, an integer, a literal and a numeric(p, s) are answered by Postgres's own function)", wayOut: "cast the argument to numeric(p, s)" },
|
|
64
|
+
"view-forms": { kind: "refused", what: "a view's whole row as a value (`SELECT v FROM v`), a WITH query named as a view the statement reads, a column qualified by its view's schema beside another item of the view's name, views read through more than 16 others", postgres: "the view's row type; the WITH query or the view as each is in scope", mason: "refused (a view is answered by its definition in its place, as Postgres's rewriter answers it; its columns, `v.*` and `public.v.col` are answered)", wayOut: "name the view's columns; give the view or the WITH query another name" },
|
|
65
|
+
"enum-forms": { kind: "refused", what: "an array of an enum read from a text (`t::mood[]`)", postgres: "reads each element as a label", mason: "refused (a column, a label, an array literal and a text read as one label are answered)", wayOut: "write the array as a literal, or ARRAY[…] of its labels" },
|
|
66
|
+
"infinite-interval": { kind: "refused", what: "an infinite interval read from a text (Postgres 17+)", postgres: "infinity, -infinity", mason: "refused when a row holds one (DuckDB's interval has no infinity)", wayOut: "filter it out first, or read it as text" },
|
|
67
|
+
"relative-time-literal": { kind: "refused", what: "a relative time combined with anything else ('today 10:00', 'now'::date + … written as one literal), or read from a row as a date or a time", postgres: "the transaction's start (a row's value: when the row is read)", mason: "refused (a bare 'now', 'today', 'tomorrow', 'yesterday' literal is answered: the transaction's start at the Parse)", wayOut: "current_date + time '10:00', now(), current_date + 1" },
|
|
68
|
+
// ---------------------------------------------------------------------------------------------- differs
|
|
69
|
+
"limit-errors": { kind: "differs", what: "a value that raises (`'x'::int`, a bad JSON number) in a row a LIMIT leaves out: in the select list, where no index gives the ORDER BY; or in the argument of a LEFT JOIN of a list's rows ON TRUE (unnest(ARRAY[…, 'x'::int]), jsonb_each of a non-object), under a LIMIT with no ORDER BY", postgres: "the select list's raises: its plan sorts every row, its select list computed for each (through an index it stops at the limit and answers); the lateral's answers: it stops at the limit", mason: "the select list's answers: a sorted, limited select list is computed for the rows the limit keeps (src/duck/late.ts), as Postgres's index scan does; the lateral's raises: DuckDB computes its argument for a whole vector of rows (2,048) before the limit stops it", wayOut: "none needed for the select list's answer; filter the bad rows out to have neither raise" },
|
|
70
|
+
"limit-lateral-filter": { kind: "differs", what: "a LEFT JOIN of a json document's rows ON TRUE (json_each, jsonb_array_elements, …) under a LIMIT, or a row count an Execute sets on the wire, and no ORDER BY, with a filter above the join that DuckDB does not push down to the base: a WHERE on the lateral's own column (d.key <> 'n'), one holding a bound parameter ($1) in a SELECT around the join, or one in a SELECT that reads the join through a CTE", postgres: "the first rows of the base, in its order, each row's members in theirs", mason: "DuckDB plans the lateral under such a filter as a delimiter join and hands the rows back in an order of its own, so the LIMIT keeps other rows (a filter on the base in the join's own SELECT, literal or parameter, or a literal one around it through a derived table, keeps Postgres's)", wayOut: "ORDER BY the base's key before the LIMIT, or filter the base in the SELECT that holds the join", heldBy: "tests/duck-wire.test.ts" },
|
|
71
|
+
"limit-lateral-argument": { kind: "differs", what: "a LEFT JOIN of a list's rows ON TRUE (unnest, json_each, jsonb_array_elements, …) under a LIMIT, or a row count an Execute sets on the wire, and no ORDER BY, whose argument holds a subquery (unnest(ARRAY(SELECT …))), or reads two or more columns of the row and no array (CASE WHEN t.kind = 'doc' THEN t.body END) in a SELECT whose list is a bare * or inside a join with a name of its own ((a LEFT JOIN … ON TRUE) AS j)", postgres: "the first rows of the base, in its order, each row's members in theirs", mason: "DuckDB plans such a lateral as a delimiter join and hands the rows back in an order of its own, so the LIMIT keeps other rows (elsewhere an argument of two columns is computed in a step of its own before the lateral, which a * would read as a column; a subquery is a delimiter join in every form)", wayOut: "name the columns instead of *, or ORDER BY the base's key before the LIMIT" },
|
|
72
|
+
"window-partition-order": { kind: "differs", what: "a window function that reads its frame in order (row_number, lag, lead, first_value, last_value, nth_value, ntile) over a window with PARTITION BY and no ORDER BY", postgres: "a partition's rows in the order its sort hands them back: in practice the order they came in", mason: "a partition's rows in the order DuckDB's partitioning hands them back, another one", wayOut: "ORDER BY in the window (OVER (PARTITION BY a ORDER BY b))" },
|
|
73
|
+
"mixed-scale-numeric": { kind: "differs", what: "a numeric column whose values have several scales: an integer UNION ALL a numeric, lead(x, 1, 0) beside numeric(2,1) values, an unconstrained column holding 1 and 1.50", postgres: "each value its own scale: 1, 1.5", mason: "one scale per column: 1.0, 1.5", wayOut: "cast to numeric(p, s), or round(x, s)" },
|
|
74
|
+
"unicode-16-case": { kind: "differs", what: "upper / lower of letters added in Unicode 16", postgres: "maps them (its own Unicode 16 tables)", mason: "leaves them as they are (DuckDB's ICU predates Unicode 16)", wayOut: "none needed for letters before Unicode 16" },
|
|
75
|
+
"float-last-digit": { kind: "differs", what: "stddev / variance of a float, and ln, log, exp, power of a double: the last digit may differ", postgres: "its algorithm and its machine's libm", mason: "DuckDB's and its machine's: alike on some pairs of machines, apart in the 16th–17th digit on others", wayOut: "round(x::numeric, 12), or compare within a tolerance (abs(a - b) <= 1e-12 * abs(b))" },
|
|
76
|
+
"negative-zero": { kind: "differs", what: "-0 beside 0 in greatest / least / percentile_disc", postgres: "keeps the one it met first", mason: "may answer the other", wayOut: "x + 0 makes -0 into 0" },
|
|
77
|
+
"float-overflow": { kind: "differs", what: "exp / power of a double past its range, or so small it underflows", postgres: "an error (22003)", mason: "Infinity, or 0", wayOut: "bound the argument" },
|
|
78
|
+
"min-max-scale-tie": { kind: "differs", what: "min / max of a numeric expression (a quotient) where equal values have different scales (0 / 7 and 0 / 1000000)", postgres: "the last of them it reads, so the scan order decides the trailing zeros", mason: "the one whose operands are least (min) or greatest (max)", wayOut: "round(min(a / b), n)" },
|
|
79
|
+
"numeric-stddev": { kind: "differs", what: "stddev / variance of DISTINCT integers or numerics, or of a numeric declared without a precision or with more than 18 digits: the last digits", postgres: "exact numeric arithmetic, to its own scale", mason: "a double's 15 digits", wayOut: "round(stddev(x), 10)" },
|
|
80
|
+
"jsonb-equal-text": { kind: "differs", what: "GROUP BY or DISTINCT of jsonb values that are equal but written apart ('1.0' and '1.00', '{\"a\": 1}' and '{\"a\": 1.0}')", postgres: "one group, written as the value its plan reads first", mason: "one group, written as the value DuckDB reads first: either may be any of them", wayOut: "GROUP BY j::text where the text must decide", heldBy: "tests/duck-jsonb-order.test.ts" },
|
|
81
|
+
"char-n-minmax": { kind: "differs", what: "greatest / least of a char(n) beside a text", postgres: "compares without the padding, answers the first of equal values", mason: "compares the padded text", wayOut: "cast the char(n) to text first" },
|
|
82
|
+
"nested-exact-numeric": { kind: "differs", what: "an expression over a numeric a subquery computed exactly (a division, an avg, a round), outside that subquery: SELECT x * 2 FROM (SELECT a / 3 AS x …)", postgres: "exact digits at every step", mason: "the subquery's value as DuckDB computed it (a double's 15 digits) where the outer query computes with it; passed through as it is (SELECT *, s.x), it is exact", wayOut: "compute it in the outermost query, or round(…, n) inside" },
|
|
83
|
+
// ---------------------------------------------------------------------------------------------- limits
|
|
84
|
+
"one-token": { kind: "limit", what: "access", postgres: "roles, grants, row-level security", mason: "every user (mason user) and token reads every copied table: no per-user or per-tenant scope", wayOut: "copy only the tables every user may read" },
|
|
85
|
+
"poll-full-scan": { kind: "limit", what: "keeping current by the poll (mason start without --replication)", postgres: "-", mason: "the xmin poll scans every added table whole, every poll (no index covers xmin): ~13–17 s for 13 GiB", wayOut: "logical replication: mason start --replication" },
|
|
86
|
+
"writes": { kind: "limit", what: "INSERT, UPDATE, DELETE, DDL, a read-write transaction", postgres: "answers", mason: "refused by name: the copy is read-only (a read-write transaction: 25006)", wayOut: "write to the source database; the copy follows" },
|
|
87
|
+
"numeric-over-38-digits": { kind: "limit", what: "a numeric of more than 38 digits, or holding NaN or Infinity: a column, or a constant ('1e400'::numeric) inside DuckDB's arithmetic or a WHERE", postgres: "exact", mason: "a double (15 digits), said in the log for a column; a constant the outermost SELECT answers is exact", wayOut: "numeric(p, s) with p ≤ 38" },
|
|
88
|
+
"numeric-array": { kind: "limit", what: "an unconstrained numeric[] column", postgres: "exact elements", mason: "double precision elements (15 digits), said in the log", wayOut: "numeric(p, s)[]" },
|
|
89
|
+
"numeric-parameter": { kind: "limit", what: "a numeric parameter ($1) compared in a WHERE", postgres: "exact", mason: "a double (exact to 15 digits)", wayOut: "a literal" },
|
|
90
|
+
"log-literals": { kind: "limit", what: ".mason/mason.log's statement lines", postgres: "-", mason: "each statement DuckDB ran, whole, literals included", wayOut: "keep the log private" },
|
|
91
|
+
};
|
|
92
|
+
/** the words a runtime refusal carries for the oracle and a reader: `Mason gap <id>` */
|
|
93
|
+
export const gapMark = (id) => `Mason gap ${id}`;
|
|
94
|
+
export const GAP_MARK = /\bMason gap ([a-z0-9-]+)/;
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A `jsonb` AS POSTGRES WRITES IT (`jsonb_out`): DuckDB's JSON is the text it was given, Postgres's jsonb is a parsed
|
|
3
|
+
* value written back canonically — an object's keys sorted (shorter first, then by bytes, as `lengthCompareJsonbString`
|
|
4
|
+
* orders them) with a duplicate key's LAST value kept, `", "` between members and `": "` after a key, a number written
|
|
5
|
+
* as `numeric_out` writes it (`1e2` is `100`, `2.50` stays `2.50`, `-0` is `0`), a string's escapes as `escape_json`
|
|
6
|
+
* writes them (`é` becomes the character; `"`, `\`, and control characters escaped). The copy stores a jsonb
|
|
7
|
+
* column's text as the source sent it — already canonical — so this matters for a value a statement BUILDS
|
|
8
|
+
* (`'{"b":1,"a":2}'::jsonb`, `jsonb_build_object`, `->` of a literal).
|
|
9
|
+
*/
|
|
10
|
+
import { numericText, parseNumeric } from "../lib/pg-numeric.js";
|
|
11
|
+
/** Parses JSON keeping every number's text (JSON.parse would round `0.1000000000000000055511` and drop `2.50`'s zero). */
|
|
12
|
+
export function parseJson(text) {
|
|
13
|
+
let i = 0;
|
|
14
|
+
const ws = () => { while (i < text.length && " \t\n\r".includes(text[i]))
|
|
15
|
+
i++; };
|
|
16
|
+
const fail = (what) => { throw new Error(`invalid input syntax for type json: ${what} at offset ${i}`); };
|
|
17
|
+
const str = () => {
|
|
18
|
+
i++;
|
|
19
|
+
let out = "";
|
|
20
|
+
for (;;) {
|
|
21
|
+
const c = text[i++];
|
|
22
|
+
if (c === undefined)
|
|
23
|
+
return fail("a string is not closed");
|
|
24
|
+
if (c === '"')
|
|
25
|
+
return out;
|
|
26
|
+
if (c !== "\\") {
|
|
27
|
+
out += c;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const e = text[i++];
|
|
31
|
+
switch (e) {
|
|
32
|
+
case '"':
|
|
33
|
+
case "\\":
|
|
34
|
+
case "/":
|
|
35
|
+
out += e;
|
|
36
|
+
break;
|
|
37
|
+
case "b":
|
|
38
|
+
out += "\b";
|
|
39
|
+
break;
|
|
40
|
+
case "f":
|
|
41
|
+
out += "\f";
|
|
42
|
+
break;
|
|
43
|
+
case "n":
|
|
44
|
+
out += "\n";
|
|
45
|
+
break;
|
|
46
|
+
case "r":
|
|
47
|
+
out += "\r";
|
|
48
|
+
break;
|
|
49
|
+
case "t":
|
|
50
|
+
out += "\t";
|
|
51
|
+
break;
|
|
52
|
+
case "u": {
|
|
53
|
+
const hex = text.slice(i, i + 4);
|
|
54
|
+
if (!/^[0-9a-fA-F]{4}$/.test(hex))
|
|
55
|
+
return fail("a \\u escape needs four hex digits");
|
|
56
|
+
out += String.fromCharCode(parseInt(hex, 16));
|
|
57
|
+
i += 4;
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
default: return fail(`unknown escape \\${e ?? ""}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
const value = () => {
|
|
65
|
+
ws();
|
|
66
|
+
const start = i;
|
|
67
|
+
const v = bare();
|
|
68
|
+
return { ...v, start, end: i };
|
|
69
|
+
};
|
|
70
|
+
const bare = () => {
|
|
71
|
+
const c = text[i];
|
|
72
|
+
if (c === "{") {
|
|
73
|
+
i++;
|
|
74
|
+
const members = [];
|
|
75
|
+
ws();
|
|
76
|
+
if (text[i] === "}") {
|
|
77
|
+
i++;
|
|
78
|
+
return { t: "obj", v: members };
|
|
79
|
+
}
|
|
80
|
+
for (;;) {
|
|
81
|
+
ws();
|
|
82
|
+
if (text[i] !== '"')
|
|
83
|
+
return fail("an object key must be a string");
|
|
84
|
+
const k = str();
|
|
85
|
+
ws();
|
|
86
|
+
if (text[i++] !== ":")
|
|
87
|
+
return fail("a key must be followed by ':'");
|
|
88
|
+
members.push([k, value()]);
|
|
89
|
+
ws();
|
|
90
|
+
const sep = text[i++];
|
|
91
|
+
if (sep === "}")
|
|
92
|
+
return { t: "obj", v: members };
|
|
93
|
+
if (sep !== ",")
|
|
94
|
+
return fail("expected ',' or '}'");
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (c === "[") {
|
|
98
|
+
i++;
|
|
99
|
+
const items = [];
|
|
100
|
+
ws();
|
|
101
|
+
if (text[i] === "]") {
|
|
102
|
+
i++;
|
|
103
|
+
return { t: "arr", v: items };
|
|
104
|
+
}
|
|
105
|
+
for (;;) {
|
|
106
|
+
items.push(value());
|
|
107
|
+
ws();
|
|
108
|
+
const sep = text[i++];
|
|
109
|
+
if (sep === "]")
|
|
110
|
+
return { t: "arr", v: items };
|
|
111
|
+
if (sep !== ",")
|
|
112
|
+
return fail("expected ',' or ']'");
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (c === '"')
|
|
116
|
+
return { t: "str", v: str() };
|
|
117
|
+
for (const [word, v] of [["true", { t: "bool", v: true }], ["false", { t: "bool", v: false }], ["null", { t: "null" }]]) {
|
|
118
|
+
if (text.startsWith(word, i)) {
|
|
119
|
+
i += word.length;
|
|
120
|
+
return v;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const m = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec(text.slice(i));
|
|
124
|
+
if (m === null)
|
|
125
|
+
return fail("not a JSON value");
|
|
126
|
+
i += m[0].length;
|
|
127
|
+
return { t: "num", text: m[0] };
|
|
128
|
+
};
|
|
129
|
+
const v = value();
|
|
130
|
+
ws();
|
|
131
|
+
if (i < text.length)
|
|
132
|
+
fail("text after the value");
|
|
133
|
+
return v;
|
|
134
|
+
}
|
|
135
|
+
/** `escape_json`: a string as Postgres's json output quotes it */
|
|
136
|
+
export function quoteJson(s) {
|
|
137
|
+
let out = '"';
|
|
138
|
+
for (const ch of s) {
|
|
139
|
+
const code = ch.codePointAt(0);
|
|
140
|
+
if (ch === '"')
|
|
141
|
+
out += '\\"';
|
|
142
|
+
else if (ch === "\\")
|
|
143
|
+
out += "\\\\";
|
|
144
|
+
else if (ch === "\b")
|
|
145
|
+
out += "\\b";
|
|
146
|
+
else if (ch === "\f")
|
|
147
|
+
out += "\\f";
|
|
148
|
+
else if (ch === "\n")
|
|
149
|
+
out += "\\n";
|
|
150
|
+
else if (ch === "\r")
|
|
151
|
+
out += "\\r";
|
|
152
|
+
else if (ch === "\t")
|
|
153
|
+
out += "\\t";
|
|
154
|
+
else if (code < 0x20)
|
|
155
|
+
out += `\\u${code.toString(16).padStart(4, "0")}`;
|
|
156
|
+
else
|
|
157
|
+
out += ch;
|
|
158
|
+
}
|
|
159
|
+
return `${out}"`;
|
|
160
|
+
}
|
|
161
|
+
/** jsonb's key order: shorter first, then by the UTF-8 bytes (lengthCompareJsonbStringValue) */
|
|
162
|
+
function keyOrder(a, b) {
|
|
163
|
+
const x = Buffer.from(a, "utf8"), y = Buffer.from(b, "utf8");
|
|
164
|
+
return x.length - y.length || Buffer.compare(x, y);
|
|
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
|
+
}
|
|
174
|
+
/** a parsed value as jsonb writes it */
|
|
175
|
+
export function writeJson(v) {
|
|
176
|
+
switch (v.t) {
|
|
177
|
+
case "null": return "null";
|
|
178
|
+
case "bool": return v.v ? "true" : "false";
|
|
179
|
+
case "num": return numericText(parseNumeric(v.text));
|
|
180
|
+
case "str": return quoteJson(v.v);
|
|
181
|
+
case "arr": return `[${v.v.map(writeJson).join(", ")}]`;
|
|
182
|
+
case "obj": return `{${storedPairs(v.v).map(([k, x]) => `${quoteJson(k)}: ${writeJson(x)}`).join(", ")}}`;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
/** A JSON text as Postgres's jsonb writes it; throws a json syntax error for a text that is no JSON. */
|
|
186
|
+
export function jsonbText(text) {
|
|
187
|
+
return writeJson(parseJson(text));
|
|
188
|
+
}
|