@adecore/database 0.21.1 → 0.22.0

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 (109) hide show
  1. package/dist/assign-ref.d.ts +2 -0
  2. package/dist/assign-ref.js +9 -0
  3. package/dist/client/client.js +14 -15
  4. package/dist/client/types.d.ts +1 -0
  5. package/dist/client/types.js +2 -0
  6. package/dist/connections/ConnectionForm.js +7 -2
  7. package/dist/connections/ConnectionManager.js +5 -5
  8. package/dist/connections/EngineIcon.js +0 -1
  9. package/dist/console/QueryConsole.js +12 -4
  10. package/dist/console/editing.js +2 -4
  11. package/dist/console/useConsoleHistory.js +3 -2
  12. package/dist/ddl/alter-mysql.js +2 -8
  13. package/dist/ddl/alter-sqlite.js +1 -1
  14. package/dist/ddl/column.js +5 -8
  15. package/dist/ddl/create.d.ts +1 -0
  16. package/dist/ddl/create.js +1 -1
  17. package/dist/ddl/diff.js +25 -23
  18. package/dist/designer/ColumnsEditor.js +8 -6
  19. package/dist/designer/ForeignKeysEditor.js +5 -1
  20. package/dist/designer/OptionsEditor.js +12 -3
  21. package/dist/designer/SqlPreview.d.ts +1 -5
  22. package/dist/designer/SqlPreview.js +1 -1
  23. package/dist/designer/TableDesigner.js +7 -6
  24. package/dist/explorer/DatabaseExplorer.js +12 -13
  25. package/dist/explorer/TableDialog.js +1 -1
  26. package/dist/explorer/tree.js +6 -16
  27. package/dist/grid/DataGrid.js +11 -14
  28. package/dist/grid/EnumPicker.js +4 -3
  29. package/dist/grid/GridHeaderCell.d.ts +1 -1
  30. package/dist/grid/GridHeaderCell.js +2 -2
  31. package/dist/grid/RecordView.js +2 -1
  32. package/dist/grid/ValueDock.js +2 -6
  33. package/dist/grid/aggregates.js +2 -3
  34. package/dist/grid/column-layout.js +3 -2
  35. package/dist/grid/copy-formats.js +2 -3
  36. package/dist/grid/display.d.ts +0 -1
  37. package/dist/grid/display.js +1 -3
  38. package/dist/grid/layout.js +6 -11
  39. package/dist/host/helper-path.js +8 -0
  40. package/dist/host/host.js +11 -16
  41. package/dist/host/validate.js +44 -43
  42. package/dist/sql.d.ts +2 -0
  43. package/dist/sql.js +5 -2
  44. package/dist/structure/StructureView.js +5 -6
  45. package/dist/table/TableView.js +19 -12
  46. package/dist/table/command-field.js +8 -12
  47. package/dist/table/grid-rows.js +2 -1
  48. package/dist/table/layout-store.js +5 -2
  49. package/dist/table/pending.js +11 -3
  50. package/dist/table/useTableTransfer.js +6 -5
  51. package/dist/testing/fake.js +7 -9
  52. package/dist/value/ValueEditor.js +1 -1
  53. package/dist/value/draft.js +1 -6
  54. package/dist/value/json.js +4 -4
  55. package/dist/workbench/DatabaseWorkbench.js +12 -5
  56. package/dist/workbench/tabs.js +3 -4
  57. package/package.json +6 -7
  58. package/src/assign-ref.ts +10 -0
  59. package/src/client/client.ts +17 -18
  60. package/src/client/types.ts +3 -0
  61. package/src/connections/ConnectionForm.tsx +62 -47
  62. package/src/connections/ConnectionManager.tsx +6 -5
  63. package/src/connections/EngineIcon.tsx +0 -2
  64. package/src/console/QueryConsole.tsx +52 -31
  65. package/src/console/editing.ts +2 -4
  66. package/src/console/useConsoleHistory.ts +5 -2
  67. package/src/ddl/alter-mysql.ts +3 -11
  68. package/src/ddl/alter-sqlite.ts +1 -1
  69. package/src/ddl/column.ts +5 -7
  70. package/src/ddl/create.ts +1 -1
  71. package/src/ddl/diff.ts +30 -25
  72. package/src/designer/ColumnsEditor.tsx +145 -125
  73. package/src/designer/ForeignKeysEditor.tsx +44 -27
  74. package/src/designer/OptionsEditor.tsx +95 -75
  75. package/src/designer/SqlPreview.tsx +2 -3
  76. package/src/designer/TableDesigner.tsx +7 -6
  77. package/src/explorer/DatabaseExplorer.tsx +26 -31
  78. package/src/explorer/TableDialog.tsx +1 -1
  79. package/src/explorer/tree.ts +8 -19
  80. package/src/grid/DataGrid.tsx +11 -14
  81. package/src/grid/EnumPicker.tsx +4 -3
  82. package/src/grid/GridHeaderCell.tsx +2 -3
  83. package/src/grid/RecordView.tsx +2 -1
  84. package/src/grid/ValueDock.tsx +2 -5
  85. package/src/grid/aggregates.ts +2 -4
  86. package/src/grid/column-layout.ts +3 -2
  87. package/src/grid/copy-formats.ts +2 -3
  88. package/src/grid/display.ts +1 -5
  89. package/src/grid/layout.ts +10 -11
  90. package/src/host/helper-path.ts +8 -8
  91. package/src/host/host.ts +11 -16
  92. package/src/host/validate.ts +45 -43
  93. package/src/protocol/messages.ts +0 -1
  94. package/src/sql.ts +7 -2
  95. package/src/structure/StructureView.tsx +7 -8
  96. package/src/table/CommandField.tsx +0 -1
  97. package/src/table/TableView.tsx +48 -34
  98. package/src/table/command-field.ts +9 -12
  99. package/src/table/grid-rows.ts +2 -7
  100. package/src/table/layout-store.ts +6 -3
  101. package/src/table/pending.ts +12 -7
  102. package/src/table/useTableTransfer.ts +7 -5
  103. package/src/testing/fake.ts +8 -12
  104. package/src/value/ValueEditor.tsx +1 -1
  105. package/src/value/draft.ts +1 -6
  106. package/src/value/json.ts +4 -4
  107. package/src/workbench/DatabaseWorkbench.tsx +14 -6
  108. package/src/workbench/DesignerTab.tsx +1 -1
  109. package/src/workbench/tabs.ts +5 -8
