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,163 @@
1
+ /**
2
+ * Completion for the SQL editor.
3
+ *
4
+ * The query language's completer knows the grammar it is completing, because
5
+ * tablewalk wrote that grammar. SQL is somebody else's language and this is not
6
+ * a parser for it — writing one to power a dropdown would be a large amount of
7
+ * code that is wrong in a different way for each dialect.
8
+ *
9
+ * What it does instead is answer two questions from the text to the left of the
10
+ * caret: **what is in scope**, and **what kind of thing goes here**. Both are
11
+ * answerable with a scan, and both degrade in a way that costs the reader
12
+ * nothing — a wrong guess offers a list they ignore, where a parser that
13
+ * refused to guess would offer nothing at all.
14
+ *
15
+ * The scope question is answered by finding `FROM x`, `JOIN y AS b` and their
16
+ * aliases anywhere in the statement, not only before the caret: people write
17
+ * `SELECT ` and then the columns they want, and at that moment the tables are
18
+ * all to the *right* of where they are typing. A completer that only looked
19
+ * backwards would be silent at exactly the moment it is most wanted.
20
+ */
21
+ import { findTable, state } from './core.js';
22
+
23
+ /* Keywords offered as a last resort, in the order they occur in a statement
24
+ rather than alphabetically — the point of the list is to suggest what comes
25
+ next, and `WHERE` is a likelier next word than `ALL`. */
26
+ const KEYWORDS = [
27
+ 'SELECT', 'FROM', 'WHERE', 'GROUP BY', 'HAVING', 'ORDER BY', 'LIMIT', 'OFFSET',
28
+ 'JOIN', 'LEFT JOIN', 'INNER JOIN', 'ON', 'AS', 'AND', 'OR', 'NOT', 'IN', 'LIKE',
29
+ 'IS NULL', 'IS NOT NULL', 'BETWEEN', 'CASE', 'WHEN', 'THEN', 'ELSE', 'END',
30
+ 'DISTINCT', 'COUNT(', 'SUM(', 'AVG(', 'MIN(', 'MAX(', 'COALESCE(', 'WITH', 'UNION',
31
+ 'ASC', 'DESC', 'EXISTS',
32
+ ];
33
+
34
+ /** Words after which a table name is what comes next. */
35
+ const EXPECTS_TABLE = /\b(from|join|update|into|table)\s+(\w*)$/i;
36
+
37
+ /** Words after which a column name is what comes next. */
38
+ const EXPECTS_COLUMN =
39
+ /\b(select|where|on|and|or|by|having|set|distinct|,|\(|=|<|>|<=|>=|!=|<>|\+|-)\s*(\w*)$/i;
40
+
41
+ /**
42
+ * Tables named in the statement, by the name they are referred to.
43
+ *
44
+ * Both directions: the alias if there is one, and the table's own name, since
45
+ * `FROM customer` makes `customer.id` valid too. Matching is deliberately loose
46
+ * about the shape of what follows — `AS` optional, a comma or newline ending
47
+ * it — because the alternative is a parser.
48
+ */
49
+ export function scopeOf(text) {
50
+ const scope = new Map();
51
+ const pattern = /\b(?:from|join|update|into)\s+([A-Za-z_][\w.$]*)(?:\s+(?:as\s+)?([A-Za-z_]\w*))?/gi;
52
+
53
+ for (const match of text.matchAll(pattern)) {
54
+ const [, name, alias] = match;
55
+ // `FROM customer WHERE` — `WHERE` is not an alias, and neither is any other
56
+ // word that can legally follow a table name.
57
+ const isKeyword = alias && /^(where|join|left|right|inner|outer|cross|on|group|order|limit|having|union|set|values|using|and|or)$/i.test(alias);
58
+ const table = findTable(name) ?? state.schema?.tables.find((t) => t.name === name);
59
+ if (!table) continue;
60
+ scope.set(table.name, table);
61
+ scope.set(table.id, table);
62
+ if (alias && !isKeyword) scope.set(alias, table);
63
+ }
64
+ return scope;
65
+ }
66
+
67
+ /** The word being typed, and where it starts. Splits on SQL's punctuation. */
68
+ export function tokenAt(text, caret) {
69
+ const before = text.slice(0, caret);
70
+ const start = Math.max(0, ...[' ', '\n', '\t', ',', '(', ')', '=', '<', '>', '+', '-', '*', '/', ';']
71
+ .map((c) => before.lastIndexOf(c) + 1));
72
+ return { word: before.slice(start), start };
73
+ }
74
+
75
+ const columnItem = (table, column, detail) => ({
76
+ kind: column.primaryKey ? 'key' : column.references ? 'ref' : 'column',
77
+ label: column.name,
78
+ value: column.name,
79
+ detail: detail ?? `${table.name} · ${column.type}`,
80
+ });
81
+
82
+ /**
83
+ * What could come next, given where the caret is.
84
+ *
85
+ * Ordering within the list matters more than the list itself: the first entry
86
+ * is what Tab accepts, so it has to be the likeliest thing rather than the
87
+ * alphabetically first. Columns of tables actually in the statement come before
88
+ * every other table's columns, and keywords come last — a keyword is always
89
+ * *possible*, which is exactly why it is never the best guess.
90
+ */
91
+ export function sqlSuggestions(text, caret) {
92
+ if (!state.schema) return [];
93
+ const { word } = tokenAt(text, caret);
94
+ const before = text.slice(0, caret);
95
+ const scope = scopeOf(text);
96
+ const matches = (name) => name.toLowerCase().startsWith(word.toLowerCase());
97
+
98
+ /* `alias.` — the one case with a definite answer. Nothing else can follow a
99
+ qualified name, so nothing else is offered. */
100
+ const qualified = /([A-Za-z_]\w*)\.(\w*)$/.exec(before);
101
+ if (qualified) {
102
+ const [, prefix, partial] = qualified;
103
+ const table = scope.get(prefix) ?? findTable(prefix) ?? state.schema.tables.find((t) => t.name === prefix);
104
+ if (!table) return [];
105
+ return table.columns
106
+ .filter((c) => c.name.toLowerCase().startsWith(partial.toLowerCase()))
107
+ .map((c) => ({ ...columnItem(table, c), value: c.name }));
108
+ }
109
+
110
+ const out = [];
111
+ const tableItem = (t) => ({
112
+ kind: t.isView ? 'view' : 'table',
113
+ label: t.name,
114
+ value: t.id,
115
+ detail: `${t.columns.length} column${t.columns.length === 1 ? '' : 's'}`,
116
+ });
117
+
118
+ if (EXPECTS_TABLE.test(before)) {
119
+ // After FROM, a table is the only sensible suggestion, so nothing dilutes it.
120
+ return state.schema.tables.filter((t) => matches(t.name) || matches(t.id)).map(tableItem);
121
+ }
122
+
123
+ if (EXPECTS_COLUMN.test(before) || word) {
124
+ for (const table of new Set(scope.values())) {
125
+ for (const column of table.columns) {
126
+ if (matches(column.name)) out.push(columnItem(table, column));
127
+ }
128
+ }
129
+ /* Aliases and table names themselves, so `c.` is reachable by typing `c`
130
+ and accepting rather than by remembering it. */
131
+ for (const [name, table] of scope) {
132
+ if (matches(name) && name !== table.name) {
133
+ out.push({ kind: 'alias', label: name, value: name, detail: `alias for ${table.name}` });
134
+ }
135
+ }
136
+ /* Every other table's columns, once the statement's own are exhausted. A
137
+ column typed before its FROM clause exists is a real way people write
138
+ SQL, and offering nothing there is worse than offering a long list. */
139
+ if (!out.length) {
140
+ for (const table of state.schema.tables) {
141
+ if (scope.has(table.name)) continue;
142
+ for (const column of table.columns) {
143
+ if (matches(column.name)) out.push(columnItem(table, column, `${table.name} · not in this statement`));
144
+ }
145
+ }
146
+ }
147
+ }
148
+
149
+ if (!word || EXPECTS_TABLE.test(before) === false) {
150
+ for (const keyword of KEYWORDS) {
151
+ if (matches(keyword)) {
152
+ out.push({ kind: 'keyword', label: keyword, value: keyword, detail: '' });
153
+ }
154
+ }
155
+ }
156
+
157
+ // Tables last among the non-keywords: a bare word is more often a column.
158
+ for (const table of state.schema.tables) {
159
+ if (matches(table.name) && !scope.has(table.name)) out.push(tableItem(table));
160
+ }
161
+
162
+ return out;
163
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Saved SQL statements: the ones you keep, and the ones your team commits.
3
+ *
4
+ * Two stores, one list. A statement in `tablewalk.json` was checked in by
5
+ * somebody and is the same to the person looking for it as one they saved this
6
+ * morning — the only difference is whether they may delete it. Merging them
7
+ * here rather than showing two lists is the whole point: nobody wants to
8
+ * remember which half of their query library they are looking at.
9
+ *
10
+ * Stored as text, never as anything compiled. It stays readable in the editor,
11
+ * it survives the schema changing underneath it — at worst as a visible error
12
+ * you can fix rather than a silently wrong answer — and a statement in the
13
+ * config file can be reviewed by someone who knows SQL and nothing about this
14
+ * tool.
15
+ */
16
+ import { api, loadJson, saveJson, state, toast } from './core.js';
17
+
18
+ const KEY = 'tablewalk.sql.v1';
19
+
20
+ const connectionKey = () => state.activeConnection ?? 'default';
21
+
22
+ /** Config-defined statements for the open connection, fetched once per switch. */
23
+ let fromConfig = [];
24
+ let fetchedFor = null;
25
+
26
+ export async function loadSavedSql() {
27
+ const connection = connectionKey();
28
+ try {
29
+ const data = await api('/api/queries');
30
+ /* Checked against the connection in hand, not the one this call started
31
+ on. Two fast switches meant the slower reply landed last and wrote
32
+ *its* statements under the *current* connection's name — one database's
33
+ saved SQL listed as another's, offered on a schema it was never written
34
+ against.
35
+
36
+ The server says which connection answered, so the reply is checked
37
+ against that rather than against a variable this function captured. */
38
+ if (data.connection && data.connection !== connectionKey()) return;
39
+ if (connection !== connectionKey()) return;
40
+ fromConfig = data.queries ?? [];
41
+ fetchedFor = connectionKey();
42
+ } catch {
43
+ /* A config file that does not answer costs the committed half of the list.
44
+ The browser's own statements still work, which is the right way round. */
45
+ if (connection !== connectionKey()) return;
46
+ fromConfig = [];
47
+ fetchedFor = connectionKey();
48
+ }
49
+ }
50
+
51
+ function localFor(connection) {
52
+ return loadJson(KEY, {})[connection] ?? [];
53
+ }
54
+
55
+ /**
56
+ * Everything saved for this connection, config first.
57
+ *
58
+ * Config first because a checked-in statement is usually the canonical one and
59
+ * the personal ones are variations on it.
60
+ */
61
+ export function savedStatements() {
62
+ const connection = connectionKey();
63
+ const config = fetchedFor === connection ? fromConfig : [];
64
+ return [
65
+ ...config.map((q) => ({ ...q, source: 'config' })),
66
+ ...localFor(connection).map((q) => ({ ...q, source: 'saved' })),
67
+ ];
68
+ }
69
+
70
+ /**
71
+ * Keep a statement under a name.
72
+ *
73
+ * A repeated name replaces rather than duplicating: two entries called "Daily
74
+ * totals" that do different things is worse than losing the older one, and the
75
+ * person typing the name a second time is almost always correcting the first.
76
+ * A name the config file already uses is refused instead — that one is not
77
+ * ours to replace, and silently shadowing it would make a committed statement
78
+ * mean something different on one machine.
79
+ */
80
+ export function saveStatement(name, sql) {
81
+ const connection = connectionKey();
82
+ const trimmed = name.trim();
83
+ if (!trimmed) return false;
84
+
85
+ if (fromConfig.some((q) => q.name === trimmed)) {
86
+ toast(`"${trimmed}" is defined in tablewalk.json. Choose another name.`, 'error');
87
+ return false;
88
+ }
89
+
90
+ const all = loadJson(KEY, {});
91
+ const kept = (all[connection] ?? []).filter((q) => q.name !== trimmed);
92
+ kept.push({ name: trimmed, sql: sql.trim() });
93
+ all[connection] = kept;
94
+
95
+ if (!saveJson(KEY, all)) {
96
+ toast('Could not save — this browser is not allowing local storage.', 'error');
97
+ return false;
98
+ }
99
+ return true;
100
+ }
101
+
102
+ export function removeStatement(name) {
103
+ const connection = connectionKey();
104
+ const all = loadJson(KEY, {});
105
+ all[connection] = (all[connection] ?? []).filter((q) => q.name !== name);
106
+ saveJson(KEY, all);
107
+ }