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.
- package/LICENSE +21 -0
- package/README.md +553 -0
- package/dist/adapters/adapter.js +372 -0
- package/dist/adapters/connect.js +33 -0
- package/dist/adapters/mysql.js +951 -0
- package/dist/adapters/postgres.js +1000 -0
- package/dist/adapters/sqlite.js +781 -0
- package/dist/client/agent.js +262 -0
- package/dist/client/app.js +973 -0
- package/dist/client/arrange.js +254 -0
- package/dist/client/ask.js +133 -0
- package/dist/client/breakdown.js +317 -0
- package/dist/client/clauses.js +390 -0
- package/dist/client/columns.js +98 -0
- package/dist/client/complete.js +437 -0
- package/dist/client/compose.js +166 -0
- package/dist/client/composer.css +495 -0
- package/dist/client/composer.js +1972 -0
- package/dist/client/connections.js +234 -0
- package/dist/client/connmanager.js +962 -0
- package/dist/client/connurl.js +188 -0
- package/dist/client/core.js +893 -0
- package/dist/client/deeplink.js +270 -0
- package/dist/client/delete.js +144 -0
- package/dist/client/diagram.js +885 -0
- package/dist/client/dropdown.js +279 -0
- package/dist/client/export.js +456 -0
- package/dist/client/features.css +524 -0
- package/dist/client/findvalue.js +169 -0
- package/dist/client/grid.js +205 -0
- package/dist/client/handoff.js +153 -0
- package/dist/client/help.css +145 -0
- package/dist/client/help.js +881 -0
- package/dist/client/history.js +222 -0
- package/dist/client/index.html +116 -0
- package/dist/client/insert.js +151 -0
- package/dist/client/menu.js +160 -0
- package/dist/client/nested.js +255 -0
- package/dist/client/page.css +713 -0
- package/dist/client/page.js +1345 -0
- package/dist/client/pagebuilder.js +1222 -0
- package/dist/client/pagemarks.js +95 -0
- package/dist/client/palette.js +374 -0
- package/dist/client/peek.js +254 -0
- package/dist/client/picker.js +139 -0
- package/dist/client/pins.js +140 -0
- package/dist/client/prompt.js +129 -0
- package/dist/client/record.js +707 -0
- package/dist/client/schemaexport.js +242 -0
- package/dist/client/schematext.js +125 -0
- package/dist/client/shape.js +178 -0
- package/dist/client/shapecheck.js +129 -0
- package/dist/client/skeleton.js +139 -0
- package/dist/client/sql.css +126 -0
- package/dist/client/sql.js +398 -0
- package/dist/client/sqlcomplete.js +163 -0
- package/dist/client/sqlsaved.js +107 -0
- package/dist/client/style.css +2711 -0
- package/dist/client/summary.js +259 -0
- package/dist/client/table.js +1035 -0
- package/dist/client/template.js +539 -0
- package/dist/client/theme.js +74 -0
- package/dist/client/tour.js +324 -0
- package/dist/client/undo.js +105 -0
- package/dist/client/url.js +166 -0
- package/dist/client/value.js +223 -0
- package/dist/client/views.js +215 -0
- package/dist/client/virtual.js +176 -0
- package/dist/client/welcome.js +170 -0
- package/dist/client/write.js +414 -0
- package/dist/server/changeimpact.js +195 -0
- package/dist/server/connections.js +615 -0
- package/dist/server/constraints.js +62 -0
- package/dist/server/credentials.js +230 -0
- package/dist/server/fixture.js +199 -0
- package/dist/server/graph.js +194 -0
- package/dist/server/impact.js +48 -0
- package/dist/server/index.js +2204 -0
- package/dist/server/journal.js +173 -0
- package/dist/server/layouts.js +128 -0
- package/dist/server/mcp.js +2840 -0
- package/dist/server/shapeonly.js +91 -0
- package/dist/shared/breakdown.js +231 -0
- package/dist/shared/breakdowntext.js +257 -0
- package/dist/shared/diff.js +130 -0
- package/dist/shared/like.js +29 -0
- package/dist/shared/lint.js +149 -0
- package/dist/shared/order.js +133 -0
- package/dist/shared/page.js +932 -0
- package/dist/shared/query.js +831 -0
- package/dist/shared/recordview.js +343 -0
- package/dist/shared/schema.js +377 -0
- package/dist/shared/sqlsaved.js +67 -0
- package/dist/shared/view.js +981 -0
- package/dist/shared/viewtext.js +273 -0
- package/dist/shared/vocabulary.js +164 -0
- package/package.json +57 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where connection secrets live, and — more importantly — where they do not.
|
|
3
|
+
*
|
|
4
|
+
* The rule this file exists to enforce: **tablewalk does not invent its own
|
|
5
|
+
* secret storage.** Rolling encryption for a local dev tool means inventing a
|
|
6
|
+
* key-management scheme, and a key sitting next to the ciphertext it protects
|
|
7
|
+
* is obfuscation with extra steps. Every option below either delegates to
|
|
8
|
+
* something that already solves the problem (the OS keychain, the
|
|
9
|
+
* environment) or is explicit about being plaintext-on-disk with the same
|
|
10
|
+
* permission discipline ssh and psql use.
|
|
11
|
+
*
|
|
12
|
+
* Four ways to supply a password, in the order they are tried:
|
|
13
|
+
*
|
|
14
|
+
* 1. `${VAR}` in the URL, expanded from the environment. Nothing is stored.
|
|
15
|
+
* 2. The OS keychain, addressed by connection name. The secret never
|
|
16
|
+
* touches a tablewalk file.
|
|
17
|
+
* 3. A credentials file at 0600, refused if the mode is wider.
|
|
18
|
+
* 4. Inline in the URL — supported because it is what people paste, and
|
|
19
|
+
* warned about because it ends up in shell history and config files.
|
|
20
|
+
*
|
|
21
|
+
* Whatever the source, the resolved URL stays in the server process. The
|
|
22
|
+
* browser is only ever sent a redacted form; `redact` in connections.ts is
|
|
23
|
+
* the single point that produces it and there are tests asserting no password
|
|
24
|
+
* survives the trip.
|
|
25
|
+
*/
|
|
26
|
+
import { readFile, writeFile, chmod, mkdir } from 'node:fs/promises';
|
|
27
|
+
import { existsSync, statSync } from 'node:fs';
|
|
28
|
+
import { homedir, platform } from 'node:os';
|
|
29
|
+
import { dirname, join } from 'node:path';
|
|
30
|
+
import { execFile } from 'node:child_process';
|
|
31
|
+
import { promisify } from 'node:util';
|
|
32
|
+
const run = promisify(execFile);
|
|
33
|
+
/** Where the credentials file lives when one is used at all. */
|
|
34
|
+
export function credentialsPath() {
|
|
35
|
+
const xdg = process.env.XDG_CONFIG_HOME || join(homedir(), '.config');
|
|
36
|
+
const current = join(xdg, 'tablewalk', 'credentials.json');
|
|
37
|
+
/* The former location is read when the current one does not exist, so a
|
|
38
|
+
rename does not look like the tool forgetting every password. New writes
|
|
39
|
+
always go to the new path. (The project has been datawalk in between;
|
|
40
|
+
the datawalk-era file is the one worth finding.) */
|
|
41
|
+
if (!existsSync(current) && existsSync(join(xdg, 'datawalk', 'credentials.json'))) {
|
|
42
|
+
return join(xdg, 'datawalk', 'credentials.json');
|
|
43
|
+
}
|
|
44
|
+
return current;
|
|
45
|
+
}
|
|
46
|
+
/** The keychain service name. One namespace, addressed by connection name. */
|
|
47
|
+
const KEYCHAIN_SERVICE = 'tablewalk';
|
|
48
|
+
/* The service the datawalk-era stored under. Read as a fallback so existing
|
|
49
|
+
passwords keep working; anything (re)saved goes to the current service. */
|
|
50
|
+
const FORMER_SERVICE = 'datawalk';
|
|
51
|
+
/* ---------- 1. environment expansion ---------- */
|
|
52
|
+
/**
|
|
53
|
+
* Expand `${VAR}` and `$VAR` in a connection string.
|
|
54
|
+
*
|
|
55
|
+
* This is the recommended option, and the only one where the secret is never
|
|
56
|
+
* written anywhere tablewalk controls:
|
|
57
|
+
*
|
|
58
|
+
* { "name": "prod", "url": "postgres://app:${PROD_DB_PASSWORD}@db/app" }
|
|
59
|
+
*
|
|
60
|
+
* An unset variable is an error rather than an empty expansion. Silently
|
|
61
|
+
* producing `postgres://app:@db/app` would send an empty password and fail
|
|
62
|
+
* with an authentication error that says nothing about the real cause.
|
|
63
|
+
*/
|
|
64
|
+
export function expandEnv(url, env = process.env) {
|
|
65
|
+
return url.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g, (_, braced, bare) => {
|
|
66
|
+
const name = braced ?? bare;
|
|
67
|
+
const value = env[name];
|
|
68
|
+
if (value === undefined) {
|
|
69
|
+
throw new Error(`Connection references $${name}, which is not set. Export it, or remove it from the connection.`);
|
|
70
|
+
}
|
|
71
|
+
return value;
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
/** True when a URL carries a literal password rather than a reference. */
|
|
75
|
+
export function hasInlinePassword(url) {
|
|
76
|
+
try {
|
|
77
|
+
return Boolean(new URL(url).password);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/* ---------- 2. the OS keychain ---------- */
|
|
84
|
+
/**
|
|
85
|
+
* Read a password from the platform keychain.
|
|
86
|
+
*
|
|
87
|
+
* macOS only for now, through the `security` binary that ships with the OS —
|
|
88
|
+
* no dependency, no native module, and the secret is protected by the same
|
|
89
|
+
* thing protecting everything else on the machine. Linux and Windows return
|
|
90
|
+
* undefined and fall through to the file, which is honest: claiming keychain
|
|
91
|
+
* support and quietly storing plaintext would be worse than not offering it.
|
|
92
|
+
*/
|
|
93
|
+
export async function keychainGet(account) {
|
|
94
|
+
if (platform() !== 'darwin')
|
|
95
|
+
return undefined;
|
|
96
|
+
for (const service of [KEYCHAIN_SERVICE, FORMER_SERVICE]) {
|
|
97
|
+
try {
|
|
98
|
+
const { stdout } = await run('security', [
|
|
99
|
+
'find-generic-password', '-s', service, '-a', account, '-w',
|
|
100
|
+
]);
|
|
101
|
+
const secret = stdout.trim();
|
|
102
|
+
if (secret)
|
|
103
|
+
return secret;
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// Not found under this name, or the user declined the prompt. The
|
|
107
|
+
// former service is tried before falling through to the file.
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
export async function keychainSet(account, secret) {
|
|
113
|
+
if (platform() !== 'darwin')
|
|
114
|
+
return false;
|
|
115
|
+
try {
|
|
116
|
+
// -U updates in place rather than erroring when the item already exists.
|
|
117
|
+
await run('security', [
|
|
118
|
+
'add-generic-password', '-U', '-s', KEYCHAIN_SERVICE, '-a', account, '-w', secret,
|
|
119
|
+
]);
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
export async function keychainDelete(account) {
|
|
127
|
+
if (platform() !== 'darwin')
|
|
128
|
+
return;
|
|
129
|
+
try {
|
|
130
|
+
await run('security', ['delete-generic-password', '-s', KEYCHAIN_SERVICE, '-a', account]);
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
/* Already gone. */
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
export const keychainAvailable = () => platform() === 'darwin';
|
|
137
|
+
/**
|
|
138
|
+
* Read the credentials file, refusing it if anyone but the owner can read it.
|
|
139
|
+
*
|
|
140
|
+
* The permission check is the whole point. It is what `ssh` does to private
|
|
141
|
+
* keys and what `psql` does to `.pgpass`, and it catches the realistic
|
|
142
|
+
* mistake: a file created correctly, then committed, copied or synced with
|
|
143
|
+
* permissions that no longer match the intent. Refusing loudly is better than
|
|
144
|
+
* reading it and leaving the user believing it was safe.
|
|
145
|
+
*/
|
|
146
|
+
export async function readCredentials(path = credentialsPath()) {
|
|
147
|
+
let mode;
|
|
148
|
+
try {
|
|
149
|
+
mode = statSync(path).mode & 0o777;
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return {}; // No file is the normal case, not an error.
|
|
153
|
+
}
|
|
154
|
+
if (mode & 0o077) {
|
|
155
|
+
throw new Error(`${path} is readable by others (mode ${mode.toString(8)}). ` +
|
|
156
|
+
`Run: chmod 600 ${path}`);
|
|
157
|
+
}
|
|
158
|
+
try {
|
|
159
|
+
const parsed = JSON.parse(await readFile(path, 'utf8'));
|
|
160
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
|
|
161
|
+
return parsed;
|
|
162
|
+
throw new Error('expected an object of name → password');
|
|
163
|
+
}
|
|
164
|
+
catch (err) {
|
|
165
|
+
throw new Error(`${path} could not be read: ${err.message}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
export async function writeCredential(name, secret, path = credentialsPath()) {
|
|
169
|
+
const existing = await readCredentials(path).catch(() => ({}));
|
|
170
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
171
|
+
/* Written with the restrictive mode from the start rather than chmod-ed
|
|
172
|
+
afterwards — between a default-mode write and the chmod there is a window
|
|
173
|
+
where the secret is world-readable. */
|
|
174
|
+
await writeFile(path, `${JSON.stringify({ ...existing, [name]: secret }, null, 2)}\n`, { mode: 0o600 });
|
|
175
|
+
await chmod(path, 0o600);
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Turn a stored connection into a usable URL.
|
|
179
|
+
*
|
|
180
|
+
* Order matters and is deliberate: the environment wins because it is the
|
|
181
|
+
* most explicit, then the keychain because it is the best-protected, then the
|
|
182
|
+
* file. An inline password is used as-is but reported, so the UI can say what
|
|
183
|
+
* it is doing rather than leaving the user to guess.
|
|
184
|
+
*/
|
|
185
|
+
export async function resolveConnection(name, storedUrl, options = {}) {
|
|
186
|
+
// 1. Environment references, which may supply the whole URL or just part.
|
|
187
|
+
const expanded = expandEnv(storedUrl);
|
|
188
|
+
const usedEnv = expanded !== storedUrl;
|
|
189
|
+
/* Anything with a host has a credential to resolve; a bare file path does
|
|
190
|
+
not. This tested for Postgres specifically, so a MySQL connection skipped
|
|
191
|
+
the keychain and the credentials file entirely and never warned about a
|
|
192
|
+
password written into its URL — the checks were there and simply did not
|
|
193
|
+
run for it. Asking whether the target *has a host* is the question that
|
|
194
|
+
was always meant, and it does not need editing again for the next
|
|
195
|
+
adapter. */
|
|
196
|
+
if (!/^[a-z][\w+.-]*:\/\//i.test(expanded)) {
|
|
197
|
+
// SQLite is a file path; there is no credential to resolve.
|
|
198
|
+
return { url: expanded, source: usedEnv ? 'environment' : 'none' };
|
|
199
|
+
}
|
|
200
|
+
if (hasInlinePassword(expanded)) {
|
|
201
|
+
return {
|
|
202
|
+
url: expanded,
|
|
203
|
+
source: usedEnv ? 'environment' : 'inline',
|
|
204
|
+
warning: usedEnv
|
|
205
|
+
? undefined
|
|
206
|
+
: 'This connection has its password written into the URL. Prefer ${ENV_VAR}, or store it in the keychain.',
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
// 2 and 3: a URL with a user but no password can have one supplied.
|
|
210
|
+
let parsed;
|
|
211
|
+
try {
|
|
212
|
+
parsed = new URL(expanded);
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
return { url: expanded, source: 'none' };
|
|
216
|
+
}
|
|
217
|
+
const fromKeychain = await keychainGet(name);
|
|
218
|
+
if (fromKeychain) {
|
|
219
|
+
parsed.password = encodeURIComponent(fromKeychain);
|
|
220
|
+
return { url: parsed.toString(), source: 'keychain' };
|
|
221
|
+
}
|
|
222
|
+
const file = await readCredentials(options.credentialsFile);
|
|
223
|
+
if (file[name]) {
|
|
224
|
+
parsed.password = encodeURIComponent(file[name]);
|
|
225
|
+
return { url: parsed.toString(), source: 'file' };
|
|
226
|
+
}
|
|
227
|
+
// No password anywhere. Postgres may still accept it — peer auth, trust,
|
|
228
|
+
// or a ~/.pgpass the driver reads itself — so this is not an error.
|
|
229
|
+
return { url: expanded, source: 'none' };
|
|
230
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { findTable, primaryKey, referencesFrom, referencesTo, } from '../shared/schema.js';
|
|
2
|
+
import { insertOrder } from '../shared/order.js';
|
|
3
|
+
const DEFAULTS = { depth: 3, perChild: 5, max: 100 };
|
|
4
|
+
export async function extractFixture(adapter, schema, tableId, key, options = {}) {
|
|
5
|
+
const depth = Math.max(0, Math.min(6, options.depth ?? DEFAULTS.depth));
|
|
6
|
+
const perChild = Math.max(1, Math.min(50, options.perChild ?? DEFAULTS.perChild));
|
|
7
|
+
const max = Math.max(1, Math.min(500, options.max ?? DEFAULTS.max));
|
|
8
|
+
const root = findTable(schema, tableId);
|
|
9
|
+
if (!root)
|
|
10
|
+
throw new Error(`Unknown table "${tableId}".`);
|
|
11
|
+
/** table id → row identity → row. The identity keeps a row from being taken twice. */
|
|
12
|
+
const collected = new Map();
|
|
13
|
+
const truncated = [];
|
|
14
|
+
let rowsRead = 0;
|
|
15
|
+
const identity = (table, row) => {
|
|
16
|
+
const pk = primaryKey(findTable(schema, table));
|
|
17
|
+
return pk.length
|
|
18
|
+
? JSON.stringify(pk.map((c) => row[c]))
|
|
19
|
+
/* A keyless table cannot be deduped by key, so the whole row is its
|
|
20
|
+
own identity — which is right: two identical rows are one row as far
|
|
21
|
+
as a fixture is concerned. */
|
|
22
|
+
: JSON.stringify(row);
|
|
23
|
+
};
|
|
24
|
+
const total = () => [...collected.values()].reduce((n, rows) => n + rows.size, 0);
|
|
25
|
+
const take = (table, row) => {
|
|
26
|
+
const rows = collected.get(table) ?? new Map();
|
|
27
|
+
collected.set(table, rows);
|
|
28
|
+
const id = identity(table, row);
|
|
29
|
+
if (rows.has(id))
|
|
30
|
+
return false;
|
|
31
|
+
if (total() >= max) {
|
|
32
|
+
if (!truncated.includes(`the ${max}-row ceiling`))
|
|
33
|
+
truncated.push(`the ${max}-row ceiling`);
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
rows.set(id, row);
|
|
37
|
+
return true;
|
|
38
|
+
};
|
|
39
|
+
const fetch = async (table, filter, limit) => {
|
|
40
|
+
const found = await adapter.query({
|
|
41
|
+
table,
|
|
42
|
+
filter: { groups: [filter.map((c) => ({ column: c.column, op: '=', value: c.value }))] },
|
|
43
|
+
limit,
|
|
44
|
+
offset: 0,
|
|
45
|
+
});
|
|
46
|
+
rowsRead += found.rows.length;
|
|
47
|
+
return found.rows;
|
|
48
|
+
};
|
|
49
|
+
const rootRows = await fetch(root.id, primaryKey(root).map((c) => ({ column: c, value: key[c] })), 1);
|
|
50
|
+
if (!rootRows.length)
|
|
51
|
+
throw new Error(`No ${root.name} with that key.`);
|
|
52
|
+
take(root.id, rootRows[0]);
|
|
53
|
+
/* Children first, so that their parents are picked up by the same walk
|
|
54
|
+
that resolves the root's — a child pointing at an employee nobody
|
|
55
|
+
collected is a fixture that does not load. */
|
|
56
|
+
const queue = [
|
|
57
|
+
{ table: root.id, row: rootRows[0], hop: 0 },
|
|
58
|
+
];
|
|
59
|
+
if (options.children) {
|
|
60
|
+
for (const fk of referencesTo(schema, root.id)) {
|
|
61
|
+
if (total() >= max)
|
|
62
|
+
break;
|
|
63
|
+
const child = findTable(schema, fk.from.table);
|
|
64
|
+
if (!child)
|
|
65
|
+
continue;
|
|
66
|
+
const rows = await fetch(child.id, fk.from.columns.map((column, i) => ({
|
|
67
|
+
column,
|
|
68
|
+
value: rootRows[0][fk.to.columns[i]],
|
|
69
|
+
})), perChild);
|
|
70
|
+
if (rows.length === perChild)
|
|
71
|
+
truncated.push(`${child.name} at ${perChild} rows`);
|
|
72
|
+
for (const row of rows) {
|
|
73
|
+
if (take(child.id, row))
|
|
74
|
+
queue.push({ table: child.id, row, hop: 0 });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/* Then upwards from everything collected: the rows each one points at. */
|
|
79
|
+
while (queue.length) {
|
|
80
|
+
const { table, row, hop } = queue.shift();
|
|
81
|
+
if (hop >= depth)
|
|
82
|
+
continue;
|
|
83
|
+
for (const fk of referencesFrom(schema, table)) {
|
|
84
|
+
const values = fk.from.columns.map((c) => row[c]);
|
|
85
|
+
if (values.some((v) => v === null || v === undefined))
|
|
86
|
+
continue;
|
|
87
|
+
const parent = findTable(schema, fk.to.table);
|
|
88
|
+
if (!parent)
|
|
89
|
+
continue;
|
|
90
|
+
const rows = await fetch(parent.id, fk.to.columns.map((column, i) => ({ column, value: values[i] })), 1);
|
|
91
|
+
for (const found of rows) {
|
|
92
|
+
if (take(parent.id, found))
|
|
93
|
+
queue.push({ table: parent.id, row: found, hop: hop + 1 });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const plan = insertOrder(schema, [...collected.keys()]);
|
|
98
|
+
/* Anything the sort could not place still has to come out somewhere, and
|
|
99
|
+
the end is the honest place for it — the cycle is named in `order`. */
|
|
100
|
+
const ordered = [...plan.insert, ...plan.cycles.flat()];
|
|
101
|
+
const tables = ordered
|
|
102
|
+
.filter((id) => collected.has(id))
|
|
103
|
+
.map((id) => ({
|
|
104
|
+
table: id,
|
|
105
|
+
rows: sortWithin(schema, id, [...collected.get(id).values()]),
|
|
106
|
+
}));
|
|
107
|
+
if (plan.cycles.length) {
|
|
108
|
+
truncated.push(`a cycle between ${plan.cycles.flat().join(', ')} — those inserts need a nullable column set afterwards`);
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
table: root.id,
|
|
112
|
+
key,
|
|
113
|
+
tables,
|
|
114
|
+
order: tables.map((t) => t.table),
|
|
115
|
+
sql: toSql(schema, tables),
|
|
116
|
+
truncated: truncated.length ? truncated : undefined,
|
|
117
|
+
rowsRead,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Rows of a self-referencing table, parents before children.
|
|
122
|
+
*
|
|
123
|
+
* `work_order.parent_id → work_order` orders the rows inside the table
|
|
124
|
+
* rather than the table against another one, and a fixture that inserts the
|
|
125
|
+
* child first fails on exactly the constraint this whole function exists to
|
|
126
|
+
* respect. Rows whose parent is not in the fixture are unconstrained and go
|
|
127
|
+
* first.
|
|
128
|
+
*/
|
|
129
|
+
function sortWithin(schema, tableId, rows) {
|
|
130
|
+
const self = referencesFrom(schema, tableId).filter((fk) => fk.to.table === tableId);
|
|
131
|
+
if (!self.length || rows.length < 2)
|
|
132
|
+
return rows;
|
|
133
|
+
const table = findTable(schema, tableId);
|
|
134
|
+
const pk = primaryKey(table);
|
|
135
|
+
if (!pk.length)
|
|
136
|
+
return rows;
|
|
137
|
+
const idOf = (row) => JSON.stringify(pk.map((c) => row[c]));
|
|
138
|
+
const present = new Map(rows.map((row) => [idOf(row), row]));
|
|
139
|
+
const placed = new Set();
|
|
140
|
+
const out = [];
|
|
141
|
+
const place = (row, seen) => {
|
|
142
|
+
const id = idOf(row);
|
|
143
|
+
if (placed.has(id) || seen.has(id))
|
|
144
|
+
return;
|
|
145
|
+
seen.add(id);
|
|
146
|
+
for (const fk of self) {
|
|
147
|
+
const parentId = JSON.stringify(fk.from.columns.map((c) => row[c]));
|
|
148
|
+
const parent = present.get(parentId);
|
|
149
|
+
if (parent)
|
|
150
|
+
place(parent, seen);
|
|
151
|
+
}
|
|
152
|
+
if (placed.has(id))
|
|
153
|
+
return;
|
|
154
|
+
placed.add(id);
|
|
155
|
+
out.push(row);
|
|
156
|
+
};
|
|
157
|
+
for (const row of rows)
|
|
158
|
+
place(row, new Set());
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* The fixture as SQL.
|
|
163
|
+
*
|
|
164
|
+
* Values are written as literals rather than bound, which is the one place
|
|
165
|
+
* in this codebase that happens — because this is a *document*, not a
|
|
166
|
+
* statement the server runs. Nothing here reaches a database; it reaches a
|
|
167
|
+
* file, a test, or a paste buffer, and a parameter list would make it
|
|
168
|
+
* useless for all three.
|
|
169
|
+
*/
|
|
170
|
+
function toSql(schema, tables) {
|
|
171
|
+
const lines = [];
|
|
172
|
+
for (const { table, rows } of tables) {
|
|
173
|
+
if (!rows.length)
|
|
174
|
+
continue;
|
|
175
|
+
const meta = findTable(schema, table);
|
|
176
|
+
const columns = meta?.columns.map((c) => c.name) ?? Object.keys(rows[0]);
|
|
177
|
+
lines.push(`-- ${table}`);
|
|
178
|
+
for (const row of rows) {
|
|
179
|
+
const present = columns.filter((c) => row[c] !== undefined);
|
|
180
|
+
lines.push(`INSERT INTO ${quoteName(meta?.name ?? table)} (${present.map(quoteName).join(', ')}) `
|
|
181
|
+
+ `VALUES (${present.map((c) => literal(row[c])).join(', ')});`);
|
|
182
|
+
}
|
|
183
|
+
lines.push('');
|
|
184
|
+
}
|
|
185
|
+
return lines.join('\n').trim();
|
|
186
|
+
}
|
|
187
|
+
const quoteName = (id) => `"${id.replace(/"/g, '""')}"`;
|
|
188
|
+
function literal(value) {
|
|
189
|
+
if (value === null || value === undefined)
|
|
190
|
+
return 'NULL';
|
|
191
|
+
if (typeof value === 'number')
|
|
192
|
+
return String(value);
|
|
193
|
+
if (typeof value === 'boolean')
|
|
194
|
+
return value ? 'TRUE' : 'FALSE';
|
|
195
|
+
/* Doubling the quote is the standard escape and the only one every dialect
|
|
196
|
+
here agrees on — a backslash is an escape in MySQL and a character in
|
|
197
|
+
Postgres. */
|
|
198
|
+
return `'${String(value).replace(/'/g, "''")}'`;
|
|
199
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { tableNamed } from '../shared/schema.js';
|
|
2
|
+
/** A refusal that is about the request rather than about the database. */
|
|
3
|
+
export class GraphError extends Error {
|
|
4
|
+
constructor(message) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = 'GraphError';
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
const isRef = (v) => typeof v === 'object' && v !== null && typeof v.ref === 'string';
|
|
10
|
+
/**
|
|
11
|
+
* `fixture`'s shape, flattened into this one.
|
|
12
|
+
*
|
|
13
|
+
* The extractor answers `[{ table, rows: [...] }]` — a whole table at a time,
|
|
14
|
+
* in insert order — because that is how it reads. Accepting it here is what
|
|
15
|
+
* makes the pair a loop: take a real row out of development, put it into a
|
|
16
|
+
* test database, without an agent hand-rewriting the shape in between.
|
|
17
|
+
*/
|
|
18
|
+
export function flattenTables(tables) {
|
|
19
|
+
return tables.flatMap(({ table, rows }) => rows.map((values) => ({ table, values })));
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Check what can be checked before anything is written.
|
|
23
|
+
*
|
|
24
|
+
* Every one of these is a mistake in the request rather than a refusal from
|
|
25
|
+
* the database, and finding them here means the transaction is never opened —
|
|
26
|
+
* so the answer to "did anything happen" is a flat no rather than a rollback
|
|
27
|
+
* to reason about.
|
|
28
|
+
*/
|
|
29
|
+
function validate(schema, rows) {
|
|
30
|
+
if (!rows.length)
|
|
31
|
+
throw new GraphError('Nothing to insert. Give at least one row.');
|
|
32
|
+
const seen = new Map();
|
|
33
|
+
rows.forEach((row, i) => {
|
|
34
|
+
const at = `Row ${i + 1}`;
|
|
35
|
+
if (!row || typeof row !== 'object')
|
|
36
|
+
throw new GraphError(`${at} is not a row.`);
|
|
37
|
+
if (!row.table)
|
|
38
|
+
throw new GraphError(`${at} does not say which table it is for.`);
|
|
39
|
+
if (!tableNamed(schema, row.table)) {
|
|
40
|
+
/* Resolved the way `insert` resolves it, bare name included. A graph
|
|
41
|
+
that refused `customer` where a single `insert` accepted it would be
|
|
42
|
+
a difference an agent has to discover by wasting a call. */
|
|
43
|
+
throw new GraphError(`${at}: no table called "${row.table}". Ask \`tables\` for what exists.`);
|
|
44
|
+
}
|
|
45
|
+
if (!row.values || typeof row.values !== 'object' || !Object.keys(row.values).length) {
|
|
46
|
+
throw new GraphError(`${at} (${row.table}) has no values. Name at least one column.`);
|
|
47
|
+
}
|
|
48
|
+
if (row.as !== undefined) {
|
|
49
|
+
if (typeof row.as !== 'string' || !row.as.trim()) {
|
|
50
|
+
throw new GraphError(`${at} (${row.table}) has a label that is not a name.`);
|
|
51
|
+
}
|
|
52
|
+
if (seen.has(row.as)) {
|
|
53
|
+
throw new GraphError(`${at}: "${row.as}" is already the label of row ${seen.get(row.as) + 1}. Labels have to be unique to point at anything.`);
|
|
54
|
+
}
|
|
55
|
+
seen.set(row.as, i);
|
|
56
|
+
}
|
|
57
|
+
for (const [column, value] of Object.entries(row.values)) {
|
|
58
|
+
if (!isRef(value))
|
|
59
|
+
continue;
|
|
60
|
+
const target = seen.get(value.ref);
|
|
61
|
+
/* Earlier, strictly. A row can only point at a row that already exists,
|
|
62
|
+
and the alternative — resolving forwards — would mean deciding an
|
|
63
|
+
order the caller did not ask for. `order` is the tool that answers
|
|
64
|
+
"what has to exist first". */
|
|
65
|
+
if (target === undefined) {
|
|
66
|
+
throw new GraphError(seen.size
|
|
67
|
+
? `${at} (${row.table}.${column}) points at "${value.ref}", which is not the label of any earlier row. Known so far: ${[...seen.keys()].join(', ')}.`
|
|
68
|
+
: `${at} (${row.table}.${column}) points at "${value.ref}", but no row before it has a label.`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
/** What a `{ ref }` resolves to, once the row it names has been inserted. */
|
|
74
|
+
function resolveRef(ref, made, where) {
|
|
75
|
+
const source = made.get(ref.ref);
|
|
76
|
+
if (!source)
|
|
77
|
+
throw new GraphError(`${where} points at "${ref.ref}", which was not inserted.`);
|
|
78
|
+
if (ref.column) {
|
|
79
|
+
if (!source.row || !(ref.column in source.row)) {
|
|
80
|
+
throw new GraphError(`${where}: the row labelled "${ref.ref}" has no column "${ref.column}" to point at.`);
|
|
81
|
+
}
|
|
82
|
+
return source.row[ref.column];
|
|
83
|
+
}
|
|
84
|
+
const key = Object.entries(source.key);
|
|
85
|
+
if (key.length > 1) {
|
|
86
|
+
/* A composite key cannot stand in for one value. Named, because the fix
|
|
87
|
+
is to pick one of them. */
|
|
88
|
+
throw new GraphError(`${where}: "${ref.ref}" has a key of more than one column (${key.map(([c]) => c).join(', ')}), `
|
|
89
|
+
+ `so it cannot be a single value. Say which: { ref: "${ref.ref}", column: "…" }.`);
|
|
90
|
+
}
|
|
91
|
+
if (!key.length) {
|
|
92
|
+
throw new GraphError(`${where}: the row labelled "${ref.ref}" came back without a key, so there is nothing to point at. `
|
|
93
|
+
+ `Name a column instead: { ref: "${ref.ref}", column: "…" }.`);
|
|
94
|
+
}
|
|
95
|
+
return key[0][1];
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Insert a graph of rows in one transaction.
|
|
99
|
+
*
|
|
100
|
+
* Every row goes through the adapter's ordinary `insert`, so every guard it
|
|
101
|
+
* carries — real columns, values coerced to their declared types, NOT NULL
|
|
102
|
+
* refused before the statement, the database's own words for everything else
|
|
103
|
+
* — applies here unchanged. What this adds is the transaction around them and
|
|
104
|
+
* the labels between them.
|
|
105
|
+
*/
|
|
106
|
+
export async function insertGraph(adapter, schema, rows) {
|
|
107
|
+
if (!adapter.insert) {
|
|
108
|
+
throw new GraphError(`${schema.dialect} connections are read-only in this build.`);
|
|
109
|
+
}
|
|
110
|
+
if (!adapter.transaction) {
|
|
111
|
+
/* Refused rather than degraded to a loop of independent inserts. Doing
|
|
112
|
+
the work without the guarantee, under a name that promises it, is how a
|
|
113
|
+
half-written fixture comes to look like a working one. */
|
|
114
|
+
throw new GraphError(`${schema.dialect} connections cannot group writes into one transaction in this build, and a graph is only worth inserting as one.`);
|
|
115
|
+
}
|
|
116
|
+
validate(schema, rows);
|
|
117
|
+
return adapter.transaction(async (tx) => {
|
|
118
|
+
const made = new Map();
|
|
119
|
+
const inserted = [];
|
|
120
|
+
for (const [i, row] of rows.entries()) {
|
|
121
|
+
const where = `Row ${i + 1} (${row.table})`;
|
|
122
|
+
/* Resolved to the schema's own id before the adapter sees it. On
|
|
123
|
+
Postgres a bare `country` means `public.country` to the adapter and
|
|
124
|
+
`tablewalk_pg_fixture.country` to the schema the caller is browsing —
|
|
125
|
+
so passing the name through would write to a different table than the
|
|
126
|
+
one that was validated. Every other write tool resolves first for the
|
|
127
|
+
same reason. */
|
|
128
|
+
const table = tableNamed(schema, row.table).id;
|
|
129
|
+
const values = {};
|
|
130
|
+
for (const [column, value] of Object.entries(row.values)) {
|
|
131
|
+
values[column] = (isRef(value)
|
|
132
|
+
? resolveRef(value, made, `${where}.${column}`)
|
|
133
|
+
: value);
|
|
134
|
+
}
|
|
135
|
+
const result = await tx.insert({ table, values }).catch((err) => {
|
|
136
|
+
/* Named by position and table, because the message the database gives
|
|
137
|
+
is about a statement and the caller is looking at a list. "Nothing
|
|
138
|
+
was written" is the part worth being explicit about: the whole
|
|
139
|
+
point of the transaction is that a failure here is not a mess to
|
|
140
|
+
clean up. */
|
|
141
|
+
throw new Error(`${where}: ${err.message}\nNothing was written — the whole graph was rolled back.`);
|
|
142
|
+
});
|
|
143
|
+
if (row.as)
|
|
144
|
+
made.set(row.as, result);
|
|
145
|
+
inserted.push({ table, as: row.as, key: result.key, row: result.row });
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
inserted,
|
|
149
|
+
teardown: [...inserted].reverse().map(({ table, key }) => ({ table, key })),
|
|
150
|
+
rowsWritten: inserted.length,
|
|
151
|
+
};
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Remove a graph of rows in one transaction.
|
|
156
|
+
*
|
|
157
|
+
* The other half, and the half that decides whether a fixture is usable
|
|
158
|
+
* twice: a teardown that fails part way leaves exactly the mess the insert
|
|
159
|
+
* was careful not to. Rows are removed in the order given — `insertGraph`
|
|
160
|
+
* hands back that order already reversed — and each goes through the
|
|
161
|
+
* adapter's ordinary `remove`, so the whole primary key is still required and
|
|
162
|
+
* a key matching anything other than one row is still refused.
|
|
163
|
+
*/
|
|
164
|
+
export async function deleteGraph(adapter, schema, rows) {
|
|
165
|
+
if (!adapter.remove) {
|
|
166
|
+
throw new GraphError(`${schema.dialect} connections cannot delete in this build.`);
|
|
167
|
+
}
|
|
168
|
+
if (!adapter.transaction) {
|
|
169
|
+
throw new GraphError(`${schema.dialect} connections cannot group writes into one transaction in this build.`);
|
|
170
|
+
}
|
|
171
|
+
if (!rows.length)
|
|
172
|
+
throw new GraphError('Nothing to delete. Give at least one row.');
|
|
173
|
+
rows.forEach((row, i) => {
|
|
174
|
+
if (!row?.table)
|
|
175
|
+
throw new GraphError(`Row ${i + 1} does not say which table it is for.`);
|
|
176
|
+
if (!tableNamed(schema, row.table)) {
|
|
177
|
+
throw new GraphError(`Row ${i + 1}: no table called "${row.table}".`);
|
|
178
|
+
}
|
|
179
|
+
if (!row.key || !Object.keys(row.key).length) {
|
|
180
|
+
throw new GraphError(`Row ${i + 1} (${row.table}) has no key, and a delete needs one.`);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
return adapter.transaction(async (tx) => {
|
|
184
|
+
const removed = [];
|
|
185
|
+
for (const [i, row] of rows.entries()) {
|
|
186
|
+
const table = tableNamed(schema, row.table).id;
|
|
187
|
+
const result = await tx.remove({ table, key: row.key }).catch((err) => {
|
|
188
|
+
throw new Error(`Row ${i + 1} (${row.table}): ${err.message}\nNothing was removed — the whole teardown was rolled back.`);
|
|
189
|
+
});
|
|
190
|
+
removed.push({ table, key: row.key, affected: result.affected });
|
|
191
|
+
}
|
|
192
|
+
return { removed, rowsDeleted: removed.reduce((n, r) => n + r.affected, 0) };
|
|
193
|
+
});
|
|
194
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { referencesTo } from '../shared/schema.js';
|
|
2
|
+
/**
|
|
3
|
+
* Run `work` over `items`, at most `width` at a time, keeping input order.
|
|
4
|
+
*
|
|
5
|
+
* `Promise.all` over a map is the usual shape and is unbounded: it starts
|
|
6
|
+
* everything and hopes the pool underneath copes. For a handful of cheap
|
|
7
|
+
* queries that is right; for a dozen table scans it is a way to make one page
|
|
8
|
+
* load monopolise a connection.
|
|
9
|
+
*/
|
|
10
|
+
export async function inBatches(items, width, work) {
|
|
11
|
+
const out = [];
|
|
12
|
+
for (let i = 0; i < items.length; i += width) {
|
|
13
|
+
out.push(...(await Promise.all(items.slice(i, i + width).map(work))));
|
|
14
|
+
}
|
|
15
|
+
return out;
|
|
16
|
+
}
|
|
17
|
+
export async function deleteImpact(adapter, schema, tableId, key) {
|
|
18
|
+
const incoming = referencesTo(schema, tableId);
|
|
19
|
+
const found = await inBatches(incoming, 4, async (fk) => {
|
|
20
|
+
const conditions = fk.to.columns.map((toCol, i) => ({
|
|
21
|
+
column: fk.from.columns[i] ?? fk.from.columns[0],
|
|
22
|
+
op: '=',
|
|
23
|
+
value: key[toCol],
|
|
24
|
+
}));
|
|
25
|
+
if (conditions.some((c) => c.value === undefined || c.value === null))
|
|
26
|
+
return null;
|
|
27
|
+
const base = {
|
|
28
|
+
table: fk.from.table,
|
|
29
|
+
columns: fk.from.columns,
|
|
30
|
+
constraint: fk.name,
|
|
31
|
+
onDelete: fk.onDelete,
|
|
32
|
+
filter: { groups: [conditions] },
|
|
33
|
+
};
|
|
34
|
+
try {
|
|
35
|
+
return { ...base, count: await adapter.count(fk.from.table, base.filter) };
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
// One unreadable table must not stop the report; a count nobody could
|
|
39
|
+
// take is more honest as an error than as a zero.
|
|
40
|
+
return { ...base, count: -1, error: err.message };
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
/* Constraints with nothing behind them are dropped: "0 rows point at this"
|
|
44
|
+
is noise in a list whose only job is to say what would be affected. */
|
|
45
|
+
return found
|
|
46
|
+
.filter((r) => r !== null)
|
|
47
|
+
.filter((r) => r.count !== 0);
|
|
48
|
+
}
|