@@ -1,5 +1,6 @@
1
1
  import { formatBytes, formatNumeral } from '@adecore/ui/format';
2
2
  import type { BinaryValue, Cell, EditValue, ValueKind } from '../protocol/index.ts';
3
+ import { isNumericKind } from '../sql.ts';
3
4
 
4
5
  /* What a cell shows: a cell of a result, or the column's default for a value an edit set to `DEFAULT`. */
5
6
  export type Shown = Cell | { readonly kind: 'default' };
@@ -22,11 +23,6 @@ export const DISPLAY_LIMIT = 200;
22
23
  /* How many bytes of a binary value the cell spells out. */
23
24
  export const BINARY_PREVIEW_BYTES = 8;
24
25
 
25
- const NUMERIC_KINDS: ReadonlySet<ValueKind> = new Set(['integer', 'decimal', 'float']);
26
-
27
- /* Whether the cells of a column of this kind sit against the end of their cell, as numbers do. */
28
- export const isNumericKind = (kind: ValueKind): boolean => NUMERIC_KINDS.has(kind);
29
-
30
26
  const ELLIPSIS = '…';
31
27
 
32
28
  const isObject = (cell: Shown): cell is Exclude<Shown, null | boolean | number | string> => cell !== null && typeof cell === 'object';
@@ -78,19 +78,18 @@ export const gutterWidth = (rowCount: number): number => Math.max(GUTTER_MIN_WID
78
78
 
79
79
  export const clampColumnWidth = (width: number): number => Math.max(MIN_COLUMN_WIDTH, Math.min(Math.ceil(width), MAX_COLUMN_WIDTH));
80
80
 
81
- /* From the longest text among the header and the first rows. `keyed` makes room for the key icon in front of the name. */
82
- export const estimateColumnWidth = (header: string, samples: readonly string[], keyed: boolean): number => {
83
- const body = samples.slice(0, SAMPLE_ROWS).reduce((longest, text) => Math.max(longest, text.length), 0);
84
- const named = header.length * CHARACTER_WIDTH + (keyed ? KEY_ICON_ROOM : 0) + SORT_ROOM;
85
- return clampColumnWidth(Math.max(body * CHARACTER_WIDTH, named) + CELL_PADDING);
86
- };
81
+ const bodyWidth = (texts: readonly string[]): number => texts.reduce((longest, text) => Math.max(longest, text.length), 0) * CHARACTER_WIDTH;
82
+
83
+ /* `keyed` makes room for the key icon in front of the name. */
84
+ const headerWidth = (header: string, keyed: boolean): number => header.length * CHARACTER_WIDTH + (keyed ? KEY_ICON_ROOM : 0) + SORT_ROOM;
85
+
86
+ /* From the longest text among the header and the first rows. */
87
+ export const estimateColumnWidth = (header: string, samples: readonly string[], keyed: boolean): number =>
88
+ clampColumnWidth(Math.max(bodyWidth(samples.slice(0, SAMPLE_ROWS)), headerWidth(header, keyed)) + CELL_PADDING);
87
89
 
88
90
  /* Wide enough for the header and every text of the column, up to `MAX_FIT_WIDTH`. */
89
- export const fitColumnWidth = (header: string, texts: readonly string[], keyed: boolean): number => {
90
- const body = texts.reduce((longest, text) => Math.max(longest, text.length), 0) * CHARACTER_WIDTH;
91
- const named = header.length * CHARACTER_WIDTH + (keyed ? KEY_ICON_ROOM : 0) + SORT_ROOM;
92
- return Math.max(MIN_COLUMN_WIDTH, Math.min(Math.ceil(Math.max(body, named) + CELL_PADDING), MAX_FIT_WIDTH));
93
- };
91
+ export const fitColumnWidth = (header: string, texts: readonly string[], keyed: boolean): number =>
92
+ Math.max(MIN_COLUMN_WIDTH, Math.min(Math.ceil(Math.max(bodyWidth(texts), headerWidth(header, keyed)) + CELL_PADDING), MAX_FIT_WIDTH));
94
93
 
95
94
  export const isDoubleClick = (previous: number | null, now: number): boolean => previous !== null && now - previous <= DOUBLE_CLICK_MS;
96
95
 
@@ -2,14 +2,6 @@ import { existsSync } from 'node:fs';
2
2
  import { createRequire } from 'node:module';
3
3
  import { dirname, join, sep } from 'node:path';
4
4
 
5
- /*
6
- * Finds the prebuilt helper that npm installed for this machine, in the `@adecore/database-<platform>-<arch>`
7
- * package, or null when there is none: an unsupported platform, or an install with optional dependencies off.
8
- *
9
- * A binary cannot run from inside an Electron archive. The path is mapped from `app.asar` to
10
- * `app.asar.unpacked`, so the app must unpack the packages (electron-builder: `asarUnpack` with
11
- * `node_modules/@adecore/database-*` followed by `/**`).
12
- */
13
5
  export interface HelperPathOptions {
14
6
  /* `process.platform` when left out. */
15
7
  readonly platform?: string;
@@ -20,6 +12,14 @@ export interface HelperPathOptions {
20
12
  readonly exists?: (path: string) => boolean;
21
13
  }
22
14
 
15
+ /*
16
+ * Finds the prebuilt helper that npm installed for this machine, in the `@adecore/database-<platform>-<arch>`
17
+ * package, or null when there is none: an unsupported platform, or an install with optional dependencies off.
18
+ *
19
+ * A binary cannot run from inside an Electron archive. The path is mapped from `app.asar` to
20
+ * `app.asar.unpacked`, so the app must unpack the packages (electron-builder: `asarUnpack` with
21
+ * `node_modules/@adecore/database-*` followed by `/**`).
22
+ */
23
23
  export const helperPath = ({
24
24
  platform = process.platform,
25
25
  arch = process.arch,
package/src/host/host.ts CHANGED
@@ -7,7 +7,6 @@ const DEFAULT_READY_TIMEOUT_MS = 10000;
7
7
  /* How long a release or a dispose waits for a helper to answer its `close` requests. */
8
8
  const CLOSE_GRACE_MS = 2000;
9
9
 
10
- /* A failure with the code the caller gets. */
11
10
  class Failure extends Error {
12
11
  readonly code: DatabaseErrorCode;
13
12
 
@@ -41,6 +40,15 @@ const failure = (id: string, code: DatabaseErrorCode, message: string): Database
41
40
 
42
41
  const messageOf = (error: unknown): string => (error instanceof Error ? error.message : String(error));
43
42
 
43
+ /* `undefined` for a line that is not JSON. */
44
+ const parseLine = (line: string): unknown => {
45
+ try {
46
+ return JSON.parse(line);
47
+ } catch {
48
+ return undefined;
49
+ }
50
+ };
51
+
44
52
  const isDict = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value);
45
53
 
46
54
  /* Settles when every promise has, or after the grace period; a helper that hangs must not hold up an app that quits. */
@@ -115,14 +123,7 @@ export const createDatabaseHost = (options: DatabaseHostOptions): DatabaseHost =
115
123
  };
116
124
 
117
125
  const onHandshake = (run: Run, line: string): void => {
118
- let message: unknown;
119
-
120
- try {
121
- message = JSON.parse(line);
122
- } catch {
123
- abort(run, 'The helper did not start with a ready line.');
124
- return;
125
- }
126
+ const message = parseLine(line);
126
127
 
127
128
  if (!isDict(message) || message.event !== 'ready') {
128
129
  abort(run, 'The helper did not start with a ready line.');
@@ -140,13 +141,7 @@ export const createDatabaseHost = (options: DatabaseHostOptions): DatabaseHost =
140
141
  };
141
142
 
142
143
  const onResponse = (run: Run, line: string): void => {
143
- let message: unknown;
144
-
145
- try {
146
- message = JSON.parse(line);
147
- } catch {
148
- return;
149
- }
144
+ const message = parseLine(line);
150
145
 
151
146
  if (!isDict(message) || typeof message.id !== 'string') {
152
147
  return;
@@ -24,6 +24,8 @@ const fail = (message: string): never => {
24
24
 
25
25
  const isDict = (value: unknown): value is Dict => typeof value === 'object' && value !== null && !Array.isArray(value);
26
26
 
27
+ const isRequestId = (value: unknown): value is string => typeof value === 'string' && value.length > 0 && value.length <= MAX_ID_LENGTH;
28
+
27
29
  /* Rejects a key nobody asked for, so a typo cannot pass for an option the helper ignores. */
28
30
  const shape = (value: unknown, label: string, required: readonly string[], optional: readonly string[] = []): Dict => {
29
31
  if (!isDict(value)) {
@@ -45,13 +47,13 @@ const shape = (value: unknown, label: string, required: readonly string[], optio
45
47
  return value;
46
48
  };
47
49
 
48
- const text = (value: unknown, label: string): void => {
50
+ const filledText = (value: unknown, label: string): void => {
49
51
  if (typeof value !== 'string' || value.length === 0) {
50
52
  fail(`"${label}" must be a non-empty string.`);
51
53
  }
52
54
  };
53
55
 
54
- const plainText = (value: unknown, label: string): void => {
56
+ const anyText = (value: unknown, label: string): void => {
55
57
  if (typeof value !== 'string') {
56
58
  fail(`"${label}" must be a string.`);
57
59
  }
@@ -76,7 +78,7 @@ const oneOf = (allowed: readonly string[]) => (value: unknown, label: string) =>
76
78
  };
77
79
 
78
80
  const absolutePath = (value: unknown, label: string): void => {
79
- text(value, label);
81
+ filledText(value, label);
80
82
 
81
83
  if (!ABSOLUTE_PATH.test(value as string)) {
82
84
  fail(`"${label}" must be an absolute path.`);
@@ -148,18 +150,18 @@ const tunnel = (input: unknown, label: string): void => {
148
150
 
149
151
  if (input.kind === 'ssh') {
150
152
  const config = shape(input, label, ['kind', 'host'], ['port', 'user', 'identityFile']);
151
- text(config.host, `${label}.host`);
153
+ filledText(config.host, `${label}.host`);
152
154
  optional(config, 'port', label, port);
153
- optional(config, 'user', label, text);
154
- optional(config, 'identityFile', label, text);
155
+ optional(config, 'user', label, filledText);
156
+ optional(config, 'identityFile', label, filledText);
155
157
  return;
156
158
  }
157
159
 
158
160
  if (input.kind === 'docker') {
159
161
  const config = shape(input, label, ['kind', 'container'], ['port', 'context']);
160
- text(config.container, `${label}.container`);
162
+ filledText(config.container, `${label}.container`);
161
163
  optional(config, 'port', label, port);
162
- optional(config, 'context', label, text);
164
+ optional(config, 'context', label, filledText);
163
165
  return;
164
166
  }
165
167
 
@@ -186,12 +188,12 @@ const connection = (input: unknown, label: string): void => {
186
188
 
187
189
  // A Docker tunnel finds the server by its container, so the host is left empty.
188
190
  const viaDocker = isDict(config.tunnel) && config.tunnel.kind === 'docker';
189
- (viaDocker ? plainText : text)(config.host, `${label}.host`);
190
- text(config.user, `${label}.user`);
191
+ (viaDocker ? anyText : filledText)(config.host, `${label}.host`);
192
+ filledText(config.user, `${label}.user`);
191
193
  optional(config, 'port', label, port);
192
- optional(config, 'socket', label, text);
193
- optional(config, 'password', label, plainText);
194
- optional(config, 'database', label, plainText);
194
+ optional(config, 'socket', label, filledText);
195
+ optional(config, 'password', label, anyText);
196
+ optional(config, 'database', label, anyText);
195
197
  optional(config, 'tls', label, oneOf(TLS_MODES));
196
198
  optional(config, 'readOnly', label, flag);
197
199
  return;
@@ -229,17 +231,17 @@ const exportSource = (input: unknown, label: string): void => {
229
231
 
230
232
  if (input.kind === 'table') {
231
233
  const source = shape(input, label, ['kind', 'schema', 'table'], ['where', 'orderBy']);
232
- text(source.schema, `${label}.schema`);
233
- text(source.table, `${label}.table`);
234
- optional(source, 'where', label, plainText);
235
- optional(source, 'orderBy', label, plainText);
234
+ filledText(source.schema, `${label}.schema`);
235
+ filledText(source.table, `${label}.table`);
236
+ optional(source, 'where', label, anyText);
237
+ optional(source, 'orderBy', label, anyText);
236
238
  return;
237
239
  }
238
240
 
239
241
  if (input.kind === 'query') {
240
242
  const source = shape(input, label, ['kind', 'sql'], ['schema']);
241
- text(source.sql, `${label}.sql`);
242
- optional(source, 'schema', label, text);
243
+ filledText(source.sql, `${label}.sql`);
244
+ optional(source, 'schema', label, filledText);
243
245
  return;
244
246
  }
245
247
 
@@ -249,9 +251,9 @@ const exportSource = (input: unknown, label: string): void => {
249
251
  const cellLimit = (params: Dict): void => optional(params, 'cellLimit', 'params', (limit, name) => integer(limit, name, 1, MAX_CELL_LIMIT));
250
252
 
251
253
  const table = (params: Dict): void => {
252
- text(params.session, 'params.session');
253
- text(params.schema, 'params.schema');
254
- text(params.table, 'params.table');
254
+ filledText(params.session, 'params.session');
255
+ filledText(params.schema, 'params.schema');
256
+ filledText(params.table, 'params.table');
255
257
  };
256
258
 
257
259
  const TARGET = ['session', 'schema', 'table'];
@@ -259,19 +261,19 @@ const TARGET = ['session', 'schema', 'table'];
259
261
  const PARAMS: { readonly [M in DatabaseMethod]: (input: unknown) => void } = {
260
262
  open: (input) => connection(shape(input, 'params', ['connection']).connection, 'params.connection'),
261
263
  test: (input) => connection(shape(input, 'params', ['connection']).connection, 'params.connection'),
262
- close: (input) => text(shape(input, 'params', ['session']).session, 'params.session'),
263
- schemas: (input) => text(shape(input, 'params', ['session']).session, 'params.session'),
264
+ close: (input) => filledText(shape(input, 'params', ['session']).session, 'params.session'),
265
+ schemas: (input) => filledText(shape(input, 'params', ['session']).session, 'params.session'),
264
266
  tables: (input) => {
265
267
  const params = shape(input, 'params', ['session', 'schema']);
266
- text(params.session, 'params.session');
267
- text(params.schema, 'params.schema');
268
+ filledText(params.session, 'params.session');
269
+ filledText(params.schema, 'params.schema');
268
270
  },
269
271
  structure: (input) => table(shape(input, 'params', TARGET)),
270
272
  rows: (input) => {
271
273
  const params = shape(input, 'params', [...TARGET, 'offset', 'limit'], ['where', 'orderBy', 'cellLimit']);
272
274
  table(params);
273
- optional(params, 'where', 'params', plainText);
274
- optional(params, 'orderBy', 'params', plainText);
275
+ optional(params, 'where', 'params', anyText);
276
+ optional(params, 'orderBy', 'params', anyText);
275
277
  integer(params.offset, 'params.offset', 0, Number.MAX_SAFE_INTEGER);
276
278
  integer(params.limit, 'params.limit', 1, MAX_LIMIT);
277
279
  cellLimit(params);
@@ -279,13 +281,13 @@ const PARAMS: { readonly [M in DatabaseMethod]: (input: unknown) => void } = {
279
281
  count: (input) => {
280
282
  const params = shape(input, 'params', TARGET, ['where']);
281
283
  table(params);
282
- optional(params, 'where', 'params', plainText);
284
+ optional(params, 'where', 'params', anyText);
283
285
  },
284
286
  cell: (input) => {
285
287
  const params = shape(input, 'params', [...TARGET, 'key', 'column']);
286
288
  table(params);
287
289
  values(params.key, 'params.key', value, true);
288
- text(params.column, 'params.column');
290
+ filledText(params.column, 'params.column');
289
291
  },
290
292
  apply: (input) => {
291
293
  const params = shape(input, 'params', [...TARGET, 'changes']);
@@ -300,34 +302,34 @@ const PARAMS: { readonly [M in DatabaseMethod]: (input: unknown) => void } = {
300
302
  },
301
303
  execute: (input) => {
302
304
  const params = shape(input, 'params', ['session', 'sql'], ['schema', 'limit', 'cellLimit']);
303
- text(params.session, 'params.session');
304
- text(params.sql, 'params.sql');
305
- optional(params, 'schema', 'params', text);
305
+ filledText(params.session, 'params.session');
306
+ filledText(params.sql, 'params.sql');
307
+ optional(params, 'schema', 'params', filledText);
306
308
  optional(params, 'limit', 'params', (limit, name) => integer(limit, name, 1, MAX_LIMIT));
307
309
  cellLimit(params);
308
310
  },
309
311
  page: (input) => {
310
312
  const params = shape(input, 'params', ['session', 'sql', 'offset', 'limit'], ['schema', 'cellLimit']);
311
- text(params.session, 'params.session');
312
- text(params.sql, 'params.sql');
313
- optional(params, 'schema', 'params', text);
313
+ filledText(params.session, 'params.session');
314
+ filledText(params.sql, 'params.sql');
315
+ optional(params, 'schema', 'params', filledText);
314
316
  integer(params.offset, 'params.offset', 0, Number.MAX_SAFE_INTEGER);
315
317
  integer(params.limit, 'params.limit', 1, MAX_LIMIT);
316
318
  cellLimit(params);
317
319
  },
318
320
  transaction: (input) => {
319
321
  const params = shape(input, 'params', ['session', 'action']);
320
- text(params.session, 'params.session');
322
+ filledText(params.session, 'params.session');
321
323
  oneOf(TRANSACTION_ACTIONS)(params.action, 'params.action');
322
324
  },
323
325
  export: (input) => {
324
326
  const params = shape(input, 'params', ['session', 'source', 'format', 'path'], ['header', 'tableName']);
325
- text(params.session, 'params.session');
327
+ filledText(params.session, 'params.session');
326
328
  exportSource(params.source, 'params.source');
327
329
  oneOf(EXPORT_FORMATS)(params.format, 'params.format');
328
330
  absolutePath(params.path, 'params.path');
329
331
  optional(params, 'header', 'params', flag);
330
- optional(params, 'tableName', 'params', text);
332
+ optional(params, 'tableName', 'params', filledText);
331
333
  },
332
334
  sample: (input) => {
333
335
  const params = shape(input, 'params', ['path', 'format', 'header'], ['limit']);
@@ -350,19 +352,19 @@ const PARAMS: { readonly [M in DatabaseMethod]: (input: unknown) => void } = {
350
352
 
351
353
  params.columns.forEach((column, i) => {
352
354
  if (column !== null) {
353
- text(column, `params.columns[${i}]`);
355
+ filledText(column, `params.columns[${i}]`);
354
356
  }
355
357
  });
356
358
  },
357
359
  discover: (input) => {
358
360
  const params = shape(input, 'params', ['kind'], ['context']);
359
361
  oneOf(['docker'])(params.kind, 'params.kind');
360
- optional(params, 'context', 'params', text);
362
+ optional(params, 'context', 'params', filledText);
361
363
  },
362
364
  cancel: (input) => {
363
365
  const params = shape(input, 'params', ['request']);
364
366
 
365
- if (typeof params.request !== 'string' || params.request.length === 0 || params.request.length > MAX_ID_LENGTH) {
367
+ if (!isRequestId(params.request)) {
366
368
  fail(`"params.request" must be a string of 1 to ${MAX_ID_LENGTH} characters.`);
367
369
  }
368
370
  }
@@ -370,7 +372,7 @@ const PARAMS: { readonly [M in DatabaseMethod]: (input: unknown) => void } = {
370
372
 
371
373
  /* Checks the whole request before anything reaches the helper, which then never has to defend itself against a page. */
372
374
  export const parseRequest = (input: unknown): ParsedRequest => {
373
- const id = isDict(input) && typeof input.id === 'string' && input.id.length > 0 && input.id.length <= MAX_ID_LENGTH ? input.id : '';
375
+ const id = isDict(input) && isRequestId(input.id) ? input.id : '';
374
376
 
375
377
  try {
376
378
  const envelope = shape(input, 'request', ['id', 'method', 'params']);
@@ -6,7 +6,6 @@ import type { Cell, EditValue, Value } from './values.ts';
6
6
  /* Raised whenever a message changes shape, so a host refuses a helper from another release. */
7
7
  export const PROTOCOL_VERSION = 3;
8
8
 
9
- /* The columns of the row key and their values. */
10
9
  export type RowKey = Readonly<Record<string, Value>>;
11
10
 
12
11
  export type RowChange =
package/src/sql.ts CHANGED
@@ -18,7 +18,12 @@ export interface SqlTarget {
18
18
  export type LiteralValue = Cell | Value | { readonly kind: 'default' };
19
19
 
20
20
  const NUMERIC_KINDS: ReadonlySet<ValueKind> = new Set(['integer', 'decimal', 'float']);
21
- const NUMERIC_TEXT = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/;
21
+
22
+ /* A number as SQL and most people write it: a sign, digits with an optional fraction, and an exponent. */
23
+ export const NUMERIC_TEXT = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/;
24
+
25
+ /* Whether a value of this kind is a number, which a grid aligns against the end of its cell. */
26
+ export const isNumericKind = (kind: ValueKind): boolean => NUMERIC_KINDS.has(kind);
22
27
  const ORDER_ITEM = /\s*(`(?:[^`]|``)+`|"(?:[^"]|"")+"|[A-Za-z_][\w$]*)(?:\s+(asc|desc))?\s*(,|$)/iy;
23
28
 
24
29
  const markOf = (engine: Engine): string => (engine === 'mysql' ? '`' : '"');
@@ -54,7 +59,7 @@ export const sqlLiteral = (engine: Engine, value: LiteralValue, kind?: ValueKind
54
59
  return Number.isFinite(value) ? String(value) : 'NULL';
55
60
  case 'string': {
56
61
  const trimmed = value.trim();
57
- return kind !== undefined && NUMERIC_KINDS.has(kind) && NUMERIC_TEXT.test(trimmed) ? trimmed : stringLiteral(engine, value);
62
+ return kind !== undefined && isNumericKind(kind) && NUMERIC_TEXT.test(trimmed) ? trimmed : stringLiteral(engine, value);
58
63
  }
59
64
  }
60
65
  switch (value.kind) {
@@ -36,7 +36,6 @@ interface Answer {
36
36
 
37
37
  const TH = 'sticky top-0 h-7 bg-surface px-3 text-left text-xs font-medium whitespace-nowrap text-text-faint';
38
38
  const TD = 'h-7 border-t border-border-soft px-3 text-xs whitespace-nowrap text-text select-text';
39
- const MONO = CODE_TEXT;
40
39
 
41
40
  /* The columns, indexes, foreign keys and DDL of one table or view. */
42
41
  export function StructureView({ connection, schema, table, toolbarStart, className, ref }: StructureViewProps) {
@@ -44,7 +43,7 @@ export function StructureView({ connection, schema, table, toolbarStart, classNa
44
43
  const client = useDatabaseClient();
45
44
  const [answer, setAnswer] = useState<Answer | null>(null);
46
45
  const [attempt, setAttempt] = useState(0);
47
- // A schema change reloads without leaving the structure on screen: the answer keeps its place until the new one arrives.
46
+ // A schema change reloads in place: the old answer stays on screen until the new one arrives.
48
47
  const [reloads, setReloads] = useState(0);
49
48
  const answered = answer !== null && answer.connection === connection && answer.schema === schema && answer.table === table && answer.attempt === attempt;
50
49
  const load: Load = answered ? answer.load : { status: 'loading' };
@@ -194,11 +193,11 @@ function ColumnRow({ column, primary, foreign }: { column: ColumnInfo; primary:
194
193
  {column.name}
195
194
  </span>
196
195
  </td>
197
- <td className={clsx(TD, MONO)}>{column.type}</td>
196
+ <td className={clsx(TD, CODE_TEXT)}>{column.type}</td>
198
197
  <td className={clsx(TD, 'text-text-muted')}>
199
198
  <YesNo value={column.nullable} />
200
199
  </td>
201
- <td className={clsx(TD, MONO)}>{column.defaultValue}</td>
200
+ <td className={clsx(TD, CODE_TEXT)}>{column.defaultValue}</td>
202
201
  <td className={TD}>
203
202
  <span className="flex gap-1">
204
203
  {column.autoIncrement && <Pill>{t('structure.autoIncrement')}</Pill>}
@@ -220,7 +219,7 @@ function IndexesTable({ indexes }: { indexes: readonly IndexInfo[] }) {
220
219
  {indexes.map((index) => (
221
220
  <tr key={index.name}>
222
221
  <td className={TD}>{index.name}</td>
223
- <td className={clsx(TD, MONO)}>{index.columns.join(', ')}</td>
222
+ <td className={clsx(TD, CODE_TEXT)}>{index.columns.join(', ')}</td>
224
223
  <td className={TD}>
225
224
  <span className="flex gap-1">
226
225
  {index.primary && <Pill tone="accent">{t('structure.primary')}</Pill>}
@@ -252,8 +251,8 @@ function ForeignKeysTable({ foreignKeys }: { foreignKeys: readonly ForeignKeyInf
252
251
  {foreignKeys.map((key, i) => (
253
252
  <tr key={key.name ?? i}>
254
253
  <td className={TD}>{key.name}</td>
255
- <td className={clsx(TD, MONO)}>{key.columns.join(', ')}</td>
256
- <td className={clsx(TD, MONO)}>{referenceOf(key)}</td>
254
+ <td className={clsx(TD, CODE_TEXT)}>{key.columns.join(', ')}</td>
255
+ <td className={clsx(TD, CODE_TEXT)}>{referenceOf(key)}</td>
257
256
  <td className={clsx(TD, 'text-text-muted')}>{action(key.onUpdate)}</td>
258
257
  <td className={clsx(TD, 'text-text-muted')}>{action(key.onDelete)}</td>
259
258
  </tr>
@@ -273,7 +272,7 @@ function Ddl({ ddl }: { ddl: string | null }) {
273
272
  <Icon icon={Copy} size={12} />
274
273
  {t('structure.copy')}
275
274
  </Button>
276
- <pre className={clsx(MONO, 'overflow-auto p-3 pr-24 whitespace-pre text-text select-text')}>{ddl}</pre>
275
+ <pre className={clsx(CODE_TEXT, 'overflow-auto p-3 pr-24 whitespace-pre text-text select-text')}>{ddl}</pre>
277
276
  </div>
278
277
  );
279
278
  }
@@ -25,7 +25,6 @@ export interface CommandFieldProps {
25
25
  engine: Engine;
26
26
  /* Every change of the chips applies at once, so this is told the whole list each time. */
27
27
  onChipsChange(next: Chip[]): void;
28
- /* "Jump to column" was picked. */
29
28
  onJumpToColumn(name: string): void;
30
29
  /* The text input, for the owner to focus on Mod+F. */
31
30
  inputRef?: Ref<HTMLInputElement>;
@@ -27,7 +27,7 @@ import type { GridSort } from '../grid/sort.ts';
27
27
  import type { ColumnRequest, FocusedCell, FocusRequest, GridMenuContext } from '../grid/types.ts';
28
28
  import { RecordView } from '../grid/RecordView.tsx';
29
29
  import { ValueDock } from '../grid/ValueDock.tsx';
30
- import { valueOfCell, type EditValue, type RowChange, type Value } from '../protocol/index.ts';
30
+ import { valueOfCell, type EditValue, type RowChange, type TableStructure, type Value } from '../protocol/index.ts';
31
31
  import type { SqlTarget } from '../sql.ts';
32
32
  import { useSchemaChange } from '../use-schema-change.ts';
33
33
  import { useStableCallback } from '../use-stable-callback.ts';
@@ -109,6 +109,19 @@ interface Filters {
109
109
 
110
110
  const NO_KEYS: ReadonlySet<string> = new Set();
111
111
 
112
+ const readOnlyReasonOf = (connection: Connection, structure: TableStructure | null, t: TFunction<'database'>): string | null => {
113
+ if (connection.config.readOnly === true) {
114
+ return t('table.readOnly.connection');
115
+ }
116
+ if (structure?.kind === 'view') {
117
+ return t('table.readOnly.view');
118
+ }
119
+ if (structure !== null && structure.rowKey === null) {
120
+ return t('table.readOnly.noKey');
121
+ }
122
+ return null;
123
+ };
124
+
112
125
  /* What the strip over the table, or the app's notice, says of an export or an import. */
113
126
  const transferText = (found: TransferNotice, t: TFunction<'database'>): string => {
114
127
  switch (found.kind) {
@@ -123,7 +136,6 @@ const transferText = (found: TransferNotice, t: TFunction<'database'>): string =
123
136
  }
124
137
  };
125
138
 
126
- /* How much of a value a menu item spells out before it cuts it. */
127
139
  const MENU_VALUE_LENGTH = 40;
128
140
 
129
141
  const FOCUS_COMMAND = shortcut('Mod+F');
@@ -210,16 +222,7 @@ function TableBody({ connection, schema, table, toolbarStart, defaultWhere, defa
210
222
  const loaded = rowsLoad.value?.result ?? null;
211
223
  const loadedOffset = rowsLoad.value?.offset ?? 0;
212
224
 
213
- const readOnlyReason =
214
- connection.config.readOnly === true
215
- ? t('table.readOnly.connection')
216
- : structure === null
217
- ? null
218
- : structure.kind === 'view'
219
- ? t('table.readOnly.view')
220
- : structure.rowKey === null
221
- ? t('table.readOnly.noKey')
222
- : null;
225
+ const readOnlyReason = readOnlyReasonOf(connection, structure, t);
223
226
  const editable = readOnlyReason === null && structure !== null && loaded !== null && !rowsLoad.loading;
224
227
 
225
228
  const columns = useMemo(() => (loaded === null ? [] : buildGridColumns(loaded, structure)), [loaded, structure]);
@@ -342,7 +345,7 @@ function TableBody({ connection, schema, table, toolbarStart, defaultWhere, defa
342
345
  }
343
346
  };
344
347
 
345
- /* Every change of the chips applies at once; one that changes the page asks first when it would throw pending changes away. */
348
+ /* A change that leaves the SQL alone only remembers the filters and keeps the page. */
346
349
  const applyChips = (next: readonly Chip[]): void => {
347
350
  const where = chipsToWhere(next);
348
351
  const orderBy = chipsToOrderBy(engine, next);
@@ -713,27 +716,7 @@ function TableBody({ connection, schema, table, toolbarStart, defaultWhere, defa
713
716
  </Button>
714
717
  </Banner>
715
718
  )}
716
- {transfer.notice !== null && (
717
- <Banner
718
- icon={transfer.notice.kind === 'failed' ? CircleAlert : transfer.notice.kind === 'exporting' ? Download : Check}
719
- tone={transfer.notice.kind === 'failed' ? 'error' : 'neutral'}
720
- message={transferText(transfer.notice, t)}
721
- className="shrink-0 pt-2"
722
- >
723
- {transfer.notice.kind === 'exporting' ? (
724
- <>
725
- <Spinner size={12} label={t('table.export.running', { format: transfer.notice.format.toUpperCase() })} />
726
- <Button size="xs" onClick={transfer.cancelExport}>
727
- {t('table.export.cancel')}
728
- </Button>
729
- </>
730
- ) : (
731
- <Button size="xs" onClick={transfer.dismiss}>
732
- {t('table.dismiss')}
733
- </Button>
734
- )}
735
- </Banner>
736
- )}
719
+ {transfer.notice !== null && <TransferBanner notice={transfer.notice} onCancel={transfer.cancelExport} onDismiss={transfer.dismiss} />}
737
720
  {loaded === null ? (
738
721
  <PanelEmpty busy={notice === null}>{notice === null ? t('table.loading') : t('table.notLoaded')}</PanelEmpty>
739
722
  ) : (
@@ -841,3 +824,34 @@ function TableBody({ connection, schema, table, toolbarStart, defaultWhere, defa
841
824
  </div>
842
825
  );
843
826
  }
827
+
828
+ interface TransferBannerProps {
829
+ notice: TransferNotice;
830
+ onCancel(): void;
831
+ onDismiss(): void;
832
+ }
833
+
834
+ function TransferBanner({ notice, onCancel, onDismiss }: TransferBannerProps) {
835
+ const { t } = useTranslation('database');
836
+ return (
837
+ <Banner
838
+ icon={notice.kind === 'failed' ? CircleAlert : notice.kind === 'exporting' ? Download : Check}
839
+ tone={notice.kind === 'failed' ? 'error' : 'neutral'}
840
+ message={transferText(notice, t)}
841
+ className="shrink-0 pt-2"
842
+ >
843
+ {notice.kind === 'exporting' ? (
844
+ <>
845
+ <Spinner size={12} label={t('table.export.running', { format: notice.format.toUpperCase() })} />
846
+ <Button size="xs" onClick={onCancel}>
847
+ {t('table.export.cancel')}
848
+ </Button>
849
+ </>
850
+ ) : (
851
+ <Button size="xs" onClick={onDismiss}>
852
+ {t('table.dismiss')}
853
+ </Button>
854
+ )}
855
+ </Banner>
856
+ );
857
+ }
@@ -65,6 +65,8 @@ const isFilter = (chip: Chip): chip is FilterChip => chip.kind === 'filter';
65
65
 
66
66
  const isSort = (chip: Chip): chip is SortChip => chip.kind === 'sort';
67
67
 
68
+ const sortChip = (sort: ColumnSort): SortChip => ({ kind: 'sort', column: sort.column, direction: sort.direction });
69
+
68
70
  /* A name as typed in SQL: bare when it can be, else in double quotes. The quotes are only for the text of a chip; `sql` quotes per engine. */
69
71
  export const identifierText = (name: string): string => (BARE_NAME.test(name) ? name : `"${name.replaceAll('"', '""')}"`);
70
72
 
@@ -95,10 +97,7 @@ export const chipsToOrderBy = (engine: Engine, chips: readonly Chip[]): string =
95
97
  export const sortsOf = (chips: readonly Chip[]): ColumnSort[] => chips.filter(isSort).map(({ column, direction }) => ({ column, direction }));
96
98
 
97
99
  /* The sorts of a header menu replace every sort and raw ORDER BY, keeping the filters where they are. */
98
- export const withSorts = (chips: readonly Chip[], sorts: readonly ColumnSort[]): Chip[] => [
99
- ...chips.filter(isFilter),
100
- ...sorts.map((sort): Chip => ({ kind: 'sort', column: sort.column, direction: sort.direction }))
101
- ];
100
+ export const withSorts = (chips: readonly Chip[], sorts: readonly ColumnSort[]): Chip[] => [...chips.filter(isFilter), ...sorts.map(sortChip)];
102
101
 
103
102
  /* A column that is not sorted joins as ascending; one that is flips its direction where it stands. */
104
103
  export const toggleSort = (chips: readonly Chip[], column: string): Chip[] => {
@@ -139,7 +138,7 @@ export const sortChipsOf = (engine: Engine, orderBy: string): Chip[] => {
139
138
  return [];
140
139
  }
141
140
  const sorts = parseOrderByLoose(engine, text);
142
- return sorts === null ? [{ kind: 'order', text }] : sorts.map((sort): Chip => ({ kind: 'sort', column: sort.column, direction: sort.direction }));
141
+ return sorts === null ? [{ kind: 'order', text }] : sorts.map(sortChip);
143
142
  };
144
143
 
145
144
  export interface ChipSource {
@@ -183,13 +182,11 @@ const readOperation = (rest: string): string | null => {
183
182
  const operator = compare[1] === '==' ? '=' : compare[1] === '!=' ? '<>' : compare[1];
184
183
  return isClosed(compare[2]!) ? `${operator} ${compare[2]}` : null;
185
184
  }
186
- const pattern = PATTERN.exec(rest);
187
- if (pattern !== null) {
188
- return isClosed(pattern[2]!) ? `${upper(pattern[1]!)} ${pattern[2]}` : null;
189
- }
190
- const member = MEMBER.exec(rest);
191
- if (member !== null) {
192
- return isClosed(member[2]!) ? `${upper(member[1]!)} ${member[2]}` : null;
185
+ for (const form of [PATTERN, MEMBER]) {
186
+ const found = form.exec(rest);
187
+ if (found !== null) {
188
+ return isClosed(found[2]!) ? `${upper(found[1]!)} ${found[2]}` : null;
189
+ }
193
190
  }
194
191
  const between = BETWEEN.exec(rest);
195
192
  if (between !== null) {