@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,229 @@
1
+ /**
2
+ * `mason user add | list | remove` — NAMED, REVOCABLE LOGINS FOR THE POSTGRES API, WITH NO EXPIRY
3
+ * (docs/DESIGN.md "On a server", item 1 of the agreed plan; src/duck/users.ts holds the rules). A BI
4
+ * tool's datasource stores ONE password: a user's lasts until it is removed.
5
+ *
6
+ * It runs as a second process beside the run (or beside `mason serve`: `docker compose exec mason mason user add
7
+ * metabase`, the data folder's `.mason/`), so it needs the project, never the run: it edits `.mason/users.json` under
8
+ * the file's lock, and the run's listener reads the change at its next connection or statement. The password is said
9
+ * ONCE, by `add`, with the URL to paste (one URL builder, pg-url.ts `connectionUrl`) when the run is up and recorded
10
+ * where it listens. The web page's users (`mason ui`) are the same functions (`addUser`, `listUsers`, `removeUser`),
11
+ * called by the run's command server.
12
+ */
13
+ import * as path from "node:path";
14
+ import { failed, jsonText, parseCommand, reportText, withEnd } from "./command.js";
15
+ import { reach } from "./state.js";
16
+ import { connectionUrl, listenerTips, postgresLink, psqlLine } from "./pg-url.js";
17
+ import { changeUsers, keyFingerprint, newUserId, readUsers, underKey, userPassword, USER_NAME_RE, USER_NAME_RULE, USERS_FILE } from "../duck/users.js";
18
+ import { didYouMean } from "../lib/did-you-mean.js";
19
+ import { out as styleOut } from "./style.js";
20
+ const out = (m) => { process.stdout.write(`${m}\n`); };
21
+ export const USER = {
22
+ needs: "project",
23
+ name: "user",
24
+ summary: "the users the Postgres API takes: a name and a password that does not expire, for a BI tool or an app",
25
+ usage: `usage: mason user add <name> [--json]
26
+ mason user list [--json]
27
+ mason user remove <name> [--json]
28
+
29
+ A user of this project's Postgres API: a name and a password, as a BI tool's datasource form asks for them
30
+ (Metabase, Grafana, Superset, Power BI) or an app's DATABASE_URL carries them. Its password does not expire:
31
+ it connects until mason user remove, which refuses its next connection at once and
32
+ ends the sessions it holds at their next statement, without a restart. The password is printed once, by add.
33
+ The user name a tool sends must be the user's own.
34
+
35
+ The list is .mason/users.json (names and ids, no password), beside the running Mason, which reads it again by itself.
36
+ Rotating the API key (apiKey in .mason/config.json, MASON_API_KEY under mason serve) removes every user at once.
37
+
38
+ mason user add metabase a user for Metabase: its password and the URL to paste
39
+ mason user list
40
+ mason user remove metabase metabase connects no more
41
+ docker compose exec mason mason user add grafana beside mason serve, in its data folder
42
+
43
+ <name> ${USER_NAME_RULE}
44
+ --json as data: add { user (the name), id, addedAt, password, database, and once Mason runs url, host, port,
45
+ sslmode }; list { users: [{ name, id, addedAt, live }] }; remove { removed: { name, id, addedAt } }; next
46
+ `,
47
+ options: { json: { type: "boolean", default: false } },
48
+ json: ["user", "id", "addedAt", "password", "url", "host", "port", "database", "sslmode", "users", "removed", "next"],
49
+ };
50
+ /** The users of a project and why one is not live: added under an API key since rotated. */
51
+ const liveOf = (u, apiKey) => underKey(u, apiKey);
52
+ /** What to run after an add: with Mason up, the list and how to end it; with Mason stopped, mason start first. */
53
+ export const addedNext = (name, up) => up
54
+ ? [["mason user list", "the users"], [`mason user remove ${name}`, "it connects no more"]]
55
+ : [["mason start", "start Mason: then mason status prints where the Postgres API listens"], ["mason user list", "the users"]];
56
+ /** What to run after a remove. */
57
+ export const REMOVED_NEXT = [["mason user list", "the users left"], ["mason user add <name>", "a new user, with a new password"]];
58
+ /** Why `name` is not one `mason user <verb>` takes (a usage failure), or null. */
59
+ export function userNameProblem(verb, name) {
60
+ if (name === undefined)
61
+ return `mason user ${verb} <name>: which user`;
62
+ if (!USER_NAME_RE.test(name))
63
+ return `${JSON.stringify(name)} is not a user name: ${USER_NAME_RULE}`;
64
+ return null;
65
+ }
66
+ const cantChange = (file, e) => ({ message: `the list of users could not be changed: ${e.message}`, code: "failed",
67
+ fix: `${file} is the list (mason user writes it): repair it, or move it aside, which removes every user` });
68
+ /** The users `file` lists, each with whether it connects under `apiKey`; or why the file cannot be read. */
69
+ export function listUsers(file, apiKey) {
70
+ const r = readUsers(file);
71
+ if ("problem" in r)
72
+ return { message: `the list of users cannot be read: ${r.problem}`, code: "bad_config", why: "no user connects while it cannot be read",
73
+ fix: `repair ${file}, or move it aside, which removes every user` };
74
+ return { users: r.users.map((u) => ({ name: u.name, id: u.id, addedAt: u.addedAt, live: liveOf(u, apiKey) })) };
75
+ }
76
+ /**
77
+ * ADD A USER to `file` under its lock: a new id, its password (said here, once, and kept nowhere), and — `link`, where
78
+ * the run's Postgres API listens, when it is up — the URL to paste. A name taken by a live user is refused.
79
+ */
80
+ export function addUser(file, apiKey, name, o) {
81
+ const usage = userNameProblem("add", name);
82
+ if (usage !== null)
83
+ return { message: usage, code: "usage" };
84
+ const fingerprint = keyFingerprint(apiKey);
85
+ let added;
86
+ let replaced = false;
87
+ let r;
88
+ try {
89
+ r = changeUsers(file, (users) => {
90
+ const was = users.find((u) => u.name === name);
91
+ // a user added under a key since rotated connects no more: its name is free, said
92
+ if (was !== undefined && was.key === fingerprint)
93
+ return { refused: "taken" };
94
+ replaced = was !== undefined;
95
+ added = { name, id: newUserId(), addedAt: new Date().toISOString(), key: fingerprint };
96
+ return [...users.filter((u) => u !== was), added];
97
+ });
98
+ }
99
+ catch (e) {
100
+ return cantChange(file, e);
101
+ }
102
+ if (!Array.isArray(r)) {
103
+ return { message: `there is already a user ${JSON.stringify(name)}`, code: "refused",
104
+ why: "a name names one user, so removing it means one thing; its password is not kept, so it cannot be printed again",
105
+ fix: `mason user remove ${name}, then mason user add ${name} (a new password; the old one stops connecting), or add another name (${name}-2) and remove ${name} once the tool uses it`,
106
+ next: [["mason user list", "the users"], [`mason user remove ${name}`, "then add it again, with a new password"]] };
107
+ }
108
+ const u = added;
109
+ const password = userPassword(apiKey, u.name, u.id);
110
+ const { link } = o;
111
+ return { user: u.name, id: u.id, addedAt: u.addedAt, password, database: link?.database ?? o.database,
112
+ ...(link !== null ? { url: connectionUrl(link, u.name, password), host: link.host, port: link.port, sslmode: link.sslmode ?? "disable" } : {}), replaced };
113
+ }
114
+ /** REMOVE A USER from `file` under its lock: it connects no more. A name the file lacks is refused with the closest it has. */
115
+ export function removeUser(file, name) {
116
+ const usage = userNameProblem("remove", name);
117
+ if (usage !== null)
118
+ return { message: usage, code: "usage" };
119
+ let removed;
120
+ let live = [];
121
+ let r;
122
+ try {
123
+ r = changeUsers(file, (users) => {
124
+ removed = users.find((u) => u.name === name);
125
+ live = users.map((u) => u.name);
126
+ return removed === undefined ? { refused: "unknown" } : users.filter((u) => u !== removed);
127
+ });
128
+ }
129
+ catch (e) {
130
+ return cantChange(file, e);
131
+ }
132
+ if (!Array.isArray(r)) {
133
+ return { message: `there is no user ${JSON.stringify(name)}${didYouMean(name, live)}`, code: "not_found",
134
+ why: live.length ? `the users: ${live.join(", ")}` : "this project has no users", next: [["mason user list", "the users"]] };
135
+ }
136
+ const gone = removed;
137
+ return { removed: { name: gone.name, id: gone.id, addedAt: gone.addedAt } };
138
+ }
139
+ // ------------------------------------------------------------------ the command
140
+ export async function user(argv) {
141
+ const f = parseCommand(USER, argv);
142
+ if (typeof f === "number")
143
+ return f;
144
+ const json = f.values.json === true;
145
+ const usage = (message) => failed("user", { message, code: "usage", usage: USER.usage }, { json });
146
+ const [verb, name, ...rest] = f.positionals;
147
+ if (verb === undefined)
148
+ return usage("add, list or remove: mason user add <name> makes a user");
149
+ if (!["add", "list", "remove"].includes(verb))
150
+ return usage(`there is no mason user ${verb}: add, list or remove${didYouMean(verb, ["add", "list", "remove"])}`);
151
+ if (verb === "list" ? name !== undefined : rest.length)
152
+ return usage(`mason user ${verb} takes ${verb === "list" ? "no name" : "one name"}: ${[name, ...rest].filter(Boolean).join(" ")} is too many`);
153
+ if (verb !== "list") {
154
+ const problem = userNameProblem(verb, name);
155
+ if (problem !== null)
156
+ return usage(problem);
157
+ }
158
+ const w = await reach(USER, json);
159
+ if (w === null || w.project === null)
160
+ return 1;
161
+ const project = w.project;
162
+ const file = path.join(project.dir, USERS_FILE);
163
+ const apiKey = project.config.apiKey;
164
+ if (verb === "list") {
165
+ const r = listUsers(file, apiKey);
166
+ if ("message" in r)
167
+ return failed("user", r, { json });
168
+ const next = [["mason user add <name>", "a user for a tool: its password and URL, printed once"], ...(r.users.length ? [["mason user remove <name>", "that user connects no more"]] : [])];
169
+ if (json) {
170
+ out(jsonText(USER, { users: r.users }, next));
171
+ return 0;
172
+ }
173
+ const s = styleOut();
174
+ if (!r.users.length) {
175
+ out(withEnd(`${s.bold(`No users yet for ${project.config.name}.`)}`, { next }));
176
+ return 0;
177
+ }
178
+ const width = Math.max(...r.users.map((u) => u.name.length));
179
+ const lines = r.users.map((u) => ` ${s.cyan(u.name.padEnd(width))} ${s.dim(`id ${u.id} · added ${u.addedAt.slice(0, 16).replace("T", " ")} UTC`)}` +
180
+ (u.live ? "" : ` ${s.yellow("does not connect: the API key was rotated since it was added")}`));
181
+ const rotated = r.users.filter((u) => !u.live);
182
+ out(withEnd(`${s.bold(`${r.users.length} user${r.users.length === 1 ? "" : "s"} of ${project.config.name}`)}\n${lines.join("\n")}`, { ...(rotated.length ? { tips: [`A user added under another API key does not connect: mason user remove ${rotated[0].name}, then mason user add ${rotated[0].name}.`] } : {}), next }));
183
+ return 0;
184
+ }
185
+ if (verb === "remove") {
186
+ const r = removeUser(file, name);
187
+ if ("message" in r)
188
+ return failed("user", r, { json });
189
+ const gone = r.removed;
190
+ const next = REMOVED_NEXT;
191
+ if (json) {
192
+ out(jsonText(USER, r, next));
193
+ return 0;
194
+ }
195
+ out(reportText({ headline: `The user ${gone.name} removed: it connects no more`,
196
+ details: [["user", gone.name], ["added", gone.addedAt], ["sessions", w.state === "up" ? "those it holds end at their next statement" : "none: Mason is not running"]],
197
+ tips: [`Adding ${gone.name} again makes a new password: the old one never connects again.`], next }));
198
+ return 0;
199
+ }
200
+ // add
201
+ const run = w.state === "up" ? w.run : null;
202
+ const link = run ? postgresLink(run.postgresUrl) : null;
203
+ const r = addUser(file, apiKey, name, { link, database: project.config.name });
204
+ if ("message" in r)
205
+ return failed("user", r, { json });
206
+ const { replaced, ...added } = r;
207
+ const next = addedNext(r.user, link !== null);
208
+ if (json) {
209
+ out(jsonText(USER, added, next));
210
+ return 0;
211
+ }
212
+ out(reportText({
213
+ headline: `The user ${r.user} added${replaced ? " (in place of one the API key's rotation had removed)" : ""}: its password is printed this once`,
214
+ details: [
215
+ ...(r.url !== undefined ? [["url", r.url], ["psql", psqlLine(r.url)]] : []),
216
+ ["user", r.user], ["password", r.password], ["database", r.database],
217
+ ...(link !== null ? [["host", link.host], ["port", String(link.port)], ...(link.sslmode ? [["ssl mode", link.sslmode]] : [])] : []),
218
+ ],
219
+ tips: [
220
+ "Paste it into the tool now: Mason keeps no copy of the password. Lost, remove the user and add it again.",
221
+ ...(link === null ? ["Mason is not running: mason start, then mason status says the host and port this user connects to."] : []),
222
+ ...listenerTips(run?.postgresListener ?? null),
223
+ "It never expires: mason user remove ends it, at once and for every session it holds.",
224
+ "Rotating the API key (apiKey in .mason/config.json, MASON_API_KEY under mason serve) removes every user at once.",
225
+ ],
226
+ next,
227
+ }));
228
+ return 0;
229
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The package's version when installed; else the image's (`MASON_VERSION`, stamped at build by images-publish.yml:
3
+ * `v0.6.1` for a release, said `0.6.1` as the package says it, or `main-<sha>`); a checkout runs as "dev" (and
4
+ * ClickHouse's `main` image).
5
+ */
6
+ import * as fs from "node:fs";
7
+ import * as path from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ export function version() {
10
+ // src/cli/ in a checkout (the repo's package.json, which is not this one), dist/src/cli/ in the package
11
+ const here = path.dirname(fileURLToPath(import.meta.url));
12
+ for (const p of [path.join(here, "..", "..", "package.json"), path.join(here, "..", "..", "..", "package.json")]) {
13
+ if (!fs.existsSync(p))
14
+ continue;
15
+ const pkg = JSON.parse(fs.readFileSync(p, "utf8"));
16
+ if (pkg.name === "@mason-data/cli" && pkg.version)
17
+ return pkg.version;
18
+ }
19
+ const image = process.env.MASON_VERSION?.trim();
20
+ if (image)
21
+ return /^v\d+\.\d+\.\d+$/.test(image) ? image.slice(1) : image;
22
+ return "dev";
23
+ }
@@ -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
+ }