@mason-data/cli 0.6.2 → 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 (45) hide show
  1. package/README.md +71 -12
  2. package/dist/src/api/postgres/catalog.js +36 -1
  3. package/dist/src/api/postgres/server.js +133 -32
  4. package/dist/src/cdc/pg-wire.js +144 -38
  5. package/dist/src/cli/background.js +29 -5
  6. package/dist/src/cli/command.js +4 -3
  7. package/dist/src/cli/commands.js +259 -127
  8. package/dist/src/cli/demo.js +149 -0
  9. package/dist/src/cli/dev.js +113 -20
  10. package/dist/src/cli/doctor.js +6 -3
  11. package/dist/src/cli/guides.js +32 -8
  12. package/dist/src/cli/init.js +58 -18
  13. package/dist/src/cli/machine.js +8 -3
  14. package/dist/src/cli/main.js +2 -2
  15. package/dist/src/cli/mcp.js +726 -0
  16. package/dist/src/cli/preflight.js +16 -7
  17. package/dist/src/cli/project.js +5 -3
  18. package/dist/src/cli/registry.js +8 -5
  19. package/dist/src/cli/run-server.js +29 -106
  20. package/dist/src/cli/serve.js +6 -1
  21. package/dist/src/cli/source-edit.js +116 -0
  22. package/dist/src/cli/sources.js +6 -4
  23. package/dist/src/demo/seed.js +237 -0
  24. package/dist/src/demo/source.js +655 -0
  25. package/dist/src/duck/distinct-key.js +228 -0
  26. package/dist/src/duck/executor-worker.js +7 -2
  27. package/dist/src/duck/executor.js +67 -26
  28. package/dist/src/duck/explain.js +128 -9
  29. package/dist/src/duck/functions.js +60 -5
  30. package/dist/src/duck/gaps.js +9 -4
  31. package/dist/src/duck/jsonb-order.js +175 -0
  32. package/dist/src/duck/jsonb.js +10 -8
  33. package/dist/src/duck/kept-route.js +82 -2
  34. package/dist/src/duck/kept.js +206 -39
  35. package/dist/src/duck/pgtree.js +40 -0
  36. package/dist/src/duck/plan.js +21 -8
  37. package/dist/src/duck/rewrite.js +1168 -68
  38. package/dist/src/duck/store.js +132 -56
  39. package/dist/src/duck/sync.js +90 -25
  40. package/dist/src/duck/verified.js +6 -0
  41. package/dist/src/lib/timezone.js +3 -2
  42. package/dist/src/web/app.css +11 -0
  43. package/dist/src/web/app.js +167 -12
  44. package/dist/src/web/index.html +6 -1
  45. package/package.json +1 -1
