@adecore/database 0.21.0 → 0.21.2

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 -6
  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
@@ -0,0 +1,2 @@
1
+ import type { Ref } from 'react';
2
+ export declare function assignRef<T>(ref: Ref<T> | undefined, node: T | null): void;
@@ -0,0 +1,9 @@
1
+ /* Hands a node to a ref of either kind, for an element that keeps a ref of its own beside the one it was given. */
2
+ export function assignRef(ref, node) {
3
+ if (typeof ref === 'function') {
4
+ ref(node);
5
+ }
6
+ else if (ref) {
7
+ ref.current = node;
8
+ }
9
+ }
@@ -2,8 +2,8 @@ import { DatabaseRequestError } from './types.js';
2
2
  const cancelledError = () => new DatabaseRequestError('cancelled', 'The request was cancelled.');
3
3
  const isUnknownSession = (error) => error instanceof DatabaseRequestError && error.code === 'unknown-session';
4
4
  const LEADING_COMMENTS = /^(?:\s+|--[^\n]*(?:\n|$)|#[^\n]*(?:\n|$)|\/\*[\s\S]*?\*\/)+/;
5
- const CHANGES_SHAPE = /^(?:create|alter|drop|rename|truncate)\b/i;
6
- const changesShape = (sql) => CHANGES_SHAPE.test(sql.replace(LEADING_COMMENTS, ''));
5
+ const SCHEMA_STATEMENT = /^(?:create|alter|drop|rename|truncate)\b/i;
6
+ const changesSchema = (sql) => SCHEMA_STATEMENT.test(sql.replace(LEADING_COMMENTS, ''));
7
7
  /* Structural equality for the JSON a config is made of; a key set to `undefined` counts as absent. */
8
8
  const isEqual = (left, right) => {
9
9
  if (left === right) {
@@ -36,6 +36,14 @@ export const createDatabaseClient = (transport, options = {}) => {
36
36
  const createId = options.createId ?? (() => crypto.randomUUID());
37
37
  const live = new Map();
38
38
  const schemaListeners = new Set();
39
+ const channelsOf = (connectionId) => {
40
+ let channels = live.get(connectionId);
41
+ if (channels === undefined) {
42
+ channels = new Map();
43
+ live.set(connectionId, channels);
44
+ }
45
+ return channels;
46
+ };
39
47
  const notifySchemaChange = (change) => {
40
48
  for (const listener of [...schemaListeners]) {
41
49
  try {
@@ -79,11 +87,7 @@ export const createDatabaseClient = (transport, options = {}) => {
79
87
  let opening = null;
80
88
  /* A session that was closed and is used again is findable again, so `disconnect` and `dispose` reach it. */
81
89
  const track = () => {
82
- let channels = live.get(connection.id);
83
- if (channels === undefined) {
84
- channels = new Map();
85
- live.set(connection.id, channels);
86
- }
90
+ const channels = channelsOf(connection.id);
87
91
  if (!channels.has(channel)) {
88
92
  channels.set(channel, created);
89
93
  }
@@ -169,7 +173,7 @@ export const createDatabaseClient = (transport, options = {}) => {
169
173
  async execute(sql, executeOptions) {
170
174
  const { signal: _signal, ...rest } = executeOptions ?? {};
171
175
  const result = await request('execute', (id) => ({ session: id, sql, ...rest }), executeOptions, false);
172
- if (result.results.some((item) => item.kind !== 'error' && changesShape(item.sql))) {
176
+ if (result.results.some((item) => item.kind !== 'error' && changesSchema(item.sql))) {
173
177
  notifySchemaChange({ connectionId: connection.id, ...(rest.schema === undefined ? {} : { schema: rest.schema }) });
174
178
  }
175
179
  return result;
@@ -216,19 +220,14 @@ export const createDatabaseClient = (transport, options = {}) => {
216
220
  };
217
221
  },
218
222
  session(connection, channel = '') {
219
- let channels = live.get(connection.id);
220
- const existing = channels?.get(channel);
223
+ const existing = live.get(connection.id)?.get(channel);
221
224
  if (existing !== undefined && isEqual(existing.session.connection.config, connection.config)) {
222
225
  existing.update(connection);
223
226
  return existing.session;
224
227
  }
225
228
  void existing?.close();
226
229
  const created = createSession(connection, channel);
227
- if (channels === undefined) {
228
- channels = new Map();
229
- live.set(connection.id, channels);
230
- }
231
- channels.set(channel, created);
230
+ channelsOf(connection.id).set(channel, created);
232
231
  return created.session;
233
232
  },
234
233
  async disconnect(connectionId) {
@@ -95,3 +95,4 @@ export declare class DatabaseRequestError extends Error {
95
95
  readonly change: number | undefined;
96
96
  constructor(code: DatabaseErrorCode, message: string, sqlState?: string, change?: number);
97
97
  }
98
+ export declare const isCancelled: (error: unknown) => boolean;
@@ -11,3 +11,5 @@ export class DatabaseRequestError extends Error {
11
11
  this.change = change;
12
12
  }
13
13
  }
14
+ /* Whether a request failed because it was cancelled, which is no failure to show. */
15
+ export const isCancelled = (error) => error instanceof DatabaseRequestError && error.code === 'cancelled';
@@ -51,7 +51,12 @@ function SqliteFields({ config, onConfigChange, touched, touch, onBrowse }) {
51
51
  });
52
52
  touch('path');
53
53
  };
54
- return (_jsxs(_Fragment, { children: [_jsx(Field, { label: t('connections.form.path'), hint: t('connections.form.pathHint'), error: error, orientation: "horizontal", group: onBrowse !== undefined, children: _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Input, { mono: true, value: config.path, placeholder: "/path/to/database.sqlite", spellCheck: false, "aria-label": onBrowse === undefined ? undefined : t('connections.form.path'), onBlur: () => touch('path'), onChange: (e) => onConfigChange({ ...config, path: e.target.value }) }), onBrowse !== undefined && (_jsx(Button, { variant: "secondary", disabled: browsing.busy, onClick: () => void browse(), children: t('connections.form.browse') }))] }) }), _jsx(Field, { label: t('connections.form.create'), hint: t('connections.form.createHint'), orientation: "horizontal", group: true, children: _jsx(Switch, { checked: config.create === true, label: t('connections.form.create'), onCheckedChange: (create) => onConfigChange({ ...config, create }) }) })] }));
54
+ return (_jsxs(_Fragment, { children: [_jsx(Field, { label: t('connections.form.path'), hint: t('connections.form.pathHint'), error: error, orientation: "horizontal", group: onBrowse !== undefined, children: _jsx(PathInput, { value: config.path, placeholder: "/path/to/database.sqlite", label: t('connections.form.path'), busy: browsing.busy, onBlur: () => touch('path'), onValueChange: (path) => onConfigChange({ ...config, path }), onBrowse: onBrowse === undefined ? undefined : () => void browse() }) }), _jsx(Field, { label: t('connections.form.create'), hint: t('connections.form.createHint'), orientation: "horizontal", group: true, children: _jsx(Switch, { checked: config.create === true, label: t('connections.form.create'), onCheckedChange: (create) => onConfigChange({ ...config, create }) }) })] }));
55
+ }
56
+ /* A path typed by hand, with a Browse button beside it when the app has a file dialog. */
57
+ function PathInput({ value, placeholder, label, busy, onBlur, onValueChange, onBrowse }) {
58
+ const { t } = useTranslation('database');
59
+ return (_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Input, { mono: true, value: value, placeholder: placeholder, spellCheck: false, "aria-label": onBrowse === undefined ? undefined : label, onBlur: onBlur, onChange: (e) => onValueChange(e.target.value) }), onBrowse !== undefined && (_jsx(Button, { variant: "secondary", disabled: busy, onClick: onBrowse, children: t('connections.form.browse') }))] }));
55
60
  }
56
61
  function PortField({ label, hint, error, value, placeholder, onBlur, onValueChange }) {
57
62
  return (_jsx(Field, { label: label, hint: hint, error: error, orientation: "horizontal", children: _jsx(Input, { type: "number", min: 1, max: 65535, className: "max-w-32", value: value === undefined ? '' : String(value), placeholder: String(placeholder), onBlur: onBlur, onChange: (e) => onValueChange(e.target.value === '' ? undefined : Number(e.target.value)) }) }));
@@ -97,7 +102,7 @@ function SshFields({ config, tunnel, onConfigChange, touched, touch, onBrowse })
97
102
  }
