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,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
+ }