tablewalk 0.0.1

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 (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +553 -0
  3. package/dist/adapters/adapter.js +372 -0
  4. package/dist/adapters/connect.js +33 -0
  5. package/dist/adapters/mysql.js +951 -0
  6. package/dist/adapters/postgres.js +1000 -0
  7. package/dist/adapters/sqlite.js +781 -0
  8. package/dist/client/agent.js +262 -0
  9. package/dist/client/app.js +973 -0
  10. package/dist/client/arrange.js +254 -0
  11. package/dist/client/ask.js +133 -0
  12. package/dist/client/breakdown.js +317 -0
  13. package/dist/client/clauses.js +390 -0
  14. package/dist/client/columns.js +98 -0
  15. package/dist/client/complete.js +437 -0
  16. package/dist/client/compose.js +166 -0
  17. package/dist/client/composer.css +495 -0
  18. package/dist/client/composer.js +1972 -0
  19. package/dist/client/connections.js +234 -0
  20. package/dist/client/connmanager.js +962 -0
  21. package/dist/client/connurl.js +188 -0
  22. package/dist/client/core.js +893 -0
  23. package/dist/client/deeplink.js +270 -0
  24. package/dist/client/delete.js +144 -0
  25. package/dist/client/diagram.js +885 -0
  26. package/dist/client/dropdown.js +279 -0
  27. package/dist/client/export.js +456 -0
  28. package/dist/client/features.css +524 -0
  29. package/dist/client/findvalue.js +169 -0
  30. package/dist/client/grid.js +205 -0
  31. package/dist/client/handoff.js +153 -0
  32. package/dist/client/help.css +145 -0
  33. package/dist/client/help.js +881 -0
  34. package/dist/client/history.js +222 -0
  35. package/dist/client/index.html +116 -0
  36. package/dist/client/insert.js +151 -0
  37. package/dist/client/menu.js +160 -0
  38. package/dist/client/nested.js +255 -0
  39. package/dist/client/page.css +713 -0
  40. package/dist/client/page.js +1345 -0
  41. package/dist/client/pagebuilder.js +1222 -0
  42. package/dist/client/pagemarks.js +95 -0
  43. package/dist/client/palette.js +374 -0
  44. package/dist/client/peek.js +254 -0
  45. package/dist/client/picker.js +139 -0
  46. package/dist/client/pins.js +140 -0
  47. package/dist/client/prompt.js +129 -0
  48. package/dist/client/record.js +707 -0
  49. package/dist/client/schemaexport.js +242 -0
  50. package/dist/client/schematext.js +125 -0
  51. package/dist/client/shape.js +178 -0
  52. package/dist/client/shapecheck.js +129 -0
  53. package/dist/client/skeleton.js +139 -0
  54. package/dist/client/sql.css +126 -0
  55. package/dist/client/sql.js +398 -0
  56. package/dist/client/sqlcomplete.js +163 -0
  57. package/dist/client/sqlsaved.js +107 -0
  58. package/dist/client/style.css +2711 -0
  59. package/dist/client/summary.js +259 -0
  60. package/dist/client/table.js +1035 -0
  61. package/dist/client/template.js +539 -0
  62. package/dist/client/theme.js +74 -0
  63. package/dist/client/tour.js +324 -0
  64. package/dist/client/undo.js +105 -0
  65. package/dist/client/url.js +166 -0
  66. package/dist/client/value.js +223 -0
  67. package/dist/client/views.js +215 -0
  68. package/dist/client/virtual.js +176 -0
  69. package/dist/client/welcome.js +170 -0
  70. package/dist/client/write.js +414 -0
  71. package/dist/server/changeimpact.js +195 -0
  72. package/dist/server/connections.js +615 -0
  73. package/dist/server/constraints.js +62 -0
  74. package/dist/server/credentials.js +230 -0
  75. package/dist/server/fixture.js +199 -0
  76. package/dist/server/graph.js +194 -0
  77. package/dist/server/impact.js +48 -0
  78. package/dist/server/index.js +2204 -0
  79. package/dist/server/journal.js +173 -0
  80. package/dist/server/layouts.js +128 -0
  81. package/dist/server/mcp.js +2840 -0
  82. package/dist/server/shapeonly.js +91 -0
  83. package/dist/shared/breakdown.js +231 -0
  84. package/dist/shared/breakdowntext.js +257 -0
  85. package/dist/shared/diff.js +130 -0
  86. package/dist/shared/like.js +29 -0
  87. package/dist/shared/lint.js +149 -0
  88. package/dist/shared/order.js +133 -0
  89. package/dist/shared/page.js +932 -0
  90. package/dist/shared/query.js +831 -0
  91. package/dist/shared/recordview.js +343 -0
  92. package/dist/shared/schema.js +377 -0
  93. package/dist/shared/sqlsaved.js +67 -0
  94. package/dist/shared/view.js +981 -0
  95. package/dist/shared/viewtext.js +273 -0
  96. package/dist/shared/vocabulary.js +164 -0
  97. package/package.json +57 -0
@@ -0,0 +1,377 @@
1
+ /**
2
+ * The schema model every adapter produces, and the whole reason this tool
3
+ * can exist for more than one database.
4
+ *
5
+ * Most database clients treat a schema as a list of tables. The interesting
6
+ * structure is the edges: which column points at which table, and — the part
7
+ * almost nothing surfaces well — which columns elsewhere point back at the row
8
+ * you are looking at. Answering that second question is what turns a database
9
+ * from a list into something you can walk.
10
+ *
11
+ * Relational databases hand you both directions for free. `information_schema`
12
+ * knows every foreign key, so the reverse map is a filter over a list you
13
+ * already have. Adapters do the dialect-specific reading; everything above
14
+ * this file works on the model below and never learns which database it is.
15
+ */
16
+ /**
17
+ * A dialect's spelling of an `ON DELETE` action, normalised.
18
+ *
19
+ * Each engine words these differently — SQLite and MySQL in prose, Postgres as
20
+ * a single character — and the difference is not interesting upstream. Shared
21
+ * so the three adapters cannot drift on a value the delete dialog reads
22
+ * literally.
23
+ *
24
+ * Unrecognised returns undefined rather than a default: "the adapter did not
25
+ * report it" and "the constraint says NO ACTION" lead to different advice, and
26
+ * conflating them presents an unknown as harmless.
27
+ */
28
+ export function deleteRuleOf(raw) {
29
+ switch (String(raw ?? '').trim().toUpperCase()) {
30
+ // Postgres stores confdeltype as one character.
31
+ case 'C':
32
+ case 'CASCADE': return 'cascade';
33
+ case 'N':
34
+ case 'SET NULL': return 'set null';
35
+ case 'D':
36
+ case 'SET DEFAULT': return 'set default';
37
+ case 'R':
38
+ case 'RESTRICT': return 'restrict';
39
+ case 'A':
40
+ case 'NO ACTION': return 'no action';
41
+ default: return undefined;
42
+ }
43
+ }
44
+ /* ---------- derived views over the model ---------- */
45
+ /**
46
+ * Columns elsewhere that point at this table — the "what refers to me"
47
+ * question. Deliberately derived rather than stored, so it cannot drift from
48
+ * the foreign key list it is computed from.
49
+ */
50
+ export function referencesTo(schema, tableId) {
51
+ return schema.foreignKeys.filter((fk) => fk.to.table === tableId);
52
+ }
53
+ /** Columns on this table that point elsewhere. */
54
+ export function referencesFrom(schema, tableId) {
55
+ return schema.foreignKeys.filter((fk) => fk.from.table === tableId);
56
+ }
57
+ export function findTable(schema, tableId) {
58
+ return schema.tables.find((t) => t.id === tableId);
59
+ }
60
+ /**
61
+ * A table by its id, or by a bare name when only one table has it.
62
+ *
63
+ * A table's id is qualified where the engine qualifies things — `public.customer`
64
+ * on Postgres, `shop.invoice` on MySQL — and nobody types that when there is
65
+ * only one `customer`. Ambiguity is left unresolved rather than guessed: two
66
+ * schemas with a `customer` each is exactly when picking one silently writes
67
+ * to the wrong database.
68
+ */
69
+ export function tableNamed(schema, asked) {
70
+ const exact = findTable(schema, asked);
71
+ if (exact)
72
+ return exact;
73
+ const matches = schema.tables.filter((t) => t.name === asked);
74
+ return matches.length === 1 ? matches[0] : undefined;
75
+ }
76
+ /**
77
+ * Whether a column should be treated as a date for *presentation* — offering
78
+ * relative-date filters, rendering without a midnight time.
79
+ *
80
+ * Kept separate from `columnKind`, which feeds value coercion and must stay
81
+ * strictly declaration-based: guessing wrong there would change what gets
82
+ * written to the database. Guessing wrong here only offers a filter that does
83
+ * not match, which the user sees immediately.
84
+ *
85
+ * The fallback exists because plenty of real schemas store dates in TEXT —
86
+ * SQLite has no date type at all, so `opened_on TEXT` is idiomatic rather
87
+ * than sloppy, and refusing to offer `last 30 days` on it would make the best
88
+ * part of the language unavailable exactly where it is most needed.
89
+ */
90
+ const DATE_NAME = /(^|_)(date|time|datetime|timestamp)(_|$)|_(at|on)$|^(created|updated|modified|deleted)(_|$)/i;
91
+ export function looksLikeDate(column) {
92
+ if (columnKind(column.type) === 'date')
93
+ return true;
94
+ // Only text columns get the name-based benefit of the doubt; a number named
95
+ // `updated_at` is an epoch and comparing it to a timestamp string is wrong.
96
+ return columnKind(column.type) === 'text' && DATE_NAME.test(column.name);
97
+ }
98
+ /**
99
+ * The column a row is best identified by in a list or a breadcrumb.
100
+ *
101
+ * A primary key is correct and unreadable — nobody recognises a row by its
102
+ * UUID. Prefer a human-meaningful column when the table has one, and say so
103
+ * by returning the primary key only as a fallback.
104
+ */
105
+ /* Order matters and is not arbitrary. Name-family columns come first because
106
+ `title` on a table that also has `first_name` is a job title, not a label —
107
+ ranking it higher labels every row in a staff list "Account Executive". */
108
+ const LABEL_CANDIDATES = [
109
+ 'name',
110
+ 'full_name',
111
+ 'display_name',
112
+ 'first_name',
113
+ 'username',
114
+ 'title',
115
+ 'label',
116
+ 'subject',
117
+ 'number',
118
+ 'email',
119
+ 'slug',
120
+ 'code',
121
+ ];
122
+ export function labelColumn(table) {
123
+ const byName = LABEL_CANDIDATES.find((c) => table.columns.some((col) => col.name === c));
124
+ if (byName)
125
+ return byName;
126
+ /* A date is never a good label. Two rows created the same day are
127
+ indistinguishable, and "2025-03-14" tells you nothing about *which*
128
+ invoice you are looking at — the key at least identifies it. Before the
129
+ demo declared proper date types this quietly labelled every invoice by
130
+ its date, which looked plausible and was useless. */
131
+ /* Nor is a column with a vocabulary. `status` is a text column and reads
132
+ like one, but a name has to identify a row: labelling six hundred
133
+ invoices "issued" is worse than labelling them by their key, because it
134
+ looks like a name and distinguishes nothing. */
135
+ const firstText = table.columns.find((c) => !c.primaryKey && /char|text|varchar|string/i.test(c.type)
136
+ && !looksLikeDate(c) && !c.allowed?.length);
137
+ return firstText?.name ?? table.columns.find((c) => c.primaryKey)?.name;
138
+ }
139
+ /** Primary key columns, in declaration order. */
140
+ export function primaryKey(table) {
141
+ return table.columns.filter((c) => c.primaryKey).map((c) => c.name);
142
+ }
143
+ /* Tables that hold identities rather than things: the party-model pattern,
144
+ where a person's name lives on `party` or `person` and everything else —
145
+ employee, contact, supplier — points at it. Named the way labelColumn's
146
+ candidates are, because the schema does not record "this link is 1:1" and
147
+ the name is the signal actually present. */
148
+ const IDENTITY_TABLE = /(^|_)(party|parties|person|persons|people|user|users|profile|profiles|identity|identities)$/i;
149
+ /**
150
+ * Where a row's name lives, as a view path.
151
+ *
152
+ * `labelColumn` answers "which of this table's own columns reads best", and
153
+ * for a well-normalised table the honest answer is none of them: employee
154
+ * carries `party_id`, and the name is `party.display_name`, one table away.
155
+ * So when a table's best label is its own key, the question is asked one
156
+ * forward hop further before giving up — a tool whose whole idea is walking
157
+ * references should not call a person "30" when the walk it teaches would
158
+ * have found the name.
159
+ *
160
+ * The hop is taken only across a link that is plausibly one-to-one: a
161
+ * reference that *is* the whole primary key (the subtype pattern —
162
+ * `person.party_id PRIMARY KEY REFERENCES party` — one-to-one by
163
+ * construction), or a reference into an identity table. Not any reference
164
+ * with a name on the other end: `invoice.customer_id` reaches a perfectly
165
+ * good name that is not the invoice's — forty invoices share it, and a trail
166
+ * of rows all called "Dry Creek Vineyards" identifies nothing.
167
+ *
168
+ * A table with a real label of its own keeps it, so nothing changes where
169
+ * things already read well. Self-references are skipped: the manager's name
170
+ * is not this row's name.
171
+ */
172
+ export function labelPath(schema, table) {
173
+ const own = labelColumn(table);
174
+ const key = primaryKey(table);
175
+ if (own && !key.includes(own))
176
+ return own;
177
+ for (const column of table.columns) {
178
+ const ref = column.references;
179
+ if (!ref || ref.table === table.id)
180
+ continue;
181
+ const oneToOne = key.length === 1 && key[0] === column.name;
182
+ const target = findTable(schema, ref.table);
183
+ if (!target)
184
+ continue;
185
+ if (!oneToOne && !IDENTITY_TABLE.test(target.name))
186
+ continue;
187
+ const theirs = labelColumn(target);
188
+ if (!theirs || primaryKey(target).includes(theirs))
189
+ continue;
190
+ return `${column.name}.${theirs}`;
191
+ }
192
+ return own;
193
+ }
194
+ /* Columns that record when a row was last touched, then when it came into
195
+ existence. Ordered by how well each answers "what has been happening here",
196
+ which is the question a table you have just opened is usually being asked. */
197
+ const TOUCHED_NAME = /^(sys_)?(updated|modified|changed|last_updated)(_at|_on|_date)?$/i;
198
+ const CREATED_NAME = /^(sys_)?(created|inserted|added|opened|raised|issued|ordered|received|signed_up|joined)(_at|_on|_date)?$/i;
199
+ /**
200
+ * The order a table's rows are shown in when the query does not say.
201
+ *
202
+ * There has to be one, and not for taste: `LIMIT`/`OFFSET` without `ORDER BY`
203
+ * has no defined order in either SQLite or Postgres, so paging through an
204
+ * unordered table can repeat a row on page two and never show another. The
205
+ * database is free to hand back rows in whatever order it finds them, and
206
+ * Postgres in particular will change its mind after an UPDATE moves a row.
207
+ * A default order is what makes "Next" mean what it appears to mean.
208
+ *
209
+ * Given that it must exist, it may as well be useful. In order of preference:
210
+ *
211
+ * 1. When the row was last touched, newest first — *if the column is
212
+ * indexed*. On a table with an indexed `updated_at`, "what changed
213
+ * recently" is what people open it to see, and the database can answer it
214
+ * by walking the index backwards.
215
+ * 2. When it was created, newest first, under the same condition.
216
+ * 3. A single numeric primary key, descending. On an autoincrement key that
217
+ * is the same "newest first" by proxy.
218
+ * 4. Any other primary key, ascending. A text key sorts alphabetically —
219
+ * `country` starting at ZW rather than AU would be perverse — and a
220
+ * composite key ascending groups its children under their parent, which
221
+ * is how `invoice_line` wants to be read.
222
+ * 5. The label column, then the first column: neither is meaningful, but
223
+ * both are deterministic, which is the part that matters.
224
+ *
225
+ * The index condition is the one rule here that is not about taste. A default
226
+ * that nobody asked for must not be a default that costs anything: `ORDER BY
227
+ * updated_at DESC LIMIT 20` on an unindexed column is a full scan and a sort
228
+ * of the whole table to return twenty rows, and it would happen on every table
229
+ * you clicked. The primary key is always indexed, which is why it is the
230
+ * fallback rather than merely one option among several.
231
+ *
232
+ * A table whose adapter does not report indexes at all gets the key, not a
233
+ * guess — `indexed` is undefined rather than empty in that case precisely so
234
+ * the two can be told apart.
235
+ */
236
+ export function defaultOrder(table) {
237
+ const indexed = new Set(table.indexed ?? []);
238
+ const named = (test) => table.columns.find((c) => test.test(c.name) &&
239
+ (looksLikeDate(c) || columnKind(c.type) === 'number') &&
240
+ indexed.has(c.name));
241
+ const touched = named(TOUCHED_NAME) ?? named(CREATED_NAME);
242
+ if (touched)
243
+ return withKey(table, [{ column: touched.name, direction: 'desc' }]);
244
+ const key = primaryKey(table);
245
+ if (key.length === 1) {
246
+ const column = table.columns.find((c) => c.name === key[0]);
247
+ return [{ column: key[0], direction: columnKind(column.type) === 'number' ? 'desc' : 'asc' }];
248
+ }
249
+ if (key.length > 1)
250
+ return key.map((column) => ({ column, direction: 'asc' }));
251
+ const label = labelColumn(table);
252
+ const fallback = label ?? table.columns[0]?.name;
253
+ return fallback ? [{ column: fallback, direction: 'asc' }] : [];
254
+ }
255
+ /**
256
+ * Make an order total by appending the primary key.
257
+ *
258
+ * Sorting by `status` leaves every row with the same status in an undefined
259
+ * order relative to its neighbours — which is the paging bug again, one level
260
+ * down: page one and page two disagree about which of two equal rows comes
261
+ * first, so one is shown twice and one not at all. Appending the key breaks
262
+ * every remaining tie the same way each time.
263
+ *
264
+ * A table with no primary key cannot be made stable this way, and is left
265
+ * alone rather than given a guess.
266
+ */
267
+ export function withKey(table, order) {
268
+ const chosen = new Set(order.map((o) => o.column));
269
+ const key = primaryKey(table).filter((c) => !chosen.has(c));
270
+ return [...order, ...key.map((column) => ({ column, direction: 'asc' }))];
271
+ }
272
+ /**
273
+ * The order to actually run with, and whether the caller asked for it.
274
+ *
275
+ * `implicit` is reported rather than hidden: the grid shows which column it
276
+ * sorted by, so a default order is something the reader can see and disagree
277
+ * with rather than a silent rearrangement of their data.
278
+ */
279
+ export function resolveOrder(table, requested) {
280
+ if (requested?.length)
281
+ return { order: withKey(table, requested), implicit: false };
282
+ return { order: defaultOrder(table), implicit: true };
283
+ }
284
+ /**
285
+ * Reconstruct a table's DDL from the catalog.
286
+ *
287
+ * This is a *reconstruction*, and callers must say so. It is built from what
288
+ * introspection captured — columns, types, nullability, defaults, keys — and
289
+ * therefore omits anything the model does not carry: indexes, check
290
+ * constraints, triggers, partitioning, storage parameters. Presenting it as
291
+ * the definitive `CREATE TABLE` would be a lie that bites the first time
292
+ * someone copies it into a migration.
293
+ *
294
+ * An adapter that can produce the database's own stored DDL should do so
295
+ * instead; `SqliteAdapter` does, because `sqlite_master` keeps the original
296
+ * statement text verbatim.
297
+ */
298
+ export function renderDDL(schema, tableId) {
299
+ const table = findTable(schema, tableId);
300
+ if (!table)
301
+ return `-- No table called "${tableId}".`;
302
+ const quote = (id) => `"${id.replace(/"/g, '""')}"`;
303
+ /* A qualified name is two identifiers, not one containing a dot. Quoting
304
+ `app.invoice_line` whole produces a table literally named "app.invoice_line",
305
+ which is valid SQL for something that does not exist — the worst kind of
306
+ wrong output, because it looks right until you run it. */
307
+ const qualified = (id) => {
308
+ const table = findTable(schema, id);
309
+ return table?.schema ? `${quote(table.schema)}.${quote(table.name)}` : quote(id);
310
+ };
311
+ const width = Math.max(...table.columns.map((c) => c.name.length), 0);
312
+ const lines = table.columns.map((col) => {
313
+ const parts = [quote(col.name).padEnd(width + 2), col.type.toUpperCase()];
314
+ if (!col.nullable)
315
+ parts.push('NOT NULL');
316
+ if (col.default !== undefined)
317
+ parts.push(`DEFAULT ${col.default}`);
318
+ return ` ${parts.join(' ')}`;
319
+ });
320
+ const pk = primaryKey(table);
321
+ if (pk.length)
322
+ lines.push(` PRIMARY KEY (${pk.map(quote).join(', ')})`);
323
+ for (const fk of referencesFrom(schema, tableId)) {
324
+ lines.push(` CONSTRAINT ${quote(fk.name)} FOREIGN KEY (${fk.from.columns.map(quote).join(', ')}) ` +
325
+ `REFERENCES ${qualified(fk.to.table)} (${fk.to.columns.map(quote).join(', ')})`);
326
+ }
327
+ const header = table.isView
328
+ ? `-- ${tableId} is a view. Its defining query was not captured;\n-- the columns below are what it returns.\nCREATE VIEW ${qualified(tableId)} (`
329
+ : `CREATE TABLE ${qualified(tableId)} (`;
330
+ const body = `${header}\n${lines.join(',\n')}\n);`;
331
+ return table.comment ? `-- ${table.comment}\n${body}` : body;
332
+ }
333
+ export function columnKind(type) {
334
+ const t = (type ?? '').toLowerCase();
335
+ if (/bool/.test(t))
336
+ return 'boolean';
337
+ if (/int|serial|numeric|decimal|real|double|float|money/.test(t))
338
+ return 'number';
339
+ if (/date|time/.test(t))
340
+ return 'date';
341
+ if (/char|text|uuid|json|enum|clob/.test(t))
342
+ return 'text';
343
+ return 'other';
344
+ }
345
+ /**
346
+ * Turn a value on its way *into* the database into the right JavaScript type
347
+ * for its column.
348
+ *
349
+ * Everything typed into a text input arrives as a string, including "17901"
350
+ * for a REAL column. SQLite's type affinity usually rescues that, but not
351
+ * always — a column with BLOB affinity stores the characters — and Postgres
352
+ * rejects it outright. Either way the stored value should be a number because
353
+ * the column says so, not because the driver was forgiving.
354
+ *
355
+ * Unparseable input is passed through unchanged rather than coerced to NaN or
356
+ * 0. Letting the database reject "abc" for an integer column gives the user a
357
+ * real error; silently storing 0 gives them a wrong number.
358
+ */
359
+ export function coerceForColumn(declaredType, value) {
360
+ if (value === null || typeof value !== 'string')
361
+ return value;
362
+ const kind = columnKind(declaredType);
363
+ if (kind === 'number') {
364
+ if (value.trim() === '')
365
+ return null;
366
+ const n = Number(value);
367
+ return Number.isFinite(n) ? n : value;
368
+ }
369
+ if (kind === 'boolean') {
370
+ if (/^(true|yes|1|t)$/i.test(value))
371
+ return true;
372
+ if (/^(false|no|0|f)$/i.test(value))
373
+ return false;
374
+ return value;
375
+ }
376
+ return value;
377
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Saved SQL statements, as a config file section.
3
+ *
4
+ * The same idea as the `views` section beside it, for the same reason: a
5
+ * statement you retype every morning is a statement that should have a name,
6
+ * and a *team's* statements belong in a file they can commit rather than in
7
+ * one person's browser. `tablewalk.json` is already the place a repository says
8
+ * "these are the databases this project talks to"; this lets it also say "and
9
+ * these are the questions people ask of them".
10
+ *
11
+ * The stored form is the text, never anything compiled. It stays readable in
12
+ * the editor, it survives the schema changing underneath it — at worst as a
13
+ * visible error you can fix rather than a silently wrong answer — and it can be
14
+ * reviewed in a pull request by someone who knows SQL and nothing about
15
+ * tablewalk.
16
+ */
17
+ /**
18
+ * Read the `queries` section of a config file.
19
+ *
20
+ * Errors name the file, the position *and* the statement's name where there is
21
+ * one, because a config file is something a person wrote by hand and "queries
22
+ * is not an array" sends them to search a file they may not have written.
23
+ *
24
+ * A duplicate name is refused rather than resolved. Two statements called
25
+ * "Daily totals" that do different things is a worse outcome than a startup
26
+ * error, and silently keeping the last one is the worst of the three.
27
+ */
28
+ export function parseQueriesConfig(raw, where) {
29
+ const queries = raw?.queries;
30
+ if (queries === undefined)
31
+ return [];
32
+ if (!Array.isArray(queries))
33
+ throw new Error(`${where}: "queries" must be an array.`);
34
+ const seen = new Set();
35
+ return queries.map((entry, i) => {
36
+ const at = `${where}: query ${i + 1}`;
37
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
38
+ throw new Error(`${at} is not an object.`);
39
+ }
40
+ const q = entry;
41
+ if (typeof q.name !== 'string' || !q.name.trim())
42
+ throw new Error(`${at} has no "name".`);
43
+ const name = q.name.trim();
44
+ if (seen.has(name)) {
45
+ throw new Error(`${where}: two queries are called "${name}". Names have to be distinct.`);
46
+ }
47
+ seen.add(name);
48
+ if (typeof q.sql !== 'string' || !q.sql.trim()) {
49
+ throw new Error(`${at} ("${name}") has no "sql".`);
50
+ }
51
+ return {
52
+ name,
53
+ sql: q.sql.trim(),
54
+ description: typeof q.description === 'string' && q.description.trim()
55
+ ? q.description.trim()
56
+ : undefined,
57
+ connection: typeof q.connection === 'string' && q.connection.trim()
58
+ ? q.connection.trim()
59
+ : undefined,
60
+ source: 'config',
61
+ };
62
+ });
63
+ }
64
+ /** The saved statements that apply to one connection, by id or by name. */
65
+ export function queriesFor(all, connectionId, connectionName) {
66
+ return all.filter((q) => !q.connection || q.connection === connectionId || q.connection === connectionName);
67
+ }