98
103
  });
99
104
  };
100
- return (_jsxs(_Fragment, { children: [_jsx(Field, { label: t('connections.form.ssh.host'), hint: t('connections.form.ssh.hostHint'), error: hostError, orientation: "horizontal", children: _jsx(Input, { value: tunnel.host, placeholder: "bastion.example.com", spellCheck: false, onBlur: () => touch('sshHost'), onChange: (e) => update({ host: e.target.value }) }) }), _jsx(PortField, { label: t('connections.form.ssh.port'), error: portError, value: tunnel.port, placeholder: 22, onBlur: () => touch('sshPort'), onValueChange: (port) => update({ port }) }), _jsx(Field, { label: t('connections.form.ssh.user'), orientation: "horizontal", children: _jsx(Input, { value: tunnel.user ?? '', autoComplete: "off", spellCheck: false, onChange: (e) => update({ user: optionalText(e.target.value) }) }) }), _jsx(Field, { label: t('connections.form.ssh.identityFile'), hint: t('connections.form.ssh.keyHint'), error: browsing.failure ?? undefined, orientation: "horizontal", group: onBrowse !== undefined, children: _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Input, { mono: true, value: tunnel.identityFile ?? '', placeholder: "~/.ssh/id_ed25519", spellCheck: false, "aria-label": onBrowse === undefined ? undefined : t('connections.form.ssh.identityFile'), onChange: (e) => update({ identityFile: optionalText(e.target.value) }) }), onBrowse !== undefined && (_jsx(Button, { variant: "secondary", disabled: browsing.busy, onClick: () => void browse(), children: t('connections.form.browse') }))] }) }), _jsx(ServerFields, { config: config, onConfigChange: onConfigChange, touched: touched, touch: touch })] }));
105
+ return (_jsxs(_Fragment, { children: [_jsx(Field, { label: t('connections.form.ssh.host'), hint: t('connections.form.ssh.hostHint'), error: hostError, orientation: "horizontal", children: _jsx(Input, { value: tunnel.host, placeholder: "bastion.example.com", spellCheck: false, onBlur: () => touch('sshHost'), onChange: (e) => update({ host: e.target.value }) }) }), _jsx(PortField, { label: t('connections.form.ssh.port'), error: portError, value: tunnel.port, placeholder: 22, onBlur: () => touch('sshPort'), onValueChange: (port) => update({ port }) }), _jsx(Field, { label: t('connections.form.ssh.user'), orientation: "horizontal", children: _jsx(Input, { value: tunnel.user ?? '', autoComplete: "off", spellCheck: false, onChange: (e) => update({ user: optionalText(e.target.value) }) }) }), _jsx(Field, { label: t('connections.form.ssh.identityFile'), hint: t('connections.form.ssh.keyHint'), error: browsing.failure ?? undefined, orientation: "horizontal", group: onBrowse !== undefined, children: _jsx(PathInput, { value: tunnel.identityFile ?? '', placeholder: "~/.ssh/id_ed25519", label: t('connections.form.ssh.identityFile'), busy: browsing.busy, onValueChange: (identityFile) => update({ identityFile: optionalText(identityFile) }), onBrowse: onBrowse === undefined ? undefined : () => void browse() }) }), _jsx(ServerFields, { config: config, onConfigChange: onConfigChange, touched: touched, touch: touch })] }));
101
106
  }
102
107
  /* What a container offers a person in a list: its image, its Compose name and the port it publishes. */
