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,372 @@
1
+ import { LIKE_ESCAPE, likePattern } from '../shared/like.js';
2
+ /**
3
+ * The operators, as a value rather than only as a type.
4
+ *
5
+ * `Op` is checked by the compiler, and the compiler is not present when a
6
+ * request arrives: `/api/query`, `/api/count`, `/api/view/run` and
7
+ * `/api/references` all cast a request body straight to `Filter`, so the type
8
+ * was a comment as far as those paths were concerned. This is the list
9
+ * `buildWhere` actually checks against.
10
+ */
11
+ export const OPS = new Set([
12
+ '=', '!=', '>', '>=', '<', '<=',
13
+ 'contains', 'startsWith', 'endsWith', 'like', 'in', 'isNull', 'isNotNull',
14
+ ]);
15
+ /* ---------- the limit policy ----------
16
+
17
+ One place, because a ceiling re-derived in four files is a ceiling that
18
+ disagrees with itself. Every read goes through `clampLimit`, so there is no
19
+ path to an unbounded query: a caller that omits a limit gets the default, a
20
+ caller that asks for too many gets the ceiling, and a caller that sends
21
+ nonsense gets the default rather than `LIMIT NaN`. */
22
+ /** What a caller gets when it does not say. Small on purpose: the first page
23
+ of a browse should be instant, and paging is one click. */
24
+ export const DEFAULT_LIMIT = 20;
25
+ /** The hard ceiling. Nothing may exceed it, including an explicit request. */
26
+ export const MAX_LIMIT = 100_000;
27
+ /**
28
+ * Beyond this, a result is being fetched to be processed rather than read.
29
+ * Callers can use it to decide whether to warn; it is not enforced.
30
+ */
31
+ export const LARGE_RESULT = 2_000;
32
+ /**
33
+ * Above this many rows, an exact count is not worth what it costs.
34
+ *
35
+ * `COUNT(*)` is a scan. On a table of a few thousand rows that is free and the
36
+ * exact number is strictly better; on a table of fifty million it is seconds of
37
+ * work to compute a number that is stale before it is rendered, and it runs on
38
+ * *every* query — the header stat is not something anyone asked for.
39
+ *
40
+ * The threshold is deliberately well above anything a person would notice the
41
+ * difference on. Below it you get the true number; above it you get the
42
+ * catalog's estimate and are told that is what it is.
43
+ */
44
+ export const ESTIMATE_ABOVE = 50_000;
45
+ /**
46
+ * Only a number or a numeric string counts as a request. Everything else —
47
+ * undefined, null, '', [], {}, 'abc' — means "not specified" and gets the
48
+ * default.
49
+ *
50
+ * The `null` case is why this cannot just be `Number(value)`: `Number(null)`
51
+ * is 0, not NaN, so a null limit passed a finite check and clamped to 1. A
52
+ * caller that omitted a limit would have got a single row and no error.
53
+ */
54
+ function asNumber(value) {
55
+ if (typeof value === 'number')
56
+ return Number.isFinite(value) ? value : undefined;
57
+ if (typeof value === 'string' && value.trim() !== '') {
58
+ const n = Number(value);
59
+ return Number.isFinite(n) ? n : undefined;
60
+ }
61
+ return undefined;
62
+ }
63
+ /**
64
+ * A request an adapter will not carry out.
65
+ *
66
+ * Distinct from a database failure, and the distinction is the whole point:
67
+ * "that is not the whole primary key" and "the database is down" are both
68
+ * exceptions, and a server that cannot tell them apart answers both with a
69
+ * 500. The first is something the caller can fix by asking differently.
70
+ *
71
+ * Thrown only by guards that run *before* a statement is issued. Once the
72
+ * database has been asked, whatever comes back is the database's answer.
73
+ */
74
+ export class Refusal extends Error {
75
+ constructor(message) {
76
+ super(message);
77
+ this.name = 'Refusal';
78
+ }
79
+ }
80
+ export function clampLimit(value, fallback = DEFAULT_LIMIT) {
81
+ const n = asNumber(value);
82
+ if (n === undefined)
83
+ return fallback;
84
+ return Math.min(Math.max(Math.floor(n), 1), MAX_LIMIT);
85
+ }
86
+ /** Offsets have no ceiling but must be a real, non-negative integer. */
87
+ export function clampOffset(value) {
88
+ const n = asNumber(value);
89
+ if (n === undefined || n < 0)
90
+ return 0;
91
+ return Math.floor(n);
92
+ }
93
+ /**
94
+ * Refuse anything that is more than one statement.
95
+ *
96
+ * Not a SQL parser and not trying to be one — it answers exactly one question,
97
+ * "is there a second statement here", and it answers it by scanning for a
98
+ * semicolon that has something after it. Everything that can contain a
99
+ * semicolon harmlessly is skipped: single- and double-quoted strings,
100
+ * backquoted identifiers, line comments, block comments, and Postgres's
101
+ * dollar-quoted bodies, which is how a function definition carries semicolons
102
+ * without being several statements.
103
+ *
104
+ * Stacking is refused rather than truncated. Running the first half of what
105
+ * someone typed and discarding the rest is worse than refusing both: they
106
+ * asked for two things and would be told about neither.
107
+ *
108
+ * Returns null when the text is a single statement, or the reason it is not.
109
+ */
110
+ export function refuseStacked(text) {
111
+ let i = 0;
112
+ const n = text.length;
113
+ while (i < n) {
114
+ const ch = text[i];
115
+ // Line comment, to end of line.
116
+ if (ch === '-' && text[i + 1] === '-') {
117
+ const nl = text.indexOf('\n', i);
118
+ i = nl < 0 ? n : nl + 1;
119
+ continue;
120
+ }
121
+ // Block comment. Not nested: no dialect here nests them.
122
+ if (ch === '/' && text[i + 1] === '*') {
123
+ const end = text.indexOf('*/', i + 2);
124
+ i = end < 0 ? n : end + 2;
125
+ continue;
126
+ }
127
+ // Dollar-quoted body: $$…$$ or $tag$…$tag$. Postgres only, harmless
128
+ // elsewhere because the opening tag simply will not match.
129
+ if (ch === '$') {
130
+ const tag = /^\$[A-Za-z_]\w*\$|^\$\$/.exec(text.slice(i))?.[0];
131
+ if (tag) {
132
+ const end = text.indexOf(tag, i + tag.length);
133
+ i = end < 0 ? n : end + tag.length;
134
+ continue;
135
+ }
136
+ }
137
+ if (ch === "'" || ch === '"' || ch === '`') {
138
+ i += 1;
139
+ while (i < n) {
140
+ if (text[i] === '\\') {
141
+ i += 2;
142
+ continue;
143
+ }
144
+ // A doubled quote is an escaped quote, not the end of the string.
145
+ if (text[i] === ch && text[i + 1] === ch) {
146
+ i += 2;
147
+ continue;
148
+ }
149
+ if (text[i] === ch) {
150
+ i += 1;
151
+ break;
152
+ }
153
+ i += 1;
154
+ }
155
+ continue;
156
+ }
157
+ if (ch === ';') {
158
+ // A trailing semicolon is punctuation, not a second statement.
159
+ if (text.slice(i + 1).trim() === '')
160
+ return null;
161
+ return 'One statement at a time. Remove the semicolon and everything after it, or run them separately.';
162
+ }
163
+ i += 1;
164
+ }
165
+ return null;
166
+ }
167
+ /**
168
+ * Output column names, with collisions given a suffix.
169
+ *
170
+ * `SELECT id, id FROM t` names both columns `id`. Left alone, the grid draws
171
+ * one header twice and nothing says which value belongs to which. The suffix
172
+ * is the same answer `compileView` gives an explicit collision, for the same
173
+ * reason: one column quietly standing in for two is worse than an ugly name.
174
+ */
175
+ export function dedupe(names) {
176
+ const seen = new Map();
177
+ return names.map((name) => {
178
+ const count = seen.get(name) ?? 0;
179
+ seen.set(name, count + 1);
180
+ return count ? `${name}_${count + 1}` : name;
181
+ });
182
+ }
183
+ /** A statement with its trailing semicolon and surrounding space removed. */
184
+ export function trimStatement(text) {
185
+ /* The whitespace *before* the semicolon goes too. `select 1 ;` left a
186
+ trailing space, which is invisible in the editor and shows up in the
187
+ reported statement as a difference between what ran and what was typed. */
188
+ return text.trim().replace(/\s*;+\s*$/, '');
189
+ }
190
+ /**
191
+ * Wrap a statement so it cannot return more rows than asked for.
192
+ *
193
+ * `clampLimit` is the invariant everywhere else in this file, and it has no
194
+ * application point in a statement this process did not build — there is no
195
+ * request object to clamp. Wrapping is the equivalent: the database is asked
196
+ * for a bounded result rather than trusted to have been asked politely.
197
+ *
198
+ * One more row than the caller wanted, so "there is more than this" is known
199
+ * without a second query — the same trick `runView` uses.
200
+ *
201
+ * The limit is interpolated rather than bound, which is the one deliberate
202
+ * exception to the rule that nothing is interpolated. It is an integer this
203
+ * process computed from `clampLimit`, never a value that came from outside,
204
+ * and it is asserted to be one before it is used. Binding it would need a
205
+ * dialect-specific placeholder here *and* MySQL's decimal-string workaround
206
+ * for row counts, which is a lot of machinery to parameterise a number we
207
+ * chose ourselves.
208
+ *
209
+ * Not every statement can be wrapped — `EXPLAIN`, `PRAGMA`, a `WITH` that ends
210
+ * in `INSERT … RETURNING` — so the caller is told whether it applied and can
211
+ * fall back to running the statement bare and saying so.
212
+ */
213
+ export function boundStatement(text, limit) {
214
+ if (!Number.isSafeInteger(limit) || limit < 1) {
215
+ throw new Error(`A row limit must be a positive integer, not ${String(limit)}.`);
216
+ }
217
+ return `SELECT * FROM (${trimStatement(text)}) AS tablewalk_bounded LIMIT ${limit + 1}`;
218
+ }
219
+ /**
220
+ * Whether a statement is worth trying to wrap.
221
+ *
222
+ * A conservative first token test, and deliberately not a parse. Getting this
223
+ * wrong in the cautious direction costs a bounded result; getting it wrong in
224
+ * the other direction costs a confusing syntax error from the database about a
225
+ * statement the user did not write. `WITH` is included because a CTE ending in
226
+ * a SELECT is the common case, and the wrap failing on one that does not is
227
+ * handled by the fallback rather than by being clever here.
228
+ */
229
+ export function looksWrappable(text) {
230
+ const first = trimStatement(text).replace(/^\s*(?:--[^\n]*\n|\/\*[\s\S]*?\*\/|\s)+/g, '');
231
+ return /^(select|with|table|values)\b/i.test(first);
232
+ }
233
+ /**
234
+ * A queue of one.
235
+ *
236
+ * Every adapter here writes through a single connection, and a transaction is
237
+ * a span of statements on it that nothing else may enter. Node is
238
+ * single-threaded but `await` is not: two requests arriving together interleave
239
+ * at every await point, so a plain `/api/update` can land in the middle of
240
+ * somebody's open transaction and be rolled back with it — a write that
241
+ * succeeded, reported success, and then quietly did not happen.
242
+ *
243
+ * So writes queue. The cost is that two concurrent edits are applied one after
244
+ * the other rather than at once, which is what a single connection was always
245
+ * going to do anyway.
246
+ */
247
+ export class Serial {
248
+ tail = Promise.resolve();
249
+ run(work) {
250
+ /* The chain must not break on a failure: `catch` here keeps the *queue*
251
+ going while the caller still gets the rejection from `next`. */
252
+ const next = this.tail.then(work, work);
253
+ this.tail = next.catch(() => { });
254
+ return next;
255
+ }
256
+ }
257
+ export const ANSI_STYLE = {
258
+ // Doubling an embedded quote is the standard escape and is what makes a
259
+ // table called `weird"name` safe rather than a syntax error.
260
+ quote: (id) => `"${id.replace(/"/g, '""')}"`,
261
+ placeholder: () => '?',
262
+ likeOp: 'LIKE',
263
+ /* `strftime` has no quarter, so it is arithmetic on the month: 1–3 → 1.
264
+ Written with the engine's own integer division rather than a CASE, which
265
+ would be four branches to say one sum. */
266
+ dateBucket: (e, unit) => (unit === 'quarter'
267
+ ? `strftime('%Y', ${e}) || '-Q' || CAST((CAST(strftime('%m', ${e}) AS INTEGER) + 2) / 3 AS TEXT)`
268
+ : `strftime('${{ day: '%Y-%m-%d', month: '%Y-%m', year: '%Y' }[unit]}', ${e})`),
269
+ };
270
+ export const POSTGRES_STYLE = {
271
+ quote: ANSI_STYLE.quote,
272
+ placeholder: (i) => `$${i}`,
273
+ likeOp: 'ILIKE',
274
+ /* `to_char` rather than `date_trunc`, for the text the interface says it
275
+ returns. `Q` is a to_char field, so the quarter needs no arithmetic. */
276
+ dateBucket: (e, unit) => `to_char(${e}, '${{ day: 'YYYY-MM-DD', month: 'YYYY-MM', quarter: 'YYYY-"Q"Q', year: 'YYYY' }[unit]}')`,
277
+ };
278
+ export const MYSQL_STYLE = {
279
+ quote: (id) => `\`${id.replace(/`/g, '``')}\``,
280
+ placeholder: () => '?',
281
+ /* A column declared with a `_bin` or `_cs` collation compares case
282
+ sensitively even here. Forcing a collation in the predicate would fix
283
+ that and break every latin1 column, so this follows the column. */
284
+ likeOp: 'LIKE',
285
+ /* `DATE_FORMAT` has no quarter specifier either, and MySQL's `QUARTER()`
286
+ returns a number — concatenated rather than formatted, to land on the
287
+ same `2026-Q3` the other two produce. */
288
+ dateBucket: (e, unit) => (unit === 'quarter'
289
+ ? `CONCAT(DATE_FORMAT(${e}, '%Y'), '-Q', QUARTER(${e}))`
290
+ : `DATE_FORMAT(${e}, '${{ day: '%Y-%m-%d', month: '%Y-%m', year: '%Y' }[unit]}')`),
291
+ };
292
+ /**
293
+ * Compile a filter to a WHERE clause plus bound parameters.
294
+ *
295
+ * `startIndex` exists for Postgres, where placeholders are numbered across the
296
+ * whole statement rather than positional.
297
+ *
298
+ * `column` exists for views. A multi-table statement needs
299
+ * `"customer_1"."name"` where a single-table one needs `"name"`, and the
300
+ * difference is *only* how an identifier is written — the operators, the
301
+ * empty-IN case, the LIKE wrapping and the invariant that every value is a
302
+ * bound parameter are the same either way. Passing the resolver in keeps one
303
+ * predicate builder rather than two that can drift; a second copy of this
304
+ * function is a second chance to interpolate a value by accident.
305
+ */
306
+ export function buildWhere(filter, style, startIndex = 1, column = (name) => style.quote(name)) {
307
+ if (!filter?.groups.length)
308
+ return { text: '', params: [] };
309
+ const params = [];
310
+ let index = startIndex;
311
+ const next = () => style.placeholder(index++);
312
+ const groupSql = filter.groups
313
+ .filter((g) => g.length)
314
+ .map((group) => group
315
+ .map((c) => {
316
+ const col = column(c.column);
317
+ switch (c.op) {
318
+ case 'isNull':
319
+ return `${col} IS NULL`;
320
+ case 'isNotNull':
321
+ return `${col} IS NOT NULL`;
322
+ case 'in': {
323
+ const list = Array.isArray(c.value) ? c.value : [c.value];
324
+ if (!list.length)
325
+ return '1 = 0'; // an empty IN matches nothing
326
+ const holes = list.map((v) => {
327
+ params.push(v ?? null);
328
+ return next();
329
+ });
330
+ return `${col} IN (${holes.join(', ')})`;
331
+ }
332
+ /* All three go through one pattern builder, which escapes the
333
+ wildcards in the value: `contains 50%` is a search for a
334
+ percent sign, not a search for everything. */
335
+ case 'contains':
336
+ case 'startsWith':
337
+ case 'endsWith':
338
+ params.push(likePattern(c.value, c.op));
339
+ return `${col} ${style.likeOp} ${next()} ESCAPE '${LIKE_ESCAPE}'`;
340
+ /* Bound, not escaped: the wildcards belong to whoever typed them.
341
+ Still a parameter — the value never becomes SQL text — so the
342
+ safety argument is unchanged, and still `ESCAPE '#'`, so the
343
+ way to ask for a literal `%` is the same one `contains` uses
344
+ internally. */
345
+ case 'like':
346
+ params.push(String(c.value ?? ''));
347
+ return `${col} ${style.likeOp} ${next()} ESCAPE '${LIKE_ESCAPE}'`;
348
+ default: {
349
+ /* The one field in a structured request that becomes SQL text
350
+ rather than a bound parameter — and until now it was checked
351
+ only by the config-file parser, so every endpoint that casts
352
+ a body to `Filter` accepted whatever it was sent. The column
353
+ is quoted and the value is bound; this was the gap between
354
+ those two, and the whole safety argument is that a structured
355
+ request cannot express injection.
356
+
357
+ Checked here rather than at each caller: one choke point
358
+ covers the four endpoints and anything added later. */
359
+ if (!OPS.has(c.op)) {
360
+ throw new Refusal(`"${String(c.op)}" is not an operator this tool knows.`);
361
+ }
362
+ params.push(c.value ?? null);
363
+ return `${col} ${c.op} ${next()}`;
364
+ }
365
+ }
366
+ })
367
+ .join(' AND '));
368
+ if (!groupSql.length)
369
+ return { text: '', params: [] };
370
+ const text = groupSql.length === 1 ? groupSql[0] : groupSql.map((g) => `(${g})`).join(' OR ');
371
+ return { text: ` WHERE ${text}`, params };
372
+ }
@@ -0,0 +1,33 @@
1
+ import { SqliteAdapter } from './sqlite.js';
2
+ import { PostgresAdapter } from './postgres.js';
3
+ import { MysqlAdapter } from './mysql.js';
4
+ export function connect(target, options = {}) {
5
+ if (/^postgres(ql)?:\/\//i.test(target)) {
6
+ return new PostgresAdapter(target, options.label, options.schemas ?? []);
7
+ }
8
+ /* `mariadb://` too: MariaDB speaks the MySQL protocol and its catalog is
9
+ the same `information_schema`, so refusing the scheme would be turning
10
+ someone away from a database this adapter can already read. */
11
+ if (/^(mysql|mariadb):\/\//i.test(target)) {
12
+ return new MysqlAdapter(target, options.label, options.schemas ?? []);
13
+ }
14
+ if (/^sqlite:/i.test(target)) {
15
+ const file = target.replace(/^sqlite:(\/\/)?/i, '');
16
+ return new SqliteAdapter(file, options.label ?? file);
17
+ }
18
+ // A bare path is a SQLite file. This is the common case for the demo and
19
+ // for anyone poking at an app's local database, so it needs no scheme.
20
+ if (/\.(db|sqlite|sqlite3)$/i.test(target) || !target.includes('://')) {
21
+ return new SqliteAdapter(target, options.label ?? target);
22
+ }
23
+ /* The scheme, not the target.
24
+
25
+ `target` here is the *resolved* connection string — the password has
26
+ already been expanded into it — and this message was stored on the
27
+ connection entry and served to the browser from then on. The scheme is
28
+ the whole of the useful content anyway: what is wrong is the protocol,
29
+ and repeating the credentials does not help anyone fix it. */
30
+ const scheme = target.match(/^([a-z][\w+.-]*):\/\//i)?.[1];
31
+ throw new Error(`Don't know how to open ${scheme ? `a "${scheme}://" connection` : 'that connection'}. `
32
+ + 'Supported: a SQLite file path, sqlite:<path>, postgres://… or mysql://…');
33
+ }