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,173 @@
|
|
|
1
|
+
/** Enough to walk back a mistake, not a session. Matches the browser's depth. */
|
|
2
|
+
const DEPTH = 50;
|
|
3
|
+
/* Per connection: a write on staging is not undoable on production, and a
|
|
4
|
+
single stack would offer exactly that. */
|
|
5
|
+
const journals = new Map();
|
|
6
|
+
let nextId = 1;
|
|
7
|
+
const entriesFor = (connection) => {
|
|
8
|
+
if (!journals.has(connection))
|
|
9
|
+
journals.set(connection, []);
|
|
10
|
+
return journals.get(connection);
|
|
11
|
+
};
|
|
12
|
+
/** Record a write that landed. Returns the entry, mostly for tests. */
|
|
13
|
+
export function recordWrite(connection, entry) {
|
|
14
|
+
const full = {
|
|
15
|
+
id: nextId++,
|
|
16
|
+
connection,
|
|
17
|
+
at: new Date().toISOString(),
|
|
18
|
+
...entry,
|
|
19
|
+
};
|
|
20
|
+
const list = entriesFor(connection);
|
|
21
|
+
list.push(full);
|
|
22
|
+
if (list.length > DEPTH)
|
|
23
|
+
list.shift();
|
|
24
|
+
return full;
|
|
25
|
+
}
|
|
26
|
+
/** The most recent first, which is the order anyone asks about them in. */
|
|
27
|
+
export function history(connection, limit = 10) {
|
|
28
|
+
return [...entriesFor(connection)].reverse().slice(0, Math.max(1, limit));
|
|
29
|
+
}
|
|
30
|
+
/** The newest write that could still be undone, if there is one. */
|
|
31
|
+
export function lastRevertible(connection) {
|
|
32
|
+
return [...entriesFor(connection)].reverse().find((e) => e.revertible);
|
|
33
|
+
}
|
|
34
|
+
export function forget(connection, id) {
|
|
35
|
+
const list = entriesFor(connection);
|
|
36
|
+
const at = list.findIndex((e) => e.id === id);
|
|
37
|
+
if (at !== -1)
|
|
38
|
+
list.splice(at, 1);
|
|
39
|
+
}
|
|
40
|
+
/** Testing seam: a fresh process is the normal state, and tests want it too. */
|
|
41
|
+
export function clearJournal(connection) {
|
|
42
|
+
if (connection === undefined)
|
|
43
|
+
journals.clear();
|
|
44
|
+
else
|
|
45
|
+
journals.delete(connection);
|
|
46
|
+
}
|
|
47
|
+
const keyFilter = (key) => ({
|
|
48
|
+
groups: [Object.entries(key).map(([column, value]) => ({
|
|
49
|
+
column,
|
|
50
|
+
op: '=',
|
|
51
|
+
value: value,
|
|
52
|
+
}))],
|
|
53
|
+
});
|
|
54
|
+
/** The row as it stands now, or undefined if it is gone. */
|
|
55
|
+
async function currentRow(adapter, table, key) {
|
|
56
|
+
const result = await adapter.query({ table, filter: keyFilter(key), limit: 1, offset: 0 });
|
|
57
|
+
return result.rows[0];
|
|
58
|
+
}
|
|
59
|
+
/* Compared as strings, because a value that went through the database and came
|
|
60
|
+
back may be a different JavaScript type than the one that went in — SQLite
|
|
61
|
+
hands back an integer for a boolean, and a driver may widen a numeric. The
|
|
62
|
+
question is "is this still the value we wrote", not "is this the same
|
|
63
|
+
object". */
|
|
64
|
+
const same = (a, b) => a === b || (a ?? null) === (b ?? null) || String(a ?? '') === String(b ?? '');
|
|
65
|
+
/**
|
|
66
|
+
* Undo one recorded write.
|
|
67
|
+
*
|
|
68
|
+
* Every step is checked against the database before anything is written, and
|
|
69
|
+
* the whole revert is refused if any of them has moved on. Half an undo is
|
|
70
|
+
* worse than none: it leaves the row in a state neither the write nor the
|
|
71
|
+
* revert intended, and nothing recorded that it happened.
|
|
72
|
+
*/
|
|
73
|
+
export async function revert(adapter, connection, entry) {
|
|
74
|
+
if (!entry.revertible) {
|
|
75
|
+
return { reverted: false, entry, errors: [entry.reason ?? 'That write cannot be undone.'] };
|
|
76
|
+
}
|
|
77
|
+
if (!adapter.update || !adapter.insert || !adapter.remove) {
|
|
78
|
+
return { reverted: false, entry, errors: ['This connection cannot write in this build.'] };
|
|
79
|
+
}
|
|
80
|
+
/* An insert is undone by removing what it added, and a delete by putting it
|
|
81
|
+
back — so a graph's steps are reversed, children before parents or the
|
|
82
|
+
other way round depending on which it was. The order a graph went in is
|
|
83
|
+
the order it must come out of, backwards. */
|
|
84
|
+
const steps = entry.kind === 'insert_graph' || entry.kind === 'delete_graph'
|
|
85
|
+
? [...entry.steps].reverse()
|
|
86
|
+
: entry.steps;
|
|
87
|
+
const undoing = entry.kind === 'insert' || entry.kind === 'insert_graph'
|
|
88
|
+
? 'remove'
|
|
89
|
+
: entry.kind === 'delete' || entry.kind === 'delete_graph'
|
|
90
|
+
? 'restore'
|
|
91
|
+
: 'put back';
|
|
92
|
+
/* Checked first, all of it, before anything is written. */
|
|
93
|
+
const problems = [];
|
|
94
|
+
for (const step of steps) {
|
|
95
|
+
if (!step.key) {
|
|
96
|
+
problems.push(`A ${step.table} row in this write has no key, so it cannot be found again.`);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const row = await currentRow(adapter, step.table, step.key);
|
|
100
|
+
if (undoing === 'restore') {
|
|
101
|
+
/* The row was deleted; putting it back is only honest if nothing has
|
|
102
|
+
taken its key since. */
|
|
103
|
+
if (row)
|
|
104
|
+
problems.push(`${step.table} ${describe(step.key)} exists again, so restoring it would overwrite something.`);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (!row) {
|
|
108
|
+
problems.push(`${step.table} ${describe(step.key)} is gone, so there is nothing to undo.`);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const moved = Object.entries(step.after ?? {}).filter(([column, value]) => !same(row[column], value));
|
|
112
|
+
if (moved.length) {
|
|
113
|
+
problems.push(`${step.table} ${describe(step.key)} has changed since — "${moved[0][0]}" is no longer what this write left. `
|
|
114
|
+
+ 'Undoing would overwrite that.');
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (problems.length)
|
|
118
|
+
return { reverted: false, entry, errors: problems };
|
|
119
|
+
const done = [];
|
|
120
|
+
try {
|
|
121
|
+
for (const step of steps) {
|
|
122
|
+
if (undoing === 'remove') {
|
|
123
|
+
await adapter.remove({ table: step.table, key: step.key });
|
|
124
|
+
done.push({ table: step.table, key: step.key, action: 'removed' });
|
|
125
|
+
}
|
|
126
|
+
else if (undoing === 'restore') {
|
|
127
|
+
await adapter.insert({
|
|
128
|
+
table: step.table,
|
|
129
|
+
values: (step.before ?? {}),
|
|
130
|
+
});
|
|
131
|
+
done.push({ table: step.table, key: step.key, action: 'restored' });
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
await adapter.update({
|
|
135
|
+
table: step.table,
|
|
136
|
+
key: step.key,
|
|
137
|
+
values: (step.before ?? {}),
|
|
138
|
+
});
|
|
139
|
+
done.push({ table: step.table, key: step.key, action: 'put back' });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
/* Reported with what had already been done rather than swallowed: a
|
|
145
|
+
partial revert is a fact about the database, and hiding it would leave
|
|
146
|
+
someone to discover it later without knowing why. */
|
|
147
|
+
return {
|
|
148
|
+
reverted: false,
|
|
149
|
+
entry,
|
|
150
|
+
steps: done,
|
|
151
|
+
errors: [`${err.message}${done.length ? ` — ${done.length} step(s) had already been undone.` : ''}`],
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
forget(connection, entry.id);
|
|
155
|
+
return { reverted: true, entry, steps: done };
|
|
156
|
+
}
|
|
157
|
+
const describe = (key) => {
|
|
158
|
+
const parts = Object.entries(key);
|
|
159
|
+
return parts.length === 1 ? String(parts[0][1]) : parts.map(([c, v]) => `${c}=${v}`).join(',');
|
|
160
|
+
};
|
|
161
|
+
/** A sentence for the history, in the words someone would use about it. */
|
|
162
|
+
export function summarise(kind, steps, detail) {
|
|
163
|
+
const table = steps[0]?.table ?? 'something';
|
|
164
|
+
const n = steps.length;
|
|
165
|
+
switch (kind) {
|
|
166
|
+
case 'update': return `Updated ${table} ${steps[0]?.key ? describe(steps[0].key) : ''}`.trim();
|
|
167
|
+
case 'insert': return `Inserted into ${table}`;
|
|
168
|
+
case 'delete': return `Deleted ${table} ${steps[0]?.key ? describe(steps[0].key) : ''}`.trim();
|
|
169
|
+
case 'insert_graph': return `Inserted ${n} row${n === 1 ? '' : 's'} as one graph`;
|
|
170
|
+
case 'delete_graph': return `Deleted ${n} row${n === 1 ? '' : 's'} as one graph`;
|
|
171
|
+
default: return detail ? `Ran SQL: ${detail}` : 'Ran a SQL write';
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Record layouts, on disk.
|
|
3
|
+
*
|
|
4
|
+
* A layout is bound to a table and applies to every record of it, which makes
|
|
5
|
+
* it the longest-lived thing tablewalk holds: a query is asked once, a filter
|
|
6
|
+
* tab lasts a session, but "this is how a work order is laid out" is a
|
|
7
|
+
* decision made once and relied on afterwards. Keeping it in localStorage
|
|
8
|
+
* meant it lived exactly as long as the browser profile that made it — a
|
|
9
|
+
* different browser, a cleared site setting, or a second machine and the
|
|
10
|
+
* work was gone with no way to get it back.
|
|
11
|
+
*
|
|
12
|
+
* So layouts are stored server-side, in a file the user can read, diff, copy
|
|
13
|
+
* between machines and check into a repo if they want to share them. Two
|
|
14
|
+
* things follow from that choice and are deliberate:
|
|
15
|
+
*
|
|
16
|
+
* - The file holds *only* layouts. It is written by the app, so it must not
|
|
17
|
+
* be a file the user also hand-maintains; connections stay in
|
|
18
|
+
* tablewalk.json, which the app never writes.
|
|
19
|
+
* - Layouts are keyed by connection id and then table id, because the same
|
|
20
|
+
* table name means different things on different databases and a layout
|
|
21
|
+
* built for one should not quietly apply to another.
|
|
22
|
+
*
|
|
23
|
+
* Unlike credentials there is no secret here, so there is no permission
|
|
24
|
+
* discipline to enforce — a layout is a preference, and a readable file is
|
|
25
|
+
* the point rather than a risk.
|
|
26
|
+
*/
|
|
27
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
28
|
+
import { homedir } from 'node:os';
|
|
29
|
+
import { dirname, join } from 'node:path';
|
|
30
|
+
export function layoutsPath() {
|
|
31
|
+
const xdg = process.env.XDG_CONFIG_HOME || join(homedir(), '.config');
|
|
32
|
+
return join(xdg, 'tablewalk', 'layouts.json');
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Saved pages live in a sibling file with the same shape — connection id,
|
|
36
|
+
* then page id. The same shape because it earns the same machinery: pages
|
|
37
|
+
* were the one piece of built work that lived only in localStorage, so a
|
|
38
|
+
* page assembled in one browser was invisible to every other, while the
|
|
39
|
+
* record layout beside it travelled. Same store, same guarantees.
|
|
40
|
+
*/
|
|
41
|
+
export function pagesPath() {
|
|
42
|
+
const xdg = process.env.XDG_CONFIG_HOME || join(homedir(), '.config');
|
|
43
|
+
return join(xdg, 'tablewalk', 'pages.json');
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Read every stored layout.
|
|
47
|
+
*
|
|
48
|
+
* A missing file is the normal case — most connections have never had a
|
|
49
|
+
* layout saved — and an unreadable one falls back to empty rather than
|
|
50
|
+
* refusing to serve the page. A record with its derived layout is a working
|
|
51
|
+
* page; a record that will not render because a preferences file has a stray
|
|
52
|
+
* comma in it is not.
|
|
53
|
+
*/
|
|
54
|
+
export async function readLayouts(path = layoutsPath()) {
|
|
55
|
+
try {
|
|
56
|
+
const parsed = JSON.parse(await readFile(path, 'utf8'));
|
|
57
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
|
|
58
|
+
return parsed;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
/* No file, or an unparseable one. Either way there are no layouts. */
|
|
62
|
+
}
|
|
63
|
+
return {};
|
|
64
|
+
}
|
|
65
|
+
/** Every layout for one connection. */
|
|
66
|
+
export async function layoutsFor(connection, path = layoutsPath()) {
|
|
67
|
+
return (await readLayouts(path))[connection] ?? {};
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Store or remove one table's layout.
|
|
71
|
+
*
|
|
72
|
+
* Read-modify-write on the whole file rather than a patch, because the file
|
|
73
|
+
* is small and the alternative is a format that supports partial updates —
|
|
74
|
+
* complexity bought with nothing. Passing `null` removes the entry, which is
|
|
75
|
+
* what "Reset to default" means: not an empty layout, but no layout, so the
|
|
76
|
+
* derived one takes over again.
|
|
77
|
+
*/
|
|
78
|
+
/**
|
|
79
|
+
* One write at a time.
|
|
80
|
+
*
|
|
81
|
+
* Saving a layout is read-the-whole-file, change one key, write the whole file
|
|
82
|
+
* back. Two saves that overlap both read the same file, and the second write
|
|
83
|
+
* lands on top of the first — so a layout saved in one tab disappeared when a
|
|
84
|
+
* layout was saved in another, with nothing to say it had. The window is
|
|
85
|
+
* small and the loss is silent, which is the combination that makes it a bug
|
|
86
|
+
* report nobody can reproduce.
|
|
87
|
+
*
|
|
88
|
+
* A promise chain rather than a lock: each write waits for the one before it,
|
|
89
|
+
* so the read that decides what to write always sees the previous write's
|
|
90
|
+
* result.
|
|
91
|
+
*
|
|
92
|
+
* One chain *per file*, not one per process. The race is two writes to the
|
|
93
|
+
* same file, and a single chain also made every unrelated file wait — layouts
|
|
94
|
+
* behind pages, this connection's behind another's, and a write that is slow
|
|
95
|
+
* or stuck on one path holding up every path. The invariant is about a file,
|
|
96
|
+
* so the queue is about a file.
|
|
97
|
+
*/
|
|
98
|
+
const writing = new Map();
|
|
99
|
+
export function writeLayout(connection, table, layout, path = layoutsPath()) {
|
|
100
|
+
/* Chained off the previous write to *this file* whether it succeeded or
|
|
101
|
+
not: one failed save must not stop every later one, which is what
|
|
102
|
+
chaining off a rejected promise would do. */
|
|
103
|
+
const queued = (writing.get(path) ?? Promise.resolve())
|
|
104
|
+
.catch(() => { })
|
|
105
|
+
.then(() => writeLayoutNow(connection, table, layout, path));
|
|
106
|
+
writing.set(path, queued);
|
|
107
|
+
/* Cleared when it is the last write to that path, so a long-lived process
|
|
108
|
+
does not accumulate an entry per file it has ever written. */
|
|
109
|
+
void queued.catch(() => { }).then(() => {
|
|
110
|
+
if (writing.get(path) === queued)
|
|
111
|
+
writing.delete(path);
|
|
112
|
+
});
|
|
113
|
+
return queued;
|
|
114
|
+
}
|
|
115
|
+
async function writeLayoutNow(connection, table, layout, path) {
|
|
116
|
+
const all = await readLayouts(path);
|
|
117
|
+
const forConnection = { ...(all[connection] ?? {}) };
|
|
118
|
+
if (layout)
|
|
119
|
+
forConnection[table] = layout;
|
|
120
|
+
else
|
|
121
|
+
delete forConnection[table];
|
|
122
|
+
if (Object.keys(forConnection).length)
|
|
123
|
+
all[connection] = forConnection;
|
|
124
|
+
else
|
|
125
|
+
delete all[connection];
|
|
126
|
+
await mkdir(dirname(path), { recursive: true });
|
|
127
|
+
await writeFile(path, `${JSON.stringify(all, null, 2)}\n`);
|
|
128
|
+
}
|