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,195 @@
1
+ import { findTable, referencesFrom, referencesTo, } from '../shared/schema.js';
2
+ import { inBatches } from './impact.js';
3
+ /**
4
+ * Read the facts, then say what they mean.
5
+ *
6
+ * `column` narrows the question: without it this is "what depends on this
7
+ * table", with it "what depends on this column" — which is the narrower and
8
+ * far more common migration. A named column that no foreign key touches still
9
+ * gets its own facts, because `NOT NULL` and `UNIQUE` are changes that break
10
+ * on the data rather than on the graph.
11
+ */
12
+ export async function changeImpact(adapter, schema, tableId, column) {
13
+ const table = findTable(schema, tableId);
14
+ if (!table) {
15
+ throw new Error(`No table called "${tableId}" in this database.`);
16
+ }
17
+ const meta = column
18
+ ? table.columns.find((c) => c.name.toLowerCase() === column.toLowerCase())
19
+ : undefined;
20
+ if (column && !meta) {
21
+ throw new Error(`"${column}" is not a column on ${table.id}. It has: ${table.columns.map((c) => c.name).join(', ')}.`);
22
+ }
23
+ /* Narrowed to the named column when there is one: a foreign key pointing at
24
+ `customer.id` is irrelevant to a change to `customer.email`, and listing
25
+ it anyway is how a report becomes noise nobody reads to the end of. */
26
+ const incoming = referencesTo(schema, table.id).filter((fk) => !meta || fk.to.columns.some((c) => c.toLowerCase() === meta.name.toLowerCase()));
27
+ const outgoing = referencesFrom(schema, table.id).filter((fk) => !meta || fk.from.columns.some((c) => c.toLowerCase() === meta.name.toLowerCase()));
28
+ /* One count per dependent table, batched. `deleteImpact` bounds its width
29
+ for the same reason: a dozen unbounded table scans is how one question
30
+ monopolises the connection everything else is waiting on. */
31
+ const counts = await inBatches(incoming, 4, async (fk) => {
32
+ try {
33
+ return await adapter.count(fk.from.table);
34
+ }
35
+ catch {
36
+ /* A table that cannot be counted — a permission, a view over something
37
+ missing — must not take the rest of the report down with it. */
38
+ return undefined;
39
+ }
40
+ });
41
+ const referencedBy = incoming.map((fk, i) => ({
42
+ constraint: fk.name,
43
+ table: fk.from.table,
44
+ columns: fk.from.columns,
45
+ toColumns: fk.to.columns,
46
+ onDelete: fk.onDelete,
47
+ nullable: nullableRef(schema, fk),
48
+ indexed: indexedRef(schema, fk),
49
+ rows: counts[i],
50
+ }));
51
+ const references = outgoing.map((fk) => ({
52
+ constraint: fk.name,
53
+ columns: fk.from.columns,
54
+ table: fk.to.table,
55
+ toColumns: fk.to.columns,
56
+ onDelete: fk.onDelete,
57
+ }));
58
+ let rows;
59
+ try {
60
+ rows = await adapter.count(table.id);
61
+ }
62
+ catch {
63
+ rows = undefined;
64
+ }
65
+ let columnFacts;
66
+ if (meta) {
67
+ /* Counted only where it can be non-zero. On a NOT NULL column the answer
68
+ is already known, and spending a scan to be told zero is a scan the
69
+ person waiting did not need. */
70
+ let nulls;
71
+ if (meta.nullable) {
72
+ try {
73
+ nulls = await adapter.count(table.id, {
74
+ groups: [[{ column: meta.name, op: 'isNull' }]],
75
+ });
76
+ }
77
+ catch {
78
+ nulls = undefined;
79
+ }
80
+ }
81
+ columnFacts = {
82
+ name: meta.name,
83
+ type: meta.type,
84
+ nullable: meta.nullable,
85
+ primaryKey: meta.primaryKey,
86
+ ...(meta.default !== undefined ? { default: meta.default } : {}),
87
+ ...(meta.allowed ? { allowed: meta.allowed } : {}),
88
+ ...(meta.references ? { references: meta.references } : {}),
89
+ ...(leads(schema, table.id, meta.name) === undefined
90
+ ? {}
91
+ : { indexed: leads(schema, table.id, meta.name) }),
92
+ ...(nulls === undefined ? {} : { nulls }),
93
+ };
94
+ }
95
+ return {
96
+ table: table.id,
97
+ ...(meta ? { column: meta.name } : {}),
98
+ ...(rows === undefined ? {} : { rows }),
99
+ ...(columnFacts ? { columnFacts } : {}),
100
+ referencedBy,
101
+ references,
102
+ notes: notesFor({
103
+ table: table.id,
104
+ ...(meta ? { column: meta.name } : {}),
105
+ ...(rows === undefined ? {} : { rows }),
106
+ ...(columnFacts ? { columnFacts } : {}),
107
+ referencedBy,
108
+ references,
109
+ notes: [],
110
+ }),
111
+ };
112
+ }
113
+ /** Does an index lead on this column? `undefined` when nobody looked. */
114
+ function leads(schema, tableId, column) {
115
+ const table = findTable(schema, tableId);
116
+ if (!table || table.indexed === undefined)
117
+ return undefined;
118
+ return table.indexed.some((c) => c.toLowerCase() === column.toLowerCase());
119
+ }
120
+ /** Can every referencing column hold null — so can those rows survive? */
121
+ function nullableRef(schema, fk) {
122
+ const child = findTable(schema, fk.from.table);
123
+ if (!child)
124
+ return false;
125
+ return fk.from.columns.every((name) => {
126
+ const col = child.columns.find((c) => c.name.toLowerCase() === name.toLowerCase());
127
+ /* A column we cannot find is not evidence that it is nullable. */
128
+ return col ? col.nullable : false;
129
+ });
130
+ }
131
+ /** Does an index lead on the referencing side? `undefined` when unknown. */
132
+ function indexedRef(schema, fk) {
133
+ return leads(schema, fk.from.table, fk.from.columns[0]);
134
+ }
135
+ /**
136
+ * Turn the facts into the sentences somebody would write after reading them.
137
+ *
138
+ * Ordered by how much they change the plan: what refuses the change outright,
139
+ * then what silently destroys data, then what will merely be slow.
140
+ */
141
+ function notesFor(impact) {
142
+ const notes = [];
143
+ const { columnFacts: col, referencedBy, references, rows } = impact;
144
+ const what = impact.column ? `${impact.table}.${impact.column}` : impact.table;
145
+ if (col?.primaryKey) {
146
+ notes.push(`${what} is part of the primary key`
147
+ + (referencedBy.length
148
+ ? `, and ${referencedBy.length} table${referencedBy.length === 1 ? '' : 's'} reference it.`
149
+ : '.'));
150
+ }
151
+ /* The change that fails on the data rather than on the graph, and the most
152
+ common one to get wrong: the column is full of nulls and the migration
153
+ that adds NOT NULL is written as though it is not. */
154
+ if (col && col.nullable && !col.primaryKey && col.nulls !== undefined) {
155
+ const of = rows === undefined ? '' : ` of ${rows.toLocaleString()}`;
156
+ notes.push(col.nulls > 0
157
+ ? `${col.nulls.toLocaleString()}${of} rows hold null, so NOT NULL would be refused until they `
158
+ + `are filled or given a default.`
159
+ : `No row${of} holds null, so NOT NULL would be accepted today — nothing stops the next insert `
160
+ + `from breaking that.`);
161
+ }
162
+ for (const dep of referencedBy) {
163
+ const count = dep.rows === undefined ? 'an unknown number of' : dep.rows.toLocaleString();
164
+ const held = `${count} row${dep.rows === 1 ? '' : 's'} in ${dep.table}`;
165
+ if (dep.onDelete === 'cascade') {
166
+ notes.push(`${dep.table}.${dep.columns.join(', ')} cascades: removing what it points at takes ${held} with it, `
167
+ + `and anything cascading from those.`);
168
+ }
169
+ else if (dep.onDelete === 'set null') {
170
+ notes.push(`${dep.table}.${dep.columns.join(', ')} is set null on delete: ${held} would survive with a hole.`);
171
+ }
172
+ else if (dep.onDelete === undefined) {
173
+ notes.push(`${dep.table}.${dep.columns.join(', ')} points at ${what} and its delete rule was not reported, `
174
+ + `so what happens to ${held} is unknown rather than nothing.`);
175
+ }
176
+ else {
177
+ notes.push(`${dep.table}.${dep.columns.join(', ')} points at ${what} under \`${dep.onDelete}\`, `
178
+ + `so the change is refused while ${held} remain${dep.rows === 1 ? 's' : ''}.`);
179
+ }
180
+ if (!dep.nullable) {
181
+ notes.push(`${dep.table}.${dep.columns.join(', ')} is NOT NULL, so those rows cannot be left pointing at nothing.`);
182
+ }
183
+ if (dep.indexed === false) {
184
+ notes.push(`No index leads on ${dep.table}.${dep.columns[0]}, so every check of this constraint scans `
185
+ + `${dep.rows === undefined ? 'the whole table' : `${dep.rows.toLocaleString()} rows`}.`);
186
+ }
187
+ }
188
+ if (col?.allowed?.length) {
189
+ notes.push(`The database constrains it to: ${col.allowed.join(', ')}. Widening it means changing that too.`);
190
+ }
191
+ if (!referencedBy.length && !references.length) {
192
+ notes.push(`Nothing in this database references ${what}, so the change is contained to ${impact.table}.`);
193
+ }
194
+ return notes;
195
+ }