103
108
  function containerDescription(container, published) {
@@ -37,15 +37,15 @@ export function ConnectionManager({ value, onValueChange, selected, onSelectedCh
37
37
  }
38
38
  onSelectedChange?.(id);
39
39
  };
40
- const add = (engine) => {
41
- const connection = { id: crypto.randomUUID(), name: t('connections.newName'), config: defaultConfig(engine) };
40
+ const append = (connection) => {
42
41
  onValueChange([...value, connection]);
43
42
  select(connection.id);
44
43
  };
44
+ const add = (engine) => {
45
+ append({ id: crypto.randomUUID(), name: t('connections.newName'), config: defaultConfig(engine) });
46
+ };
45
47
  const addFromContainer = (container) => {
46
- const connection = connectionFromContainer(crypto.randomUUID(), container);
47
- onValueChange([...value, connection]);
48
- select(connection.id);
48
+ append(connectionFromContainer(crypto.randomUUID(), container));
49
49
  };
50
50
  const change = (next) => {
51
51
  onValueChange(value.map((connection) => (connection.id === next.id ? next : connection)));
@@ -1,7 +1,6 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Icon } from '@adecore/ui';
3
3
  import { ENGINE_ICONS } from './engine-icons.js';
4
- /* A file on disk for SQLite, a server for MySQL and MariaDB. */
5
4
  export function EngineIcon({ engine, size = 16, className, ref }) {
6
5
  return _jsx(Icon, { ref: ref, icon: ENGINE_ICONS[engine], size: size, className: className });
7
6
  }
@@ -5,7 +5,7 @@ import { Check, CircleAlert, History, Play, Square, X } from 'lucide-react';
5
5
  import { useTranslation } from 'react-i18next';
6
6
  import { Banner, Button, ColumnResizeHandle, Icon, IconButton, messageOf, PromptDialog, Spinner, Tabs, Tooltip, useColumnResize } from '@adecore/ui';
7
7
  import { formatNumber } from '@adecore/ui/format';
8
- import { DatabaseRequestError } from '../client/types.js';
8
+ import { isCancelled } from '../client/types.js';
9
9
  import { useDatabaseAction, useDatabaseClient, useDatabaseFiles, useDatabaseNotice, useDatabaseStorage } from '../client-context.js';
10
10
  import { splitStatements, statementAt } from '../sql-split.js';
11
11
  import { DestructiveDialog } from './DestructiveDialog.js';
@@ -41,6 +41,14 @@ const parseHeight = (raw) => {
41
41
  function AppEditor({ render, ...editor }) {
42
42
  return render(editor);
43
43
  }
44
+ /* Run and Run all, or Cancel while a run is under way. */
45
+ function RunButtons({ running, disabled, onCancel, onRun }) {
46
+ const { t } = useTranslation('database');
47
+ if (running) {
48
+ return (_jsxs(_Fragment, { children: [_jsxs(Button, { size: "sm", onClick: onCancel, children: [_jsx(Icon, { icon: Square, size: 12 }), t('console.cancel')] }), _jsx(Spinner, { size: 14, label: t('console.running') })] }));
49
+ }
50
+ return (_jsxs(_Fragment, { children: [_jsx(Tooltip, { label: t('console.run'), kbd: RUN_SHORTCUT, children: _jsxs(Button, { variant: "primary", size: "sm", disabled: disabled, onClick: () => onRun('selection-or-statement'), children: [_jsx(Icon, { icon: Play, size: 12 }), t('console.run')] }) }), _jsx(Tooltip, { label: t('console.runAll'), kbd: RUN_ALL_SHORTCUT, children: _jsx(Button, { variant: "secondary", size: "sm", disabled: disabled, onClick: () => onRun('all'), children: t('console.runAll') }) })] }));
51
+ }
44
52
  /* Type SQL, run it, and read what each statement did. Several statements give a tab each. */
45
53
  export function QueryConsole({ connection, schema, onSchemaChange, value, defaultValue = '', onValueChange, defaultHistoryOpen = false, autoFocus = false, renderEditor, toolbarEnd, className, ref }) {
46
54
  const { t } = useTranslation('database');
@@ -80,7 +88,7 @@ export function QueryConsole({ connection, schema, onSchemaChange, value, defaul
80
88
  const [exporting, setExporting] = useState(false);
81
89
  // What the console shows itself when the app takes no notices.
82
90
  const [notice, setNotice] = useState(null);
83
- /* Closed until the first run, so the editor has the whole height before anything ran. */
91
+ // Closed until the first run, so the editor has the whole height before anything ran.
84
92
  const [resultsOpen, setResultsOpen] = useState(false);
85
93
  const [resultsHeight, setResultsHeight] = useState(() => parseHeight(storage?.get(RESULTS_HEIGHT_KEY)));
86
94
  const { startResize } = useColumnResize(resultsPane, {
@@ -183,7 +191,7 @@ export function QueryConsole({ connection, schema, onSchemaChange, value, defaul
183
191
  record(outcomeOf(executed.results));
184
192
  }
185
193
  catch (error) {
186
- if (controller.signal.aborted || (error instanceof DatabaseRequestError && error.code === 'cancelled')) {
194
+ if (controller.signal.aborted || isCancelled(error)) {
187
195
  setRun({ status: 'cancelled' });
188
196
  }
189
197
  else {
@@ -304,7 +312,7 @@ export function QueryConsole({ connection, schema, onSchemaChange, value, defaul
304
312
  placeholder: t('console.placeholder'),
305
313
  autoFocus
306
314
  };
307
- return (_jsxs("div", { ref: ref, className: clsx('flex min-h-0 flex-col bg-surface text-text', className), children: [_jsxs("div", { className: "flex min-h-0 flex-1", children: [_jsxs("div", { className: "flex min-w-0 flex-1 flex-col", children: [_jsxs("div", { className: "flex shrink-0 flex-wrap items-center gap-2 border-b border-border px-3 py-2", children: [session !== null && (_jsxs(_Fragment, { children: [run.status === 'running' ? (_jsxs(_Fragment, { children: [_jsxs(Button, { size: "sm", onClick: () => running.current?.abort(), children: [_jsx(Icon, { icon: Square, size: 12 }), t('console.cancel')] }), _jsx(Spinner, { size: 14, label: t('console.running') })] })) : (_jsxs(_Fragment, { children: [_jsx(Tooltip, { label: t('console.run'), kbd: RUN_SHORTCUT, children: _jsxs(Button, { variant: "primary", size: "sm", disabled: blank || busy, onClick: () => requestRun(targetOf('selection-or-statement')), children: [_jsx(Icon, { icon: Play, size: 12 }), t('console.run')] }) }), _jsx(Tooltip, { label: t('console.runAll'), kbd: RUN_ALL_SHORTCUT, children: _jsx(Button, { variant: "secondary", size: "sm", disabled: blank || busy, onClick: () => requestRun(targetOf('all')), children: t('console.runAll') }) })] })), _jsx(TransactionControls, { mode: mode, onModeChange: changeMode, open: inTransaction, busy: busy, onCommit: () => void settle('commit'), onRollback: () => void settle('rollback') })] })), _jsxs("div", { className: "ml-auto flex items-center gap-2", children: [toolbarEnd, session !== null && schemas.length > 0 && (_jsx(SchemaPicker, { schemas: schemas, value: activeSchema ?? null, disabled: busy, onValueChange: onSchemaChange ?? setPickedSchema })), session !== null && (_jsxs(Button, { variant: "secondary", size: "sm", "aria-pressed": historyOpen, className: "aria-pressed:bg-surface-active aria-pressed:text-text", onClick: () => setHistoryOpen(!historyOpen), children: [_jsx(Icon, { icon: History, size: 12 }), t('console.history.title')] }))] })] }), _jsx("div", { className: clsx('flex min-h-0 flex-1 flex-col', renderEditor === undefined && 'p-3'), children: renderEditor === undefined ? _jsx(SqlEditor, { ...editorProps }) : _jsx(AppEditor, { render: renderEditor, ...editorProps }) })] }), session !== null && historyOpen && _jsx(HistoryPanel, { entries: history.entries, onPick: pickHistory, onClear: history.clear })] }), session !== null && notice !== null && (_jsx(Banner, { icon: notice.tone === 'error' ? CircleAlert : Check, tone: notice.tone === 'error' ? 'error' : 'neutral', message: notice.description ?? notice.title, className: "shrink-0 pb-2", children: _jsx(Button, { size: "xs", onClick: () => setNotice(null), children: t('console.dismiss') }) })), session !== null && resultsOpen && (_jsxs("section", { ref: resultsPane, "aria-label": t('console.results'), className: "relative flex min-h-24 shrink-0 flex-col border-t border-border", style: { height: resultsHeight ?? '50%', maxHeight: `calc(100% - ${MIN_EDITOR_HEIGHT}px)` }, children: [_jsx(ColumnResizeHandle, { from: "bottom", onPointerDown: startResize }), _jsx(Tabs.Root, { value: index, onValueChange: (next) => setTab(Number(next)), children: _jsx(Tabs.List, { "aria-label": t('console.results'), className: "shrink-0 pr-1.5 pl-3", end: _jsx(IconButton, { icon: X, size: "sm", label: t('console.closeResults'), onClick: () => setResultsOpen(false) }), children: results.map((result, position) => (_jsx(Tabs.Tab, { value: position, children: statementLabel(result.sql) }, position))) }) }), run.status === 'failed' && _jsx(Banner, { icon: CircleAlert, tone: "error", message: run.message, className: "shrink-0 pt-2" }), run.status === 'cancelled' && _jsx(Banner, { icon: CircleAlert, tone: "neutral", message: t('console.cancelled'), className: "shrink-0 pt-2" }), shown !== undefined && (_jsx(StatementResultView, { result: shown, offset: offset, pager: pager, onExport: exportBase === undefined ? undefined : (format) => void exportResult(exportBase, format), exporting: exporting, engine: engine, valuePanelOpen: panelOpen, onValuePanelOpenChange: setPanelOpen, keys: keys, onFollowReference: act === undefined || connection === undefined
315
+ return (_jsxs("div", { ref: ref, className: clsx('flex min-h-0 flex-col bg-surface text-text', className), children: [_jsxs("div", { className: "flex min-h-0 flex-1", children: [_jsxs("div", { className: "flex min-w-0 flex-1 flex-col", children: [_jsxs("div", { className: "flex shrink-0 flex-wrap items-center gap-2 border-b border-border px-3 py-2", children: [session !== null && (_jsxs(_Fragment, { children: [_jsx(RunButtons, { running: run.status === 'running', disabled: blank || busy, onCancel: () => running.current?.abort(), onRun: (scope) => requestRun(targetOf(scope)) }), _jsx(TransactionControls, { mode: mode, onModeChange: changeMode, open: inTransaction, busy: busy, onCommit: () => void settle('commit'), onRollback: () => void settle('rollback') })] })), _jsxs("div", { className: "ml-auto flex items-center gap-2", children: [toolbarEnd, session !== null && schemas.length > 0 && (_jsx(SchemaPicker, { schemas: schemas, value: activeSchema ?? null, disabled: busy, onValueChange: onSchemaChange ?? setPickedSchema })), session !== null && (_jsxs(Button, { variant: "secondary", size: "sm", "aria-pressed": historyOpen, className: "aria-pressed:bg-surface-active aria-pressed:text-text", onClick: () => setHistoryOpen(!historyOpen), children: [_jsx(Icon, { icon: History, size: 12 }), t('console.history.title')] }))] })] }), _jsx("div", { className: clsx('flex min-h-0 flex-1 flex-col', renderEditor === undefined && 'p-3'), children: renderEditor === undefined ? _jsx(SqlEditor, { ...editorProps }) : _jsx(AppEditor, { render: renderEditor, ...editorProps }) })] }), session !== null && historyOpen && _jsx(HistoryPanel, { entries: history.entries, onPick: pickHistory, onClear: history.clear })] }), session !== null && notice !== null && (_jsx(Banner, { icon: notice.tone === 'error' ? CircleAlert : Check, tone: notice.tone === 'error' ? 'error' : 'neutral', message: notice.description ?? notice.title, className: "shrink-0 pb-2", children: _jsx(Button, { size: "xs", onClick: () => setNotice(null), children: t('console.dismiss') }) })), session !== null && resultsOpen && (_jsxs("section", { ref: resultsPane, "aria-label": t('console.results'), className: "relative flex min-h-24 shrink-0 flex-col border-t border-border", style: { height: resultsHeight ?? '50%', maxHeight: `calc(100% - ${MIN_EDITOR_HEIGHT}px)` }, children: [_jsx(ColumnResizeHandle, { from: "bottom", onPointerDown: startResize }), _jsx(Tabs.Root, { value: index, onValueChange: (next) => setTab(Number(next)), children: _jsx(Tabs.List, { "aria-label": t('console.results'), className: "shrink-0 pr-1.5 pl-3", end: _jsx(IconButton, { icon: X, size: "sm", label: t('console.closeResults'), onClick: () => setResultsOpen(false) }), children: results.map((result, position) => (_jsx(Tabs.Tab, { value: position, children: statementLabel(result.sql) }, position))) }) }), run.status === 'failed' && _jsx(Banner, { icon: CircleAlert, tone: "error", message: run.message, className: "shrink-0 pt-2" }), run.status === 'cancelled' && _jsx(Banner, { icon: CircleAlert, tone: "neutral", message: t('console.cancelled'), className: "shrink-0 pt-2" }), shown !== undefined && (_jsx(StatementResultView, { result: shown, offset: offset, pager: pager, onExport: exportBase === undefined ? undefined : (format) => void exportResult(exportBase, format), exporting: exporting, engine: engine, valuePanelOpen: panelOpen, onValuePanelOpenChange: setPanelOpen, keys: keys, onFollowReference: act === undefined || connection === undefined
308
316
  ? undefined
309
317
  : (reference) => act({
310
318
  kind: 'open-table',
@@ -5,7 +5,6 @@ const lineEndOf = (value, at) => {
5
5
  const newline = value.indexOf('\n', at);
6
6
  return newline === -1 ? value.length : newline;
7
7
  };
8
- /* The whitespace a line begins with. */
9
8
  const indentOf = (line) => /^[ \t]*/.exec(line)[0];
10
9
  /* The lines a selection touches. A selection that ends at the start of a line leaves that line out. */
11
10
  const touchedLines = (value, start, end) => {
@@ -42,13 +41,12 @@ export const outdentEdit = (value, start, end) => {
42
41
  if (total === 0) {
43
42
  return null;
44
43
  }
45
- const firstLineStart = from;
46
44
  return {
47
45
  from,
48
46
  to,
49
47
  insert: lines.map((line, index) => line.slice(removed[index])).join('\n'),
50
- selectionStart: Math.max(firstLineStart, start - removed[0]),
51
- selectionEnd: Math.max(firstLineStart, end - total)
48
+ selectionStart: Math.max(from, start - removed[0]),
49
+ selectionEnd: Math.max(from, end - total)
52
50
  };
53
51
  };
54
52
  /* Enter: a new line that starts with the indentation of the line the caret is on, up to the caret. */
@@ -1,13 +1,14 @@
1
1
  import { useEffect, useState } from 'react';
2
2
  import { useDatabaseStorage } from '../client-context.js';
3
3
  import { addEntry, historyKey, parseHistory, serializeHistory } from './history.js';
4
+ const load = (storage, key) => ({ key, entries: parseHistory(storage?.get(key) ?? null), changed: false });
4
5
  /* The runs of one connection, kept in the app's storage when it has one and for the life of the console when it has not. */
5
6
  export function useConsoleHistory(connectionId) {
6
7
  const storage = useDatabaseStorage();
7
8
  const key = historyKey(connectionId);
8
- const [state, setState] = useState(() => ({ key, entries: parseHistory(storage?.get(key) ?? null), changed: false }));
9
+ const [state, setState] = useState(() => load(storage, key));
9
10
  if (state.key !== key) {
10
- setState({ key, entries: parseHistory(storage?.get(key) ?? null), changed: false });
11
+ setState(load(storage, key));
11
12
  }
12
13
  useEffect(() => {
13
14
  if (state.changed) {
@@ -1,13 +1,7 @@
1
1
  import { columnSpecSql } from './column.js';
2
- import { foreignKeySql, mysqlOptionsSql } from './create.js';
2
+ import { foreignKeySql, inlineIndexSql, mysqlOptionsSql } from './create.js';
3
3
  import { columnList, quote, tableName } from './dialect.js';
4
4
  const positionSql = (dialect, after) => (after === null ? 'FIRST' : `AFTER ${quote(dialect, after)}`);
5
- const indexSql = (dialect, index) => [
6
- 'ADD',
7
- index.unique ? 'UNIQUE INDEX' : 'INDEX',
8
- ...(index.name === '' ? [] : [quote(dialect, index.name)]),
9
- `(${columnList(dialect, index.columns)})`
10
- ].join(' ');
11
5
  const withPosition = (clause, position) => (position === null ? clause : `${clause} ${position}`);
12
6
  /*
13
7
  * One `ALTER TABLE`, or two when it has to be. A position clause names the columns as they are
@@ -61,7 +55,7 @@ export const mysqlAlterSql = (dialect, schema, original, draft, diff) => {
61
55
  late.push(`ADD PRIMARY KEY (${columnList(dialect, draft.primaryKey)})`);
62
56
  }
63
57
  for (const index of diff.addedIndexes) {
64
- late.push(indexSql(dialect, index));
58
+ late.push(`ADD ${inlineIndexSql(dialect, index)}`);
65
59
  }
66
60
  for (const foreignKey of diff.addedForeignKeys) {
67
61
  late.push(`ADD ${foreignKeySql(dialect, schema, foreignKey)}`);
@@ -5,7 +5,7 @@ import { isAutoIndex } from './draft.js';
5
5
  import { dropIndexSql } from './statements.js';
6
6
  const RENAME_COLUMN = [3, 25];
7
7
  const DROP_COLUMN = [3, 35];
8
- /* The clauses `ALTER TABLE .. ADD COLUMN` refuses: a key, a computed value, or a NOT NULL column with nothing to fill the rows with. */
8
+ /* Whether `ALTER TABLE .. ADD COLUMN` takes the column; it refuses a key, a stored computed value, or a NOT NULL column with nothing to fill the rows with. */
9
9
  const addable = (column) => {
10
10
  if (column.autoIncrement || (column.generated && column.generatedClause !== null && /\bSTORED\b/i.test(column.generatedClause))) {
11
11
  return false;
@@ -1,6 +1,5 @@
1
- import { sqlLiteral } from '../sql.js';
1
+ import { NUMERIC_TEXT, sqlLiteral } from '../sql.js';
2
2
  import { quote } from './dialect.js';
3
- const NUMBER = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/;
4
3
  const STRING = /^'(?:[^']|'')*'$/;
5
4
  const KEYWORD = /^(?:NULL|TRUE|FALSE|CURRENT_DATE|CURRENT_TIME(?:\(\d*\))?|CURRENT_TIMESTAMP(?:\(\d*\))?|LOCALTIME(?:STAMP)?(?:\(\d*\))?|NOW\(\d*\))$/i;
6
5
  /*
@@ -9,7 +8,7 @@ const KEYWORD = /^(?:NULL|TRUE|FALSE|CURRENT_DATE|CURRENT_TIME(?:\(\d*\))?|CURRE
9
8
  */
10
9
  export const defaultClause = (expression) => {
11
10
  const text = expression.trim();
12
- if (NUMBER.test(text) || STRING.test(text) || KEYWORD.test(text)) {
11
+ if (NUMERIC_TEXT.test(text) || STRING.test(text) || KEYWORD.test(text)) {
13
12
  return text;
14
13
  }
15
14
  return text.startsWith('(') && text.endsWith(')') ? text : `(${text})`;
@@ -28,13 +27,11 @@ export const columnSpecSql = (dialect, column, { inlinePrimaryKey = false } = {}
28
27
  parts.push(column.autoIncrement ? 'PRIMARY KEY AUTOINCREMENT' : 'PRIMARY KEY');
29
28
  }
30
29
  // MariaDB takes neither NULL nor NOT NULL after the expression of a computed column.
31
- if (column.generated && dialect.flavor === 'mariadb') {
32
- // The expression says everything.
33
- }
34
- else if (!column.nullable) {
30
+ const statesNullability = !(column.generated && dialect.flavor === 'mariadb');
31
+ if (statesNullability && !column.nullable) {
35
32
  parts.push('NOT NULL');
36
33
  }
37
- else if (mysql) {
34
+ else if (statesNullability && mysql) {
38
35
  // MySQL gives a TIMESTAMP column NOT NULL unless it is told otherwise.
39
36
  parts.push('NULL');
40
37
  }
@@ -3,6 +3,7 @@ import { type ForeignKeyDraft, type IndexDraft, type TableDraft } from './draft.
3
3
  import type { TableOptions } from './parse.ts';
4
4
  export declare const foreignKeySql: (dialect: Dialect, schema: string, foreignKey: ForeignKeyDraft) => string;
5
5
  export declare const mysqlOptionsSql: (options: TableOptions, against?: TableOptions) => string[];
6
+ export declare const inlineIndexSql: (dialect: Dialect, index: IndexDraft) => string;
6
7
  export declare const createIndexSql: (dialect: Dialect, schema: string, table: string, index: Pick<IndexDraft, 'name' | 'columns' | 'unique'>) => string;
7
8
  export interface CreateParts {
8
9
  readonly table: string;
@@ -35,7 +35,7 @@ export const mysqlOptionsSql = (options, against) => {
35
35
  ];
36
36
  };
37
37
  const sqliteOptionsSql = (options) => [...(options.strict ? ['STRICT'] : []), ...(options.withoutRowid ? ['WITHOUT ROWID'] : [])];
38
- const inlineIndexSql = (dialect, index) => [index.unique ? 'UNIQUE INDEX' : 'INDEX', ...(index.name === '' ? [] : [quote(dialect, index.name)]), `(${columnList(dialect, index.columns)})`].join(' ');
38
+ export const inlineIndexSql = (dialect, index) => [index.unique ? 'UNIQUE INDEX' : 'INDEX', ...(index.name === '' ? [] : [quote(dialect, index.name)]), `(${columnList(dialect, index.columns)})`].join(' ');
39
39
  /* `CREATE [UNIQUE] INDEX name ON table (columns)`. */
40
40
  export const createIndexSql = (dialect, schema, table, index) => `CREATE ${index.unique ? 'UNIQUE ' : ''}INDEX ${dialect.engine === 'sqlite' ? sqliteSchemaPrefix(dialect, schema) : ''}${quote(dialect, index.name)} ON ${tableName(dialect, dialect.engine === 'sqlite' ? '' : schema, table)} (${columnList(dialect, index.columns)})`;
41
41
  /* The `CREATE TABLE` of a draft under a name of the caller's choosing, which a rebuild uses for the table it fills before the swap. */
package/dist/ddl/diff.js CHANGED
@@ -26,12 +26,8 @@ const longestRun = (positions) => {
26
26
  };
27
27
  const OPTION_FIELDS = ['engine', 'charset', 'collation', 'comment', 'withoutRowid', 'strict'];
28
28
  const matches = (original, draft) => draft.originalName !== null && (original.name === '' ? draft.key === original.key : draft.originalName === original.name);
29
- /* What a draft changes in the table it came from, told apart by the names items kept. */
30
- export const diffOf = (dialect, original, draft) => {
29
+ const columnChanges = (dialect, original, draft) => {
31
30
  const survivors = draft.columns.filter((column) => column.originalName !== null);
32
- const renames = new Map(survivors.map((column) => [column.originalName, column.name]));
33
- const project = (names) => names.map((name) => renames.get(name) ?? name);
34
- const originalOf = (name) => original.columns.find((column) => column.name === name);
35
31
  const positions = survivors.map((column) => original.columns.findIndex((candidate) => candidate.name === column.originalName));
36
32
  const staying = longestRun(positions);
37
33
  let survivor = 0;
@@ -44,7 +40,7 @@ export const diffOf = (dialect, original, draft) => {
44
40
  return;
45
41
  }
46
42
  const rank = survivor++;
47
- const before = originalOf(column.originalName);
43
+ const before = original.columns.find((candidate) => candidate.name === column.originalName);
48
44
  if (before === undefined) {
49
45
  return;
50
46
  }
@@ -58,6 +54,9 @@ export const diffOf = (dialect, original, draft) => {
58
54
  });
59
55
  });
60
56
  const droppedColumns = original.columns.filter((column) => !survivors.some((kept) => kept.originalName === column.name));
57
+ return { droppedColumns, keptColumns, addedColumns };
58
+ };
59
+ const indexChanges = (original, draft, project) => {
61
60
  const droppedIndexes = [];
62
61
  const addedIndexes = draft.indexes.filter((index) => index.originalName === null);
63
62
  const renamedIndexes = [];
@@ -74,36 +73,39 @@ export const diffOf = (dialect, original, draft) => {
74
73
  renamedIndexes.push({ from: before, to: after });
75
74
  }
76
75
  }
76
+ return { droppedIndexes, addedIndexes, renamedIndexes };
77
+ };
78
+ const sameForeignKey = (before, after, project) => before.name === after.name &&
79
+ sameList(project(before.columns), after.columns) &&
80
+ before.referencedSchema === after.referencedSchema &&
81
+ before.referencedTable === after.referencedTable &&
82
+ sameList(before.referencedColumns, after.referencedColumns) &&
83
+ before.onUpdate === after.onUpdate &&
84
+ before.onDelete === after.onDelete;
85
+ const foreignKeyChanges = (original, draft, project) => {
77
86
  const droppedForeignKeys = [];
78
87
  const addedForeignKeys = draft.foreignKeys.filter((key) => key.originalName === null);
79
88
  for (const before of original.foreignKeys) {
80
89
  const after = draft.foreignKeys.find((key) => matches(before, key));
81
- const same = after !== undefined &&
82
- before.name === after.name &&
83
- sameList(project(before.columns), after.columns) &&
84
- before.referencedSchema === after.referencedSchema &&
85
- before.referencedTable === after.referencedTable &&
86
- sameList(before.referencedColumns, after.referencedColumns) &&
87
- before.onUpdate === after.onUpdate &&
88
- before.onDelete === after.onDelete;
89
- if (!same) {
90
+ if (after === undefined || !sameForeignKey(before, after, project)) {
90
91
  droppedForeignKeys.push(before);
91
92
  if (after !== undefined) {
92
93
  addedForeignKeys.push(after);
93
94
  }
94
95
  }
95
96
  }
97
+ return { droppedForeignKeys, addedForeignKeys };
98
+ };
99
+ /* What a draft changes in the table it came from, told apart by the names items kept. */
100
+ export const diffOf = (dialect, original, draft) => {
101
+ const renames = new Map(draft.columns.filter((column) => column.originalName !== null).map((column) => [column.originalName, column.name]));
102
+ const project = (names) => names.map((name) => renames.get(name) ?? name);
96
103
  return {
97
104
  renamed: original.name !== draft.name,
98
- droppedColumns,
99
- keptColumns,
100
- addedColumns,
105
+ ...columnChanges(dialect, original, draft),
101
106
  primaryKeyChanged: !sameList(project(original.primaryKey), draft.primaryKey),
102
- droppedIndexes,
103
- addedIndexes,
104
- renamedIndexes,
105
- droppedForeignKeys,
106
- addedForeignKeys,
107
+ ...indexChanges(original, draft, project),
108
+ ...foreignKeyChanges(original, draft, project),
107
109
  optionsChanged: OPTION_FIELDS.some((field) => original.options[field] !== draft.options[field])
108
110
  };
109
111
  };
@@ -15,10 +15,12 @@ export function ColumnsEditor({ draft, dialect, disabled, onChange, className, r
15
15
  const listId = useId();
16
16
  const mysql = dialect.engine === 'mysql';
17
17
  const defaultType = typeSuggestionsOf(dialect)[0];
18
- return (_jsxs("div", { ref: ref, className: clsx('flex min-h-0 flex-col', className), children: [draft.columns.length === 0 ? (_jsx(EmptyState, { children: t('designer.columns.empty') })) : (_jsxs("div", { children: [_jsx("datalist", { id: listId, children: typeSuggestionsOf(dialect).map((type) => (_jsx("option", { value: type }, type))) }), _jsxs("table", { className: "w-full border-collapse", children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { scope: "col", className: TH, children: _jsx("span", { className: "sr-only", children: t('designer.columns.order') }) }), _jsx("th", { scope: "col", className: TH, children: t('designer.columns.name') }), _jsx("th", { scope: "col", className: TH, children: t('designer.columns.type') }), _jsx("th", { scope: "col", className: clsx(TH, 'text-center'), children: t('designer.columns.notNull') }), _jsx("th", { scope: "col", className: TH, children: t('designer.columns.default') }), mysql && (_jsx("th", { scope: "col", className: clsx(TH, 'text-center'), children: t('designer.columns.autoIncrement') })), _jsx("th", { scope: "col", className: TH, children: t('designer.columns.primaryKey') }), mysql && (_jsx("th", { scope: "col", className: TH, children: t('designer.columns.comment') })), _jsx("th", { scope: "col", className: TH, children: _jsx("span", { className: "sr-only", children: t('designer.columns.remove') }) })] }) }), _jsx("tbody", { children: draft.columns.map((column, at) => {
19
- const keyAt = draft.primaryKey.indexOf(column.name);
20
- const locked = disabled || column.generated;
21
- const patch = (change) => onChange(patchColumn(draft, column.key, change));
22
- return (_jsxs("tr", { children: [_jsx("td", { className: clsx(TD, 'whitespace-nowrap'), children: _jsxs(ButtonGroup, { children: [_jsx(IconButton, { icon: ArrowUp, size: "xs", label: t('designer.columns.moveUp'), disabled: disabled || at === 0, onClick: () => onChange(moveColumn(draft, column.key, -1)) }), _jsx(IconButton, { icon: ArrowDown, size: "xs", label: t('designer.columns.moveDown'), disabled: disabled || at === draft.columns.length - 1, onClick: () => onChange(moveColumn(draft, column.key, 1)) })] }) }), _jsx("td", { className: clsx(TD, 'min-w-40'), children: _jsx(Input, { size: "sm", className: "w-full", "aria-label": t('designer.columns.name'), spellCheck: false, value: column.name, disabled: locked, onChange: (event) => patch({ name: event.target.value }) }) }), _jsx("td", { className: clsx(TD, 'min-w-36'), children: _jsx(Input, { size: "sm", mono: true, className: "w-full", "aria-label": t('designer.columns.type'), list: listId, spellCheck: false, value: column.type, disabled: locked, onChange: (event) => patch({ type: event.target.value }) }) }), _jsx("td", { className: TD, children: _jsx(Checkbox, { className: "mx-auto", label: t('designer.columns.notNullFor', { name: column.name }), checked: !column.nullable, disabled: locked, onCheckedChange: (checked) => patch({ nullable: !checked }) }) }), _jsx("td", { className: clsx(TD, 'min-w-36'), children: column.generated ? (_jsx(Pill, { mono: true, children: column.generatedClause ?? t('designer.columns.generated') })) : (_jsx(Input, { size: "sm", mono: true, className: "w-full", "aria-label": t('designer.columns.default'), placeholder: t('designer.columns.noDefault'), spellCheck: false, value: column.defaultValue ?? '', disabled: disabled, onChange: (event) => patch({ defaultValue: event.target.value === '' ? null : event.target.value }) })) }), mysql && (_jsx("td", { className: TD, children: _jsx(Checkbox, { className: "mx-auto", label: t('designer.columns.autoIncrementFor', { name: column.name }), checked: column.autoIncrement, disabled: locked, onCheckedChange: (checked) => patch({ autoIncrement: checked }) }) })), _jsx("td", { className: clsx(TD, 'whitespace-nowrap'), children: _jsxs("span", { className: "flex items-center gap-1", children: [_jsx(IconButton, { icon: KeyRound, size: "xs", label: keyAt < 0 ? t('designer.columns.addToKey') : t('designer.columns.removeFromKey'), "aria-pressed": keyAt >= 0, disabled: locked, onClick: () => onChange(togglePrimaryKey(draft, column.key)) }), keyAt >= 0 && draft.primaryKey.length > 1 && (_jsx("span", { className: "text-xs text-text-muted", children: formatNumber(keyAt + 1) }))] }) }), mysql && (_jsx("td", { className: clsx(TD, 'min-w-40'), children: _jsx(Input, { size: "sm", className: "w-full", "aria-label": t('designer.columns.comment'), value: column.comment, disabled: locked, onChange: (event) => patch({ comment: event.target.value }) }) })), _jsx("td", { className: TD, children: _jsx(IconButton, { icon: Trash2, size: "xs", label: t('designer.columns.remove'), disabled: disabled, onClick: () => onChange(removeColumn(draft, column.key)) }) })] }, column.key));
23
- }) })] })] })), _jsx("div", { className: "shrink-0 border-t border-border-soft px-3 py-2", children: _jsxs(Button, { variant: "secondary", size: "sm", disabled: disabled, onClick: () => onChange(addColumn(draft, defaultType)), children: [_jsx(Icon, { icon: Plus, size: 12 }), t('designer.columns.add')] }) })] }));
18
+ return (_jsxs("div", { ref: ref, className: clsx('flex min-h-0 flex-col', className), children: [draft.columns.length === 0 ? (_jsx(EmptyState, { children: t('designer.columns.empty') })) : (_jsxs("div", { children: [_jsx("datalist", { id: listId, children: typeSuggestionsOf(dialect).map((type) => (_jsx("option", { value: type }, type))) }), _jsxs("table", { className: "w-full border-collapse", children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { scope: "col", className: TH, children: _jsx("span", { className: "sr-only", children: t('designer.columns.order') }) }), _jsx("th", { scope: "col", className: TH, children: t('designer.columns.name') }), _jsx("th", { scope: "col", className: TH, children: t('designer.columns.type') }), _jsx("th", { scope: "col", className: clsx(TH, 'text-center'), children: t('designer.columns.notNull') }), _jsx("th", { scope: "col", className: TH, children: t('designer.columns.default') }), mysql && (_jsx("th", { scope: "col", className: clsx(TH, 'text-center'), children: t('designer.columns.autoIncrement') })), _jsx("th", { scope: "col", className: TH, children: t('designer.columns.primaryKey') }), mysql && (_jsx("th", { scope: "col", className: TH, children: t('designer.columns.comment') })), _jsx("th", { scope: "col", className: TH, children: _jsx("span", { className: "sr-only", children: t('designer.columns.remove') }) })] }) }), _jsx("tbody", { children: draft.columns.map((column, at) => (_jsx(ColumnRow, { draft: draft, column: column, at: at, mysql: mysql, listId: listId, disabled: disabled, onChange: onChange }, column.key))) })] })] })), _jsx("div", { className: "shrink-0 border-t border-border-soft px-3 py-2", children: _jsxs(Button, { variant: "secondary", size: "sm", disabled: disabled, onClick: () => onChange(addColumn(draft, defaultType)), children: [_jsx(Icon, { icon: Plus, size: 12 }), t('designer.columns.add')] }) })] }));
19
+ }
20
+ function ColumnRow({ draft, column, at, mysql, listId, disabled, onChange }) {
21
+ const { t } = useTranslation('database');
22
+ const keyAt = draft.primaryKey.indexOf(column.name);
23
+ const locked = disabled || column.generated;
24
+ const patch = (change) => onChange(patchColumn(draft, column.key, change));
25
+ return (_jsxs("tr", { children: [_jsx("td", { className: clsx(TD, 'whitespace-nowrap'), children: _jsxs(ButtonGroup, { children: [_jsx(IconButton, { icon: ArrowUp, size: "xs", label: t('designer.columns.moveUp'), disabled: disabled || at === 0, onClick: () => onChange(moveColumn(draft, column.key, -1)) }), _jsx(IconButton, { icon: ArrowDown, size: "xs", label: t('designer.columns.moveDown'), disabled: disabled || at === draft.columns.length - 1, onClick: () => onChange(moveColumn(draft, column.key, 1)) })] }) }), _jsx("td", { className: clsx(TD, 'min-w-40'), children: _jsx(Input, { size: "sm", className: "w-full", "aria-label": t('designer.columns.name'), spellCheck: false, value: column.name, disabled: locked, onChange: (event) => patch({ name: event.target.value }) }) }), _jsx("td", { className: clsx(TD, 'min-w-36'), children: _jsx(Input, { size: "sm", mono: true, className: "w-full", "aria-label": t('designer.columns.type'), list: listId, spellCheck: false, value: column.type, disabled: locked, onChange: (event) => patch({ type: event.target.value }) }) }), _jsx("td", { className: TD, children: _jsx(Checkbox, { className: "mx-auto", label: t('designer.columns.notNullFor', { name: column.name }), checked: !column.nullable, disabled: locked, onCheckedChange: (checked) => patch({ nullable: !checked }) }) }), _jsx("td", { className: clsx(TD, 'min-w-36'), children: column.generated ? (_jsx(Pill, { mono: true, children: column.generatedClause ?? t('designer.columns.generated') })) : (_jsx(Input, { size: "sm", mono: true, className: "w-full", "aria-label": t('designer.columns.default'), placeholder: t('designer.columns.noDefault'), spellCheck: false, value: column.defaultValue ?? '', disabled: disabled, onChange: (event) => patch({ defaultValue: event.target.value === '' ? null : event.target.value }) })) }), mysql && (_jsx("td", { className: TD, children: _jsx(Checkbox, { className: "mx-auto", label: t('designer.columns.autoIncrementFor', { name: column.name }), checked: column.autoIncrement, disabled: locked, onCheckedChange: (checked) => patch({ autoIncrement: checked }) }) })), _jsx("td", { className: clsx(TD, 'whitespace-nowrap'), children: _jsxs("span", { className: "flex items-center gap-1", children: [_jsx(IconButton, { icon: KeyRound, size: "xs", label: keyAt < 0 ? t('designer.columns.addToKey') : t('designer.columns.removeFromKey'), "aria-pressed": keyAt >= 0, disabled: locked, onClick: () => onChange(togglePrimaryKey(draft, column.key)) }), keyAt >= 0 && draft.primaryKey.length > 1 && _jsx("span", { className: "text-xs text-text-muted", children: formatNumber(keyAt + 1) })] }) }), mysql && (_jsx("td", { className: clsx(TD, 'min-w-40'), children: _jsx(Input, { size: "sm", className: "w-full", "aria-label": t('designer.columns.comment'), value: column.comment, disabled: locked, onChange: (event) => patch({ comment: event.target.value }) }) })), _jsx("td", { className: TD, children: _jsx(IconButton, { icon: Trash2, size: "xs", label: t('designer.columns.remove'), disabled: disabled, onClick: () => onChange(removeColumn(draft, column.key)) }) })] }));
24
26
  }
@@ -19,6 +19,10 @@ export function ForeignKeysEditor({ draft, schema, tables, referenceColumns, dis
19
19
  return (_jsxs("div", { ref: ref, className: clsx('flex min-h-0 flex-col', className), children: [_jsxs("div", { children: [draft.foreignKeys.length === 0 && _jsx(EmptyState, { children: t('designer.foreignKeys.empty') }), draft.foreignKeys.map((foreignKey) => {
20
20
  const referenced = foreignKey.referencedTable === '' ? [] : referenceColumns(foreignKey.referencedTable);
21
21
  const choices = tables.includes(foreignKey.referencedTable) || foreignKey.referencedTable === '' ? tables : [...tables, foreignKey.referencedTable];
22
- return (_jsxs("div", { className: "flex flex-col gap-3 border-b border-border-soft px-3 py-3", children: [_jsxs("div", { className: "flex items-end gap-3", children: [_jsx(Field, { label: t('designer.foreignKeys.name'), className: "w-56", children: _jsx(Input, { size: "sm", spellCheck: false, value: foreignKey.name, disabled: disabled, onChange: (event) => onChange(patchForeignKey(draft, foreignKey.key, { name: event.target.value })) }) }), _jsx(Field, { label: t('designer.foreignKeys.referencedTable'), group: true, className: "w-56", children: _jsx(Select, { size: "sm", className: "w-full", label: t('designer.foreignKeys.referencedTable'), placeholder: t('designer.foreignKeys.pickTable'), value: foreignKey.referencedTable === '' ? null : foreignKey.referencedTable, disabled: disabled, items: choices.map((table) => ({ value: table, label: table })), onValueChange: (table) => onChange(setReferencedTable(draft, foreignKey.key, schema, table)) }) }), _jsx(Field, { label: t('designer.foreignKeys.onUpdate'), group: true, className: "w-40", children: _jsx(Select, { size: "sm", className: "w-full", label: t('designer.foreignKeys.onUpdate'), value: foreignKey.onUpdate ?? DEFAULT_ACTION, disabled: disabled, items: actions, onValueChange: (action) => onChange(patchForeignKey(draft, foreignKey.key, { onUpdate: action === DEFAULT_ACTION ? null : action })) }) }), _jsx(Field, { label: t('designer.foreignKeys.onDelete'), group: true, className: "w-40", children: _jsx(Select, { size: "sm", className: "w-full", label: t('designer.foreignKeys.onDelete'), value: foreignKey.onDelete ?? DEFAULT_ACTION, disabled: disabled, items: actions, onValueChange: (action) => onChange(patchForeignKey(draft, foreignKey.key, { onDelete: action === DEFAULT_ACTION ? null : action })) }) }), _jsx(IconButton, { icon: Trash2, size: "xs", className: "mb-1", label: t('designer.foreignKeys.remove'), disabled: disabled, onClick: () => onChange(removeForeignKey(draft, foreignKey.key)) })] }), _jsxs("div", { className: "grid grid-cols-2 gap-3", children: [_jsx(Field, { label: t('designer.foreignKeys.columns'), group: true, children: _jsx(NamePicker, { label: t('designer.foreignKeys.columns'), options: columns, value: foreignKey.columns, disabled: disabled, onToggle: (name) => onChange(toggleForeignKeyColumn(draft, foreignKey.key, 'columns', name)) }) }), _jsx(Field, { label: t('designer.foreignKeys.referencedColumns'), group: true, children: referenced === null ? (_jsx(Spinner, { size: 14, label: t('designer.foreignKeys.loadingColumns') })) : (_jsx(NamePicker, { label: t('designer.foreignKeys.referencedColumns'), options: referenced, value: foreignKey.referencedColumns, disabled: disabled, onToggle: (name) => onChange(toggleForeignKeyColumn(draft, foreignKey.key, 'referencedColumns', name)) })) })] })] }, foreignKey.key));
22
+ return (_jsxs("div", { className: "flex flex-col gap-3 border-b border-border-soft px-3 py-3", children: [_jsxs("div", { className: "flex items-end gap-3", children: [_jsx(Field, { label: t('designer.foreignKeys.name'), className: "w-56", children: _jsx(Input, { size: "sm", spellCheck: false, value: foreignKey.name, disabled: disabled, onChange: (event) => onChange(patchForeignKey(draft, foreignKey.key, { name: event.target.value })) }) }), _jsx(Field, { label: t('designer.foreignKeys.referencedTable'), group: true, className: "w-56", children: _jsx(Select, { size: "sm", className: "w-full", label: t('designer.foreignKeys.referencedTable'), placeholder: t('designer.foreignKeys.pickTable'), value: foreignKey.referencedTable === '' ? null : foreignKey.referencedTable, disabled: disabled, items: choices.map((table) => ({ value: table, label: table })), onValueChange: (table) => onChange(setReferencedTable(draft, foreignKey.key, schema, table)) }) }), _jsx(ActionField, { label: t('designer.foreignKeys.onUpdate'), value: foreignKey.onUpdate, items: actions, disabled: disabled, onValueChange: (action) => onChange(patchForeignKey(draft, foreignKey.key, { onUpdate: action })) }), _jsx(ActionField, { label: t('designer.foreignKeys.onDelete'), value: foreignKey.onDelete, items: actions, disabled: disabled, onValueChange: (action) => onChange(patchForeignKey(draft, foreignKey.key, { onDelete: action })) }), _jsx(IconButton, { icon: Trash2, size: "xs", className: "mb-1", label: t('designer.foreignKeys.remove'), disabled: disabled, onClick: () => onChange(removeForeignKey(draft, foreignKey.key)) })] }), _jsxs("div", { className: "grid grid-cols-2 gap-3", children: [_jsx(Field, { label: t('designer.foreignKeys.columns'), group: true, children: _jsx(NamePicker, { label: t('designer.foreignKeys.columns'), options: columns, value: foreignKey.columns, disabled: disabled, onToggle: (name) => onChange(toggleForeignKeyColumn(draft, foreignKey.key, 'columns', name)) }) }), _jsx(Field, { label: t('designer.foreignKeys.referencedColumns'), group: true, children: referenced === null ? (_jsx(Spinner, { size: 14, label: t('designer.foreignKeys.loadingColumns') })) : (_jsx(NamePicker, { label: t('designer.foreignKeys.referencedColumns'), options: referenced, value: foreignKey.referencedColumns, disabled: disabled, onToggle: (name) => onChange(toggleForeignKeyColumn(draft, foreignKey.key, 'referencedColumns', name)) })) })] })] }, foreignKey.key));
23
23
  })] }), _jsx("div", { className: "shrink-0 px-3 py-2", children: _jsxs(Button, { variant: "secondary", size: "sm", disabled: disabled, onClick: () => onChange(addForeignKey(draft, schema)), children: [_jsx(Icon, { icon: Plus, size: 12 }), t('designer.foreignKeys.add')] }) })] }));
24
24
  }
25
+ /* What a key does on an update or a delete; `null` is the engine's default. */
26
+ function ActionField({ label, value, items, disabled, onValueChange }) {
27
+ return (_jsx(Field, { label: label, group: true, className: "w-40", children: _jsx(Select, { size: "sm", className: "w-full", label: label, value: value ?? DEFAULT_ACTION, disabled: disabled, items: items, onValueChange: (action) => onValueChange(action === DEFAULT_ACTION ? null : action) }) }));
28
+ }
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useId } from 'react';
3
3
  import clsx from 'clsx';
4
4
  import { useTranslation } from 'react-i18next';
@@ -10,8 +10,17 @@ export function OptionsEditor({ draft, dialect, disabled, onChange, className, r
10
10
  const { t } = useTranslation('database');
11
11
  const id = useId();
12
12
  const { options } = draft;
13
+ const patch = (change) => onChange(patchOptions(draft, change));
13
14
  if (dialect.engine === 'sqlite') {
14
- return (_jsxs("div", { ref: ref, className: clsx('flex flex-col gap-4 p-3', className), children: [_jsxs("label", { className: "flex items-start gap-3", children: [_jsx(Switch, { label: t('designer.options.withoutRowid'), checked: options.withoutRowid, disabled: disabled, onCheckedChange: (checked) => onChange(patchOptions(draft, { withoutRowid: checked })) }), _jsxs("span", { className: "flex flex-col", children: [_jsx("span", { className: "text-sm text-text", children: t('designer.options.withoutRowid') }), _jsx("span", { className: "text-xs text-text-muted", children: t('designer.options.withoutRowidHint') })] })] }), _jsxs("label", { className: "flex items-start gap-3", children: [_jsx(Switch, { label: t('designer.options.strict'), checked: options.strict, disabled: disabled, onCheckedChange: (checked) => onChange(patchOptions(draft, { strict: checked })) }), _jsxs("span", { className: "flex flex-col", children: [_jsx("span", { className: "text-sm text-text", children: t('designer.options.strict') }), _jsx("span", { className: "text-xs text-text-muted", children: t('designer.options.strictHint') })] })] })] }));
15
+ return (_jsxs("div", { ref: ref, className: clsx('flex flex-col gap-4 p-3', className), children: [_jsx(OptionSwitch, { label: t('designer.options.withoutRowid'), hint: t('designer.options.withoutRowidHint'), checked: options.withoutRowid, disabled: disabled, onCheckedChange: (withoutRowid) => patch({ withoutRowid }) }), _jsx(OptionSwitch, { label: t('designer.options.strict'), hint: t('designer.options.strictHint'), checked: options.strict, disabled: disabled, onCheckedChange: (strict) => patch({ strict }) })] }));
15
16
  }
16
- return (_jsxs("div", { ref: ref, className: clsx('flex max-w-xl flex-col gap-4 p-3', className), children: [_jsx("datalist", { id: `${id}-engines`, children: MYSQL_ENGINES.map((engine) => (_jsx("option", { value: engine }, engine))) }), _jsx("datalist", { id: `${id}-charsets`, children: MYSQL_CHARSETS.map((charset) => (_jsx("option", { value: charset }, charset))) }), _jsx("datalist", { id: `${id}-collations`, children: collationSuggestionsOf(options.charset).map((collation) => (_jsx("option", { value: collation }, collation))) }), _jsx(Field, { label: t('designer.options.engine'), hint: t('designer.options.serverDefault'), children: _jsx(Input, { mono: true, list: `${id}-engines`, spellCheck: false, value: options.engine, disabled: disabled, onChange: (event) => onChange(patchOptions(draft, { engine: event.target.value })) }) }), _jsx(Field, { label: t('designer.options.charset'), hint: t('designer.options.serverDefault'), children: _jsx(Input, { mono: true, list: `${id}-charsets`, spellCheck: false, value: options.charset, disabled: disabled, onChange: (event) => onChange(patchOptions(draft, { charset: event.target.value })) }) }), _jsx(Field, { label: t('designer.options.collation'), hint: t('designer.options.serverDefault'), children: _jsx(Input, { mono: true, list: `${id}-collations`, spellCheck: false, value: options.collation, disabled: disabled, onChange: (event) => onChange(patchOptions(draft, { collation: event.target.value })) }) }), _jsx(Field, { label: t('designer.options.comment'), children: _jsx(TextArea, { rows: 3, value: options.comment, disabled: disabled, onChange: (event) => onChange(patchOptions(draft, { comment: event.target.value })) }) })] }));
17
+ return (_jsxs("div", { ref: ref, className: clsx('flex max-w-xl flex-col gap-4 p-3', className), children: [_jsx(SuggestedField, { label: t('designer.options.engine'), listId: `${id}-engines`, suggestions: MYSQL_ENGINES, value: options.engine, disabled: disabled, onValueChange: (engine) => patch({ engine }) }), _jsx(SuggestedField, { label: t('designer.options.charset'), listId: `${id}-charsets`, suggestions: MYSQL_CHARSETS, value: options.charset, disabled: disabled, onValueChange: (charset) => patch({ charset }) }), _jsx(SuggestedField, { label: t('designer.options.collation'), listId: `${id}-collations`, suggestions: collationSuggestionsOf(options.charset), value: options.collation, disabled: disabled, onValueChange: (collation) => patch({ collation }) }), _jsx(Field, { label: t('designer.options.comment'), children: _jsx(TextArea, { rows: 3, value: options.comment, disabled: disabled, onChange: (event) => patch({ comment: event.target.value }) }) })] }));
18
+ }
19
+ function OptionSwitch({ label, hint, checked, disabled, onCheckedChange }) {
20
+ return (_jsxs("label", { className: "flex items-start gap-3", children: [_jsx(Switch, { label: label, checked: checked, disabled: disabled, onCheckedChange: onCheckedChange }), _jsxs("span", { className: "flex flex-col", children: [_jsx("span", { className: "text-sm text-text", children: label }), _jsx("span", { className: "text-xs text-text-muted", children: hint })] })] }));
21
+ }
22
+ /* A free text field with a list of common values to pick from; empty leaves it to the server. */
23
+ function SuggestedField({ label, listId, suggestions, value, disabled, onValueChange }) {
24
+ const { t } = useTranslation('database');
25
+ return (_jsxs(_Fragment, { children: [_jsx("datalist", { id: listId, children: suggestions.map((suggestion) => (_jsx("option", { value: suggestion }, suggestion))) }), _jsx(Field, { label: label, hint: t('designer.options.serverDefault'), children: _jsx(Input, { mono: true, list: listId, spellCheck: false, value: value, disabled: disabled, onChange: (event) => onValueChange(event.target.value) }) })] }));
17
26
  }
@@ -1,12 +1,8 @@
1
1
  import type { Ref } from 'react';
2
- import type { DraftProblem } from '../ddl/index.ts';
3
- import type { Plan } from './plan.ts';
2
+ import { type Plan } from './plan.ts';
4
3
  export interface SqlPreviewProps {
5
4
  plan: Plan;
6
5
  className?: string;
7
6
  ref?: Ref<HTMLDivElement>;
8
7
  }
9
- export declare function ProblemText({ problem }: {
10
- problem: DraftProblem;
11
- }): import("react").JSX.Element;
12
8
  export declare function SqlPreview({ plan, className, ref }: SqlPreviewProps): import("react").JSX.Element;
@@ -6,7 +6,7 @@ import { Button, Icon, copyText } from '@adecore/ui';
6
6
  import { CODE_TEXT } from '../code-text.js';
7
7
  import { scriptOf } from './plan.js';
8
8
  /* The sentence a problem of the draft is read as. */
9
- export function ProblemText({ problem }) {
9
+ function ProblemText({ problem }) {
10
10
  const { t } = useTranslation('database');
11
11
  return _jsx(_Fragment, { children: t(`ddl.problem.${problem.code}`, { subject: problem.subject }) });
12
12
  }