@@ -0,0 +1,237 @@
1
+ /**
2
+ * THE DEMO DATABASE'S SCHEMA AND ROWS (`mason init demo`, src/demo/source.ts): a small web shop a BI user recognises at
3
+ * once — `customers`, `products`, `orders`, `order_items` — with primary and foreign keys declared, about 200 / 50 /
4
+ * 5,000 / 15,000 rows, and the orders' dates spread over the twelve months before the seed, so a time-window chart has
5
+ * data in every week.
6
+ *
7
+ * SYNTHETIC ONLY (CLAUDE.md "A real database's data and structures are confidential"): every name below is made up
8
+ * here, from short word lists, and every email is at example.com.
9
+ *
10
+ * DETERMINISTIC: one seeded PRNG (`mulberry32`) draws every value, so every demo holds the same rows; only the dates are
11
+ * anchored to the moment of the seed (`now`), so "the last 30 days" has orders whenever the demo was made. The rounds of
12
+ * changes (`changeSql`) draw from a PRNG seeded by the newest order's id, so they are the same sequence too, across
13
+ * restarts.
14
+ *
15
+ * WRITTEN AS A FEW MULTI-ROW INSERTs (`ROWS_PER_INSERT` a statement), never one per row: PGlite is slow per statement.
16
+ * Prices and totals are counted in integer cents and printed as decimals, so a total is exactly its items' sum.
17
+ */
18
+ /** A small, fast, seeded PRNG (mulberry32): the same seed, the same sequence, on every machine. */
19
+ export function mulberry32(seed) {
20
+ let a = seed >>> 0;
21
+ return () => {
22
+ a = (a + 0x6d2b79f5) >>> 0;
23
+ let t = a;
24
+ t = Math.imul(t ^ (t >>> 15), t | 1);
25
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
26
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
27
+ };
28
+ }
29
+ /** The demo database's name and its user: what its URL says, and what `current_database()` and `current_user` are. */
30
+ export const DEMO_DATABASE = "demo";
31
+ export const DEMO_USER = "demo";
32
+ /** `postgres://demo@127.0.0.1:<port>/demo`: the demo database's URL, its port the one fact that differs. */
33
+ export function demoUrl(port) {
34
+ return `postgres://${DEMO_USER}@127.0.0.1:${port}/${DEMO_DATABASE}`;
35
+ }
36
+ export const SEED_COUNTS = { customers: 200, products: 50, orders: 5_000 };
37
+ /** the database's four tables, in the order they are made (a foreign key's target first) */
38
+ export const DEMO_TABLES = ["customers", "products", "orders", "order_items"];
39
+ /**
40
+ * THE TABLE `mason init demo` LEAVES OUT of the sources file, for the person to add themselves: the first thing they do
41
+ * after `mason start` is a real `mason add` (or the web page's Add), and they see a copy begin. `order_items`, because
42
+ * its foreign keys' targets (`orders`, `products`) are among the ones added, so it joins cleanly when it arrives. The
43
+ * database holds it regardless, and the writer (`changeSql`) keeps inserting into it.
44
+ */
45
+ export const DEMO_TABLE_TO_ADD = "order_items";
46
+ /** the tables `mason init demo` writes to the sources file: the database's, but the one left to add */
47
+ export const DEMO_ADDED_TABLES = DEMO_TABLES.filter((t) => t !== DEMO_TABLE_TO_ADD);
48
+ /** an order's status moves along this path (`changeSql`); `cancelled` is a side exit */
49
+ export const ORDER_STATUSES = ["pending", "paid", "shipped", "delivered"];
50
+ const ROWS_PER_INSERT = 1_000;
51
+ const DAY_MS = 86_400_000;
52
+ const FIRST = ["Ada", "Ben", "Cleo", "Dev", "Eli", "Fay", "Gus", "Hana", "Ivo", "Jun", "Kai", "Lea", "Milo", "Nia", "Otto",
53
+ "Pia", "Quin", "Rae", "Sol", "Tess", "Uma", "Vic", "Wren", "Xavi", "Yara", "Zed"];
54
+ const LAST = ["Abbott", "Brook", "Castell", "Dorn", "Ember", "Fallow", "Grove", "Hollis", "Ingram", "Juniper", "Keel",
55
+ "Lark", "Marsh", "Nettle", "Orchard", "Penn", "Quarry", "Rook", "Sorrel", "Thorne", "Vale", "Wilde"];
56
+ /** weighted: the first countries hold most customers, as a shop's do */
57
+ const COUNTRIES = ["United States", "United States", "United States", "United Kingdom", "United Kingdom", "Germany",
58
+ "Germany", "France", "Canada", "Australia", "Netherlands", "Spain", "Japan", "Brazil", "India"];
59
+ const CATEGORIES = {
60
+ Books: { nouns: ["Novel", "Cookbook", "Atlas", "Field Guide", "Notebook"], cents: [800, 4_500] },
61
+ Electronics: { nouns: ["Headphones", "Speaker", "Charger", "Keyboard", "Webcam"], cents: [1_500, 25_000] },
62
+ Home: { nouns: ["Lamp", "Rug", "Mug", "Throw", "Vase"], cents: [1_200, 12_000] },
63
+ Garden: { nouns: ["Trowel", "Planter", "Hose", "Seed Kit", "Bird Feeder"], cents: [900, 8_000] },
64
+ Sports: { nouns: ["Yoga Mat", "Water Bottle", "Jump Rope", "Backpack", "Cap"], cents: [1_000, 9_000] },
65
+ };
66
+ const ADJECTIVES = ["Oak", "Slate", "Cedar", "Coral", "Indigo", "Amber", "Birch", "Moss", "Pebble", "Saffron"];
67
+ /** a SQL string literal (the seed's own words only, but quoted properly all the same) */
68
+ const lit = (s) => `'${s.replace(/'/g, "''")}'`;
69
+ const money = (cents) => `${Math.floor(cents / 100)}.${String(cents % 100).padStart(2, "0")}`;
70
+ const stamp = (ms) => lit(new Date(ms).toISOString());
71
+ /** `INSERT INTO t (cols) VALUES …` in statements of ROWS_PER_INSERT rows */
72
+ function inserts(table, columns, rows) {
73
+ const out = [];
74
+ for (let i = 0; i < rows.length; i += ROWS_PER_INSERT) {
75
+ out.push(`INSERT INTO ${table} (${columns}) VALUES\n${rows.slice(i, i + ROWS_PER_INSERT).join(",\n")}`);
76
+ }
77
+ return out;
78
+ }
79
+ const DDL = `CREATE TABLE customers (
80
+ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
81
+ name text NOT NULL,
82
+ email text NOT NULL UNIQUE,
83
+ country text NOT NULL,
84
+ created_at timestamptz NOT NULL DEFAULT now()
85
+ );
86
+ CREATE TABLE products (
87
+ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
88
+ name text NOT NULL,
89
+ category text NOT NULL,
90
+ price numeric(10,2) NOT NULL
91
+ );
92
+ CREATE TABLE orders (
93
+ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
94
+ customer_id bigint NOT NULL REFERENCES customers (id),
95
+ status text NOT NULL,
96
+ total numeric(12,2) NOT NULL,
97
+ created_at timestamptz NOT NULL DEFAULT now()
98
+ );
99
+ CREATE TABLE order_items (
100
+ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
101
+ order_id bigint NOT NULL REFERENCES orders (id),
102
+ product_id bigint NOT NULL REFERENCES products (id),
103
+ quantity integer NOT NULL,
104
+ unit_price numeric(10,2) NOT NULL
105
+ );
106
+ CREATE INDEX order_items_order_id ON order_items (order_id);
107
+ CREATE INDEX orders_customer_id ON orders (customer_id);
108
+ COMMENT ON TABLE orders IS 'One row per checkout; total is the sum of its order_items';`;
109
+ /** The product catalogue: its prices in cents, by id (1-based), which the orders' items read. */
110
+ function products(rand) {
111
+ const names = Object.keys(CATEGORIES);
112
+ const out = [];
113
+ for (let i = 0; i < SEED_COUNTS.products; i++) {
114
+ const category = names[i % names.length];
115
+ const c = CATEGORIES[category];
116
+ const noun = c.nouns[Math.floor(i / names.length) % c.nouns.length];
117
+ const adjective = ADJECTIVES[Math.floor(i / (names.length * c.nouns.length)) % ADJECTIVES.length];
118
+ // a price ending in 99 or 49, as a shop's do
119
+ const cents = Math.floor((c.cents[0] + rand() * (c.cents[1] - c.cents[0])) / 100) * 100 + (rand() < 0.7 ? 99 : 49);
120
+ out.push({ name: `${adjective} ${noun}`, category, cents });
121
+ }
122
+ return out;
123
+ }
124
+ /** The status of an order placed `ageDays` ago: the old ones delivered (some cancelled), the newest still moving. */
125
+ function statusOf(ageDays, rand) {
126
+ if (rand() < 0.04)
127
+ return "cancelled";
128
+ if (ageDays > 10)
129
+ return "delivered";
130
+ if (ageDays > 5)
131
+ return rand() < 0.6 ? "delivered" : "shipped";
132
+ if (ageDays > 2)
133
+ return rand() < 0.5 ? "shipped" : "paid";
134
+ return rand() < 0.5 ? "paid" : "pending";
135
+ }
136
+ /**
137
+ * The whole seed, as statements to run in order in ONE transaction: the tables, their rows, the identity sequences moved
138
+ * past the ids written, and ANALYZE (run by the caller, outside the transaction) so the catalog's row estimates are the
139
+ * counts. `now` anchors the dates: the orders span the 365 days before it.
140
+ */
141
+ export function seedStatements(now) {
142
+ const rand = mulberry32(20_261_027);
143
+ const end = now.getTime();
144
+ const start = end - 365 * DAY_MS;
145
+ const customerRows = [];
146
+ const customerSince = [];
147
+ for (let id = 1; id <= SEED_COUNTS.customers; id++) {
148
+ const first = FIRST[Math.floor(rand() * FIRST.length)];
149
+ const last = LAST[Math.floor(rand() * LAST.length)];
150
+ const country = COUNTRIES[Math.floor(rand() * COUNTRIES.length)];
151
+ // customers sign up over the year before the first orders and the year itself
152
+ const since = start - 180 * DAY_MS + Math.floor(rand() * 500 * DAY_MS);
153
+ customerSince.push(Math.min(since, end - DAY_MS));
154
+ customerRows.push(`(${id}, ${lit(`${first} ${last}`)}, ${lit(`${first}.${last}.${id}@example.com`.toLowerCase())}, ${lit(country)}, ${stamp(customerSince[id - 1])})`);
155
+ }
156
+ const catalogue = products(rand);
157
+ const productRows = catalogue.map((p, i) => `(${i + 1}, ${lit(p.name)}, ${lit(p.category)}, ${money(p.cents)})`);
158
+ // the orders' times: more of them as the year goes on (a growing shop), sorted so ids follow time, as an app's do
159
+ const times = [];
160
+ for (let i = 0; i < SEED_COUNTS.orders; i++)
161
+ times.push(start + Math.floor(Math.sqrt(rand()) * (end - start - 60_000)));
162
+ times.sort((a, b) => a - b);
163
+ const orderRows = [];
164
+ const itemRows = [];
165
+ let itemId = 0;
166
+ for (let i = 0; i < times.length; i++) {
167
+ const id = i + 1;
168
+ const at = times[i];
169
+ // a customer who had signed up by then; a few customers order far more often (the square skews to low ids)
170
+ let customer = 1 + Math.floor(rand() * rand() * SEED_COUNTS.customers);
171
+ if (customerSince[customer - 1] > at)
172
+ customer = 1 + Math.floor(rand() * SEED_COUNTS.customers);
173
+ const lines = 1 + Math.floor(rand() * 5); // 1–5 items, about 3 on average
174
+ let total = 0;
175
+ for (let l = 0; l < lines; l++) {
176
+ const product = 1 + Math.floor(rand() * catalogue.length);
177
+ const quantity = rand() < 0.75 ? 1 : 2 + Math.floor(rand() * 3);
178
+ const cents = catalogue[product - 1].cents;
179
+ total += quantity * cents;
180
+ itemRows.push(`(${++itemId}, ${id}, ${product}, ${quantity}, ${money(cents)})`);
181
+ }
182
+ orderRows.push(`(${id}, ${customer}, ${lit(statusOf((end - at) / DAY_MS, rand))}, ${money(total)}, ${stamp(at)})`);
183
+ }
184
+ return {
185
+ ddl: DDL,
186
+ rows: [
187
+ ...inserts("customers", "id, name, email, country, created_at", customerRows),
188
+ ...inserts("products", "id, name, category, price", productRows),
189
+ ...inserts("orders", "id, customer_id, status, total, created_at", orderRows),
190
+ ...inserts("order_items", "id, order_id, product_id, quantity, unit_price", itemRows),
191
+ ],
192
+ // the identity columns continue past the ids written, so a later insert (`changeSql`) takes the next one
193
+ after: DEMO_TABLES.map((t) => `SELECT setval(pg_get_serial_sequence('${t}', 'id'), (SELECT max(id) FROM ${t}))`),
194
+ };
195
+ }
196
+ /** What one round of changes reads first: the newest order's id, the products' prices, and the oldest order still moving. */
197
+ export const CHANGE_STATE_SQL = `SELECT (SELECT coalesce(max(id), 0) FROM orders)::text AS newest,
198
+ (SELECT count(*) FROM customers)::text AS customers,
199
+ (SELECT coalesce(json_agg(json_build_array(id, (price * 100)::bigint) ORDER BY id), '[]'::json)::text FROM products) AS prices,
200
+ (SELECT json_build_array(id, status)::text FROM orders WHERE status IN ('pending', 'paid', 'shipped') ORDER BY created_at, id LIMIT 1) AS moving`;
201
+ /**
202
+ * ONE ROUND OF CHANGES, as one transaction's statements: a new order (now, `pending`) with 1–4 items and its total, and
203
+ * the oldest order still moving moved one step on its path (pending → paid → shipped → delivered). Its values are drawn
204
+ * from a PRNG seeded by the newest order's id, so the rounds are the same sequence on every demo. Answers the statements
205
+ * (the new order's id read back last, as `id`) and what they did, in one line, given that id.
206
+ */
207
+ export function changeSql(s) {
208
+ const rand = mulberry32(s.newest * 7_919 + 17);
209
+ const customer = 1 + Math.floor(rand() * Math.max(1, s.customers));
210
+ const lines = 1 + Math.floor(rand() * 4);
211
+ const items = [];
212
+ for (let l = 0; l < lines && s.prices.length > 0; l++) {
213
+ const [product, cents] = s.prices[Math.floor(rand() * s.prices.length)];
214
+ items.push({ product, quantity: rand() < 0.75 ? 1 : 2, cents });
215
+ }
216
+ const total = items.reduce((sum, i) => sum + i.quantity * i.cents, 0);
217
+ const statements = [
218
+ // the order's id is its identity's next, read back by the items through currval
219
+ `INSERT INTO orders (customer_id, status, total, created_at) VALUES (${customer}, 'pending', ${money(total)}, now())`,
220
+ ...(items.length === 0 ? [] : [`INSERT INTO order_items (order_id, product_id, quantity, unit_price) VALUES ` +
221
+ items.map((i) => `(currval(pg_get_serial_sequence('orders', 'id')), ${i.product}, ${i.quantity}, ${money(i.cents)})`).join(", ")]),
222
+ ];
223
+ let moved = "";
224
+ if (s.moving !== null) {
225
+ const [id, status] = s.moving;
226
+ const next = ORDER_STATUSES[ORDER_STATUSES.indexOf(status) + 1];
227
+ if (next !== undefined) {
228
+ statements.push(`UPDATE orders SET status = ${lit(next)} WHERE id = ${id}`);
229
+ moved = `; order ${id} ${status} → ${next}`;
230
+ }
231
+ }
232
+ statements.push(`SELECT currval(pg_get_serial_sequence('orders', 'id'))::text AS id`);
233
+ return {
234
+ statements,
235
+ said: (orderId) => `order ${orderId} placed by customer ${customer} (${items.length} item${items.length === 1 ? "" : "s"}, ${money(total)})${moved}`,
236
+ };
237
+ }