pgkiln 0.47.0 → 0.48.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.
@@ -32,6 +32,7 @@ export function objectMenu(schema: string, o: MenuObj): Raw {
32
32
  ${table ? item(`${BASE}/sql/objects/table?${new URLSearchParams({ schema, template: o.ref })}`, 'copy', 'Use as template') : ''}
33
33
  ${table ? item(`${BASE}/sql/load`, 'upload', 'Load data') : ''}
34
34
  ${table ? html`${item(q('index'), 'list', 'Add index')}${item(q('trigger'), 'bolt', 'Add trigger')}${item(q('policy'), 'shield', 'Add policy')}` : ''}
35
+ ${RELATIONS.has(o.type) ? item(`${BASE}/sql/rest/object?${new URLSearchParams({ schema, name: o.name })}`, 'globe', 'REST › Enable / Disable') : ''}
35
36
  ${item(q('ddl'), 'code', 'Quick DDL to the worksheet')}
36
37
  ${item(q('download'), 'download', 'Quick DDL as a file')}
37
38
  ${item(q('drop'), 'trash', 'Drop…')}
@@ -6,6 +6,7 @@ import { readSchema } from '../ddl.ts';
6
6
  import { back, BASE, csrf, developer, flash, region, send, shell, workshopTabs, type Req } from './ui.ts';
7
7
  import { currentWorkspace, inWorkspaceSql, sessionSchema, workshopDb } from './workspaces.ts';
8
8
  import { objectMenu } from './object-actions.ts';
9
+ import { isPinned, pinButton } from './launchpad.ts';
9
10
  import { owner } from '../db.ts';
10
11
  import type { Session } from '../session.ts';
11
12
 
@@ -142,7 +143,12 @@ export async function objectBrowserRoutes(app: FastifyInstance) {
142
143
 
143
144
  let detail: Raw;
144
145
  if (!schema) detail = html`<p class="muted">No schema you may use.</p>`;
145
- else if (selected) detail = await objectDetail(db, s, schema, selected, String(q.tab ?? ''), Number(q.p) || 1, link);
146
+ else if (selected) {
147
+ // (sprint 96) pin the object to the SQL Workshop's home
148
+ const href = `${BASE}/sql/objects?${new URLSearchParams({ schema, k: selected.type, o: selected.ref })}`;
149
+ const pin = pinButton(s, { href, label: selected.name, icon: OB_TYPES.find(([t]) => t === selected.type)![2] }, await isPinned(s, href), href);
150
+ detail = html`<div class="ob-pin">${pin}</div>${await objectDetail(db, s, schema, selected, String(q.tab ?? ''), Number(q.p) || 1, link)}`;
151
+ }
146
152
  else
147
153
  detail = region(`Create database objects in ${schema}`, html`<div class="ob-tiles">
148
154
  <a class="create-source" href="${BASE}/sql/objects/table?${new URLSearchParams({ schema })}">${icon('table')}<span><b>Table (form)</b><small class="muted">Columns, keys, indexes and security in a form</small></span></a>${[
@@ -0,0 +1,203 @@
1
+ import type { FastifyInstance, FastifyReply } from 'fastify';
2
+ import pg from 'pg';
3
+ import { PageCss } from '../css.ts';
4
+ import { owner } from '../db.ts';
5
+ import { html, raw, type Raw } from '../html.ts';
6
+ import { icon } from '../icons.ts';
7
+ import { isNumeric } from '../runtime/report.ts';
8
+ import { renderChartBody, type ChartKind } from '../runtime/charts.ts';
9
+ import type { Session } from '../session.ts';
10
+ import { unloadStatement } from '../unload.ts';
11
+ import { back, BASE, csrf, developer, flash, region, send, shell, workshopTabs, type Body, type Req } from './ui.ts';
12
+ import { currentWorkspace, sessionSchema, workshopDb } from './workspaces.ts';
13
+
14
+ // (sprint 97, Database Actions DA-6: Create Chart and Charts) A query drawn as a chart with pgkiln's own
15
+ // server-rendered charts (src/runtime/charts.ts): the worksheet's Chart tab draws the last query, guessing the label
16
+ // column (the first text or date column) and the value columns (the numbers); saved charts (meta.sql_chart,
17
+ // migration 110) are listed under SQL Workshop → Charts, the ones on the dashboard drawn there. A saved chart's
18
+ // SELECT runs read-only, with a statement timeout and at most 500 rows, as the workshop's connection.
19
+
20
+ export const SQL_CHART_KINDS: [ChartKind, string][] = [['bar', 'Bar'], ['column', 'Column'], ['stacked', 'Stacked bar'], ['line', 'Line'], ['area', 'Area'], ['donut', 'Donut'], ['pie', 'Pie'], ['scatter', 'Scatter']];
21
+ const MAX_ROWS = 500;
22
+
23
+ export interface ChartSpec { kind: ChartKind; label: string; values: string[] }
24
+
25
+ /** The columns to draw: the ones asked for when they exist, else a label (first non-number) and the numbers. */
26
+ export function chartSpec(columns: string[], numeric: boolean[], asked: Partial<ChartSpec> = {}): ChartSpec | null {
27
+ const kind = SQL_CHART_KINDS.some(([k]) => k === asked.kind) ? asked.kind! : 'bar';
28
+ const label = asked.label && columns.includes(asked.label) ? asked.label : (columns.find((_, i) => !numeric[i]) ?? columns[0]);
29
+ const wanted = (asked.values ?? []).filter((v) => columns.includes(v) && v !== label);
30
+ const values = wanted.length ? wanted : columns.filter((c, i) => numeric[i] && c !== label);
31
+ return label && values.length ? { kind, label, values } : null;
32
+ }
33
+
34
+ /** A chart of rows (text values from the worksheet, or values from a query), with the rules for the page's <style>. */
35
+ export function drawChart(title: string, columns: string[], rows: unknown[][], spec: ChartSpec): { body: Raw; css: string } {
36
+ const css = new PageCss();
37
+ const li = columns.indexOf(spec.label);
38
+ const vi = spec.values.map((v) => columns.indexOf(v));
39
+ const data = rows.map((r) => [r[li] === null || r[li] === undefined ? '' : String(r[li]), ...vi.map((i) => (r[i] === null || r[i] === undefined || r[i] === '' ? null : Number(r[i])))]);
40
+ const body = renderChartBody(spec.kind, title, data, [{ name: spec.label }, ...spec.values.map((name) => ({ name }))], css);
41
+ return { body, css: css.text };
42
+ }
43
+
44
+ /** The controls of a chart (type, label column, value columns), named chart_*. */
45
+ export function chartControls(columns: string[], spec: ChartSpec, idPrefix = 'ch'): Raw {
46
+ return html`<div class="ch-controls">
47
+ <label><span>Type</span><select name="chart_kind" id="${idPrefix}-kind">${SQL_CHART_KINDS.map(([k, label]) => html`<option value="${k}"${k === spec.kind ? raw(' selected') : ''}>${label}</option>`)}</select></label>
48
+ <label><span>Labels</span><select name="chart_label" id="${idPrefix}-label">${columns.map((c) => html`<option value="${c}"${c === spec.label ? raw(' selected') : ''}>${c}</option>`)}</select></label>
49
+ <fieldset><legend>Values</legend>${columns.map((c) => html`<label class="check"><input type="checkbox" name="chart_values" value="${c}"${spec.values.includes(c) ? raw(' checked') : ''}> ${c}</label>`)}</fieldset>
50
+ </div>`;
51
+ }
52
+
53
+ const list = (v: unknown) => (Array.isArray(v) ? v : v === undefined || v === null || v === '' ? [] : [v]).map(String);
54
+ export const askedSpec = (b: Body): Partial<ChartSpec> => ({ kind: b.chart_kind as ChartKind, label: typeof b.chart_label === 'string' ? b.chart_label : undefined, values: list(b.chart_values) });
55
+
56
+ interface SavedChart { id: number; name: string; sql: string; kind: ChartKind; label_col: string; value_cols: string[]; on_dashboard: boolean; created_by: string | null; updated_at: string }
57
+
58
+ /** A saved chart's SELECT, run read-only: columns, which are numbers, and the rows. */
59
+ async function runChart(s: Session, sql: string) {
60
+ const db = await workshopDb(s);
61
+ const schema = await sessionSchema(s, db);
62
+ const c = await db.pool.connect();
63
+ try {
64
+ await c.query('begin transaction read only');
65
+ await c.query(`select set_config('statement_timeout', '10s', true)`);
66
+ if (schema) await c.query(`select set_config('search_path', $1, true)`, [`${pg.escapeIdentifier(schema)}, public`]);
67
+ const res = await c.query({ text: `select * from (${unloadStatement(sql)}\n) chart_rows limit ${MAX_ROWS}`, rowMode: 'array' });
68
+ return { columns: res.fields.map((f) => f.name), numeric: res.fields.map((f) => isNumeric(f.dataTypeID)), rows: res.rows as unknown[][] };
69
+ } finally {
70
+ await c.query('rollback').catch(() => {});
71
+ c.release(true);
72
+ }
73
+ }
74
+
75
+ async function chartCard(s: Session, ch: SavedChart, full = false): Promise<{ body: Raw; css: string }> {
76
+ try {
77
+ const r = await runChart(s, ch.sql);
78
+ const spec = chartSpec(r.columns, r.numeric, { kind: ch.kind, label: ch.label_col, values: ch.value_cols });
79
+ if (!spec) return { body: html`<p class="muted">The query has no number column to draw.</p>`, css: '' };
80
+ const d = drawChart(ch.name, r.columns, r.rows, spec);
81
+ return { body: html`${d.body}${full ? html`<p class="muted small">${r.rows.length} row${r.rows.length === 1 ? '' : 's'}${r.rows.length === MAX_ROWS ? ` (the first ${MAX_ROWS})` : ''}.</p>` : ''}`, css: d.css };
82
+ } catch (e) {
83
+ return { body: html`<p class="alert alert-error">${(e as Error).message}</p>`, css: '' };
84
+ }
85
+ }
86
+
87
+ const style = (nonce: string, css: string) => (css ? html`<style nonce="${nonce}">${raw(css.replace(/</g, ''))}</style>` : '');
88
+
89
+ export async function sqlChartRoutes(app: FastifyInstance) {
90
+ // the list and the dashboard
91
+ app.get(`${BASE}/sql/charts`, async (req: Req, reply: FastifyReply) => {
92
+ const s = await developer(req, reply);
93
+ if (!s) return;
94
+ const charts = (await owner.query<SavedChart>('select * from meta.sql_chart where workspace_id = $1 order by lower(name)', [currentWorkspace(s)])).rows;
95
+ let css = '';
96
+ const cards: Raw[] = [];
97
+ for (const ch of charts.filter((c) => c.on_dashboard)) {
98
+ const card = await chartCard(s, ch);
99
+ css += `\n${card.css}`;
100
+ cards.push(html`<section class="ch-card"><h2><a href="${BASE}/sql/charts/${ch.id}">${ch.name}</a></h2>${card.body}</section>`);
101
+ }
102
+ const main = html`<h1 class="u-mb1">Charts</h1>${workshopTabs('charts')}${style(req.cspNonce, css)}
103
+ <p class="muted">Queries drawn as charts. Make one from a query in the <a href="${BASE}/sql/worksheet">SQL Worksheet</a> (its Chart tab, Ctrl+F11), then save it.</p>
104
+ ${cards.length ? html`<div class="ch-dashboard">${cards}</div>` : ''}
105
+ ${region('All charts', charts.length ? html`<div class="table-wrap"><table class="report"><thead><tr><th>Chart</th><th>Type</th><th>On the dashboard</th><th>Changed</th></tr></thead><tbody>
106
+ ${charts.map((c) => html`<tr><td><a href="${BASE}/sql/charts/${c.id}">${c.name}</a></td><td>${SQL_CHART_KINDS.find(([k]) => k === c.kind)?.[1] ?? c.kind}</td><td>${c.on_dashboard ? 'Yes' : 'No'}</td>
107
+ <td>${new Date(c.updated_at).toISOString().slice(0, 16).replace('T', ' ')}${c.created_by ? html` <span class="muted small">by ${c.created_by}</span>` : ''}</td></tr>`)}</tbody></table></div>`
108
+ : html`<p class="muted">No charts yet.</p>`)}`;
109
+ return send(reply, s, shell(s, 'Charts', [['SQL Workshop', `${BASE}/sql`], ['Charts']], main, 'sql'));
110
+ });
111
+
112
+ // one chart: drawn, with its settings
113
+ app.get(`${BASE}/sql/charts/:id`, async (req: Req, reply: FastifyReply) => {
114
+ const s = await developer(req, reply);
115
+ if (!s) return;
116
+ const ch = /^\d{1,9}$/.test(String(req.params.id)) ? await owner.one<SavedChart>('select * from meta.sql_chart where id = $1 and workspace_id = $2', [req.params.id, currentWorkspace(s)]) : undefined;
117
+ if (!ch) return reply.code(404).send('Not found');
118
+ const card = await chartCard(s, ch, true);
119
+ let controls: Raw | '' = '';
120
+ try {
121
+ const r = await runChart(s, ch.sql);
122
+ const spec = chartSpec(r.columns, r.numeric, { kind: ch.kind, label: ch.label_col, values: ch.value_cols });
123
+ if (spec) controls = chartControls(r.columns, spec, 'sch');
124
+ } catch {
125
+ controls = '';
126
+ }
127
+ const main = html`<h1 class="u-mb1">${ch.name}</h1>${workshopTabs('charts')}${style(req.cspNonce, card.css)}
128
+ <div class="ch-view">${card.body}</div>
129
+ <form method="post" action="${BASE}/sql/charts/${ch.id}" class="ch-edit">${csrf(s)}
130
+ <div class="field"><label class="label" for="ch-name">Name</label><input id="ch-name" name="name" value="${ch.name}" maxlength="120" required></div>
131
+ ${controls}
132
+ <label class="check"><input type="checkbox" name="on_dashboard"${ch.on_dashboard ? raw(' checked') : ''}> On the Charts dashboard</label>
133
+ <details class="u-mt1"><summary>The query</summary><pre class="source">${ch.sql}</pre></details>
134
+ <div class="buttons"><button class="btn btn-hot" name="action" value="save">${icon('save')} Save</button>
135
+ <button class="btn" name="action" value="worksheet">${icon('code')} Open in the worksheet</button>
136
+ <button class="btn btn-danger" name="action" value="delete" data-confirm="Delete the chart ${ch.name}?">${icon('trash')} Delete</button></div>
137
+ </form>`;
138
+ return send(reply, s, shell(s, ch.name, [['SQL Workshop', `${BASE}/sql`], ['Charts', `${BASE}/sql/charts`], [ch.name]], main, 'sql'));
139
+ });
140
+
141
+ // save a chart from the worksheet (a new one), or change, open or delete a saved one
142
+ const save = async (req: Req, reply: FastifyReply) => {
143
+ const s = await developer(req, reply);
144
+ if (!s) return;
145
+ const b = req.body ?? {};
146
+ const ws = currentWorkspace(s);
147
+ const id = req.params?.id;
148
+ const name = String(b.name ?? b.chart_name ?? '').trim();
149
+ const asked = askedSpec(b);
150
+ const kind = SQL_CHART_KINDS.some(([k]) => k === asked.kind) ? asked.kind! : 'bar';
151
+ if (id !== undefined) {
152
+ const ch = /^\d{1,9}$/.test(String(id)) ? await owner.one<SavedChart>('select * from meta.sql_chart where id = $1 and workspace_id = $2', [id, ws]) : undefined;
153
+ if (!ch) return reply.code(404).send('Not found');
154
+ if (b.action === 'delete') {
155
+ await owner.query('delete from meta.sql_chart where id = $1', [ch.id]);
156
+ flash(s, `Deleted the chart ${ch.name}.`);
157
+ return back(reply, s, `${BASE}/sql/charts`);
158
+ }
159
+ if (b.action === 'worksheet') {
160
+ s.state.__SQLDRAFT = ch.sql;
161
+ return back(reply, s, `${BASE}/sql/worksheet`);
162
+ }
163
+ if (!name || name.length > 120) {
164
+ flash(s, 'A chart needs a name (at most 120 characters).', 'error');
165
+ return back(reply, s, `${BASE}/sql/charts/${ch.id}`);
166
+ }
167
+ try {
168
+ await owner.query(`update meta.sql_chart set name = $2, kind = $3, label_col = $4, value_cols = $5, on_dashboard = $6, updated_at = now() where id = $1`,
169
+ [ch.id, name, kind, asked.label ?? ch.label_col, asked.values?.length ? asked.values : ch.value_cols, b.on_dashboard === 'on']);
170
+ } catch (e) {
171
+ if ((e as { code?: string }).code !== '23505') throw e;
172
+ flash(s, `A chart named “${name}” exists already.`, 'error');
173
+ return back(reply, s, `${BASE}/sql/charts/${ch.id}`);
174
+ }
175
+ flash(s, `Saved the chart ${name}.`);
176
+ return back(reply, s, `${BASE}/sql/charts/${ch.id}`);
177
+ }
178
+ let sql = '';
179
+ try {
180
+ sql = unloadStatement(String(b.chart_sql ?? ''));
181
+ } catch {
182
+ flash(s, 'Only a chart of one SELECT can be saved.', 'error');
183
+ return back(reply, s, `${BASE}/sql/worksheet`);
184
+ }
185
+ if (!name || name.length > 120) {
186
+ flash(s, 'Name the chart to save it (at most 120 characters).', 'error');
187
+ return back(reply, s, `${BASE}/sql/worksheet`);
188
+ }
189
+ try {
190
+ const r = await owner.one<{ id: number }>(
191
+ `insert into meta.sql_chart (workspace_id, name, sql, kind, label_col, value_cols, created_by) values ($1, $2, $3, $4, $5, $6, $7) returning id`,
192
+ [ws, name, sql, kind, asked.label ?? '', asked.values ?? [], s.username]);
193
+ flash(s, `Saved the chart ${name}: it is on the Charts dashboard.`);
194
+ return back(reply, s, `${BASE}/sql/charts/${r!.id}`);
195
+ } catch (e) {
196
+ if ((e as { code?: string }).code !== '23505') throw e;
197
+ flash(s, `A chart named “${name}” exists already.`, 'error');
198
+ return back(reply, s, `${BASE}/sql/worksheet`);
199
+ }
200
+ };
201
+ app.post(`${BASE}/sql/charts`, save);
202
+ app.post(`${BASE}/sql/charts/:id`, save);
203
+ }
@@ -0,0 +1,49 @@
1
+ // (sprint 96, Database Actions DA-15) The SQL Workshop's tools in one list: the home page's Launchpad tabs
2
+ // (Development, Administration, Monitoring), the More menu's headings and the builder's search (Ctrl+K) read it.
3
+ // `tab` is the SQL Workshop tab a tool's own pages mark (workshopTabs in ui.ts); tools without one are pages
4
+ // elsewhere in the builder.
5
+
6
+ export type ToolGroup = 'development' | 'administration' | 'monitoring';
7
+ export interface SqlTool {
8
+ key: string;
9
+ href: string;
10
+ icon: string;
11
+ label: string;
12
+ description: string;
13
+ group: ToolGroup;
14
+ /** the SQL Workshop tab its pages mark */
15
+ tab?: string;
16
+ /** one of the main tabs (not in More) */
17
+ main?: boolean;
18
+ /** administrators only */
19
+ admin?: boolean;
20
+ }
21
+
22
+ export const TOOL_GROUPS: [ToolGroup, string][] = [['development', 'Development'], ['administration', 'Administration'], ['monitoring', 'Monitoring']];
23
+
24
+ export const SQL_TOOLS: SqlTool[] = [
25
+ { key: 'worksheet', href: '/builder/sql/worksheet', icon: 'code', label: 'SQL Worksheet', description: 'Run statements and scripts with a navigator, plans, results and history.', group: 'development', tab: 'sql', main: true },
26
+ { key: 'objects', href: '/builder/sql/objects', icon: 'database', label: 'Object Browser', description: 'Tables, views and functions with their columns, data, keys, policies and DDL.', group: 'development', tab: 'objects', main: true },
27
+ { key: 'scripts', href: '/builder/sql/scripts', icon: 'file', label: 'SQL Scripts', description: 'Saved scripts of the workspace: upload, run, download, run history.', group: 'development', tab: 'scripts', main: true },
28
+ { key: 'table', href: '/builder/sql/objects/table', icon: 'table', label: 'Create Table', description: 'A new table in a form: columns, keys, indexes and security.', group: 'development' },
29
+ { key: 'charts', href: '/builder/sql/charts', icon: 'chart', label: 'Charts', description: 'Queries drawn as charts, and a dashboard of them.', group: 'development', tab: 'charts' },
30
+ { key: 'quicksql', href: '/builder/sql/quick', icon: 'bolt', label: 'Quick SQL', description: 'A data model in shorthand, turned into DDL.', group: 'development', tab: 'quicksql' },
31
+ { key: 'query', href: '/builder/sql/query', icon: 'filter', label: 'Query Builder', description: 'A query from tables and their joins, drawn.', group: 'development', tab: 'query' },
32
+ { key: 'load', href: '/builder/sql/load', icon: 'upload', label: 'Load Data', description: 'CSV, Excel, JSON or XML into a new or existing table.', group: 'development', tab: 'load' },
33
+ { key: 'unload', href: '/builder/sql/unload', icon: 'download', label: 'Unload Data', description: 'A table, view or query as CSV, JSON, Excel, XML or SQL inserts.', group: 'development', tab: 'unload' },
34
+ { key: 'sample', href: '/builder/sql/sample-data', icon: 'layers', label: 'Sample Data', description: 'Rows that look real, for development.', group: 'development', tab: 'sample' },
35
+ { key: 'ddl', href: '/builder/sql/ddl', icon: 'file', label: 'Generate DDL', description: 'A schema\'s objects as one script.', group: 'development', tab: 'ddl' },
36
+ { key: 'compare', href: '/builder/sql/compare', icon: 'layers', label: 'Schema Comparison', description: 'The differences between two schemas, and the SQL to align them.', group: 'development', tab: 'compare' },
37
+ { key: 'modeler', href: '/builder/sql/modeler', icon: 'org', label: 'Data Modeler', description: 'Relational diagrams of tables and their foreign keys; DDL and a data dictionary from them.', group: 'development', tab: 'modeler' },
38
+ { key: 'rest', href: '/builder/sql/rest', icon: 'globe', label: 'RESTful Services', description: 'Tables and views as REST endpoints (AutoREST): URLs, examples, OpenAPI.', group: 'development', tab: 'rest' },
39
+ { key: 'ai', href: '/builder/sql/ai', icon: 'bolt', label: 'AI', description: 'SQL from a question, explanations and table descriptions.', group: 'development', tab: 'ai' },
40
+ { key: 'utilities', href: '/builder/sql/utilities', icon: 'wrench', label: 'Utilities', description: 'Object reports, About Database and the other utilities.', group: 'administration', tab: 'utilities', main: true },
41
+ { key: 'backups', href: '/builder/backups', icon: 'archive', label: 'Manage Backups', description: 'Backups of the workspace\'s applications.', group: 'administration' },
42
+ { key: 'workspaces', href: '/builder/workspaces', icon: 'layers', label: 'Workspaces', description: 'Workspaces, their developers and database roles.', group: 'administration', admin: true },
43
+ { key: 'dbusers', href: '/builder/db-users', icon: 'key', label: 'Database Users', description: 'PostgreSQL roles: sign-in, expiry, memberships and schema privileges.', group: 'administration', admin: true },
44
+ { key: 'developers', href: '/builder/developers', icon: 'users', label: 'Developers', description: 'Builder accounts and their workspaces.', group: 'administration' },
45
+ { key: 'monitor', href: '/builder/sql/utilities/monitor', icon: 'activity', label: 'Database Monitor', description: 'Sessions, what they run and what blocks them.', group: 'monitoring', admin: true },
46
+ { key: 'about', href: '/builder/sql/utilities/about', icon: 'info', label: 'About Database', description: 'The server: version, settings, extensions and sizes.', group: 'monitoring' },
47
+ { key: 'reports', href: '/builder/sql/utilities/reports', icon: 'list', label: 'Object Reports', description: 'Tables without keys, unindexed foreign keys, row level security gaps and more.', group: 'monitoring' },
48
+ { key: 'activity', href: '/builder/dashboard', icon: 'chart', label: 'Monitor Activity', description: 'Page views, errors and slow pages of the applications.', group: 'monitoring' },
49
+ ];
@@ -8,6 +8,9 @@ import { clientIp, logActivity, type Session } from '../session.ts';
8
8
  import { clearCompletions } from './code-editor.ts';
9
9
  import { scriptRoutes } from './scripts.ts';
10
10
  import { worksheetRoutes } from './worksheet.ts';
11
+ import { launchpad, launchpadRoutes } from './launchpad.ts';
12
+ import { sqlChartRoutes } from './sql-charts.ts';
13
+ import { isAdmin } from './locks.ts';
11
14
  import { quickSqlRoutes } from './quicksql.ts';
12
15
  import { queryBuilderRoutes } from './querybuilder.ts';
13
16
  import { unloadRoutes } from './unload.ts';
@@ -16,6 +19,8 @@ import { sqlUtilityRoutes } from './sql-utilities.ts';
16
19
  import { createTemplate, objectBrowserRoutes } from './object-browser.ts';
17
20
  import { objectActionRoutes } from './object-actions.ts';
18
21
  import { tableEditorRoutes } from './table-editor.ts';
22
+ import { dataModelerRoutes } from './data-modeler.ts';
23
+ import { autoRestBuilderRoutes } from './autorest.ts';
19
24
  import { currentWorkspace, sessionSchema, usableSchemas, workshopDb, workshopRunsAs } from './workspaces.ts';
20
25
 
21
26
  // SQL Workshop: SQL commands and the object browser (owner connection; an isolated
@@ -54,21 +59,13 @@ async function workshopHome(req: Req, reply: FastifyReply) {
54
59
  [currentWorkspace(s)],
55
60
  ),
56
61
  ]);
57
- const tile = (href: string, ic: string, title: string, text: string) =>
58
- html`<a class="ab-tile sw-tile" href="${BASE}${href}"><span class="ab-tile-icon sw-tile-icon">${icon(ic)}</span><strong>${title}</strong><span>${text}</span></a>`;
59
62
  const list = (title: string | Raw, rows: Raw[], empty: string) =>
60
63
  html`<section class="sw-recent"><h2>${title}</h2>${rows.length ? html`<ul>${rows}</ul>` : html`<p class="muted">${empty}</p>`}</section>`;
61
64
  const main = html`<div class="ab-home">
62
65
  <div class="ab-main">
63
66
  <h1 class="sr-only">SQL Workshop</h1>
64
67
  ${workshopTabs('home')}
65
- <div class="ab-tiles sw-tiles">
66
- ${tile('/sql/worksheet', 'code', 'SQL Worksheet', 'Run statements and scripts, with a navigator, plans and history.')}
67
- ${tile('/sql/objects', 'search', 'Object Browser', 'Browse tables, views and functions, their policies, grants and data.')}
68
- ${tile('/sql/scripts', 'file', 'SQL Scripts', 'Create, upload and run stored SQL scripts.')}
69
- ${tile('/sql/utilities', 'wrench', 'Utilities', 'Load and unload data, sample data, DDL, schema comparison, object reports and the database.')}
70
- ${tile('/sql/quick', 'bolt', 'Quick SQL', 'Write a data model in shorthand and get the DDL.')}
71
- </div>
68
+ ${await launchpad(s, await isAdmin(s.username))}
72
69
  <div class="sw-recents">
73
70
  ${list('Recently created tables', tables.rows.map((t) => html`<li><a href="${BASE}/sql/objects?o=${encodeURIComponent(t.qname)}">${t.qname}</a><span class="muted">~${Number(t.rows).toLocaleString('en')} rows</span></li>`), 'No tables you can read.')}
74
71
  ${list(html`<a href="${BASE}/sql/worksheet?tab=history" title="Open SQL History">Recent SQL commands</a>`, commands.rows.map((c) => html`<li><a href="${BASE}/sql/worksheet?recent=${c.id}" title="${c.detail.slice(0, 500)}">${c.detail.replace(/\s+/g, ' ').slice(0, 60)}</a><span class="muted">${ago(c.at)}</span></li>`), 'You haven\'t run SQL commands yet.')}
@@ -105,6 +102,10 @@ export async function sqlRoutes(app: FastifyInstance) {
105
102
  // Generate DDL and Schema Comparison (sprint 56)
106
103
  await app.register(ddlToolRoutes);
107
104
  await app.register(sqlUtilityRoutes);
105
+ // (sprint 97) Charts: saved charts and their dashboard
106
+ await app.register(sqlChartRoutes);
107
+ // (sprint 96) the Launchpad's pins and the builder's search (Ctrl+K)
108
+ await app.register(launchpadRoutes);
108
109
  // (sprint 91) the SQL Worksheet (was SQL Commands; /sql/commands and a post to /sql are the same page)
109
110
  await app.register(worksheetRoutes);
110
111
  app.get(`${BASE}/sql`, workshopHome);
@@ -128,4 +129,8 @@ export async function sqlRoutes(app: FastifyInstance) {
128
129
  await app.register(objectActionRoutes);
129
130
  // (sprint 95) Table Properties: create, edit or copy a table in a form
130
131
  await app.register(tableEditorRoutes);
132
+ // (sprint 98) the Data Modeler: relational diagrams, their DDL and data dictionary
133
+ await app.register(dataModelerRoutes);
134
+ // (sprint 100) RESTful Services: AutoREST (autorest.ts)
135
+ await app.register(autoRestBuilderRoutes);
131
136
  }
package/src/builder/ui.ts CHANGED
@@ -7,6 +7,7 @@ import { html, raw, type Raw } from '../html.ts';
7
7
  import { icon } from '../icons.ts';
8
8
  import { documentShell } from '../layout.ts';
9
9
  import { getSession, saveState, takeFlash, type Session } from '../session.ts';
10
+ import { SQL_TOOLS, TOOL_GROUPS } from './sql-tools.ts';
10
11
  import { ensureDevbar } from '../runtime/devbar.ts';
11
12
 
12
13
  // Shared building blocks of the builder UI.
@@ -51,8 +52,8 @@ export type Section = 'apps' | 'users' | 'sql' | 'developers' | 'gallery';
51
52
  // Builder-only stylesheets and scripts, loaded on every builder page after
52
53
  // app.css / app.js (the runtime never loads them). Add new builder assets
53
54
  // here, and only here.
54
- export const BUILDER_STYLES = ['/static/builder.css', '/static/code-editor.css', '/static/worksheet.css'];
55
- export const BUILDER_SCRIPTS = ['/static/builder.js', '/static/code-editor.js', '/static/worksheet.js'];
55
+ export const BUILDER_STYLES = ['/static/builder.css', '/static/code-editor.css', '/static/worksheet.css', '/static/data-modeler.css'];
56
+ export const BUILDER_SCRIPTS = ['/static/builder.js', '/static/code-editor.js', '/static/worksheet.js', '/static/palette.js', '/static/data-modeler.js'];
56
57
  export const builderHead = () =>
57
58
  html`${BUILDER_STYLES.map((href) => html`<link rel="stylesheet" href="${href}">\n`)}${BUILDER_SCRIPTS.map((src) => html`<script src="${src}" defer></script>\n`)}`;
58
59
 
@@ -139,6 +140,7 @@ export function shell(s: Session, title: string, crumbs: [string, string?][], ma
139
140
  <a href="${BASE}/users">${icon('user')} Manage users and groups</a>
140
141
  <a href="${BASE}/developers">${icon('users')} Developers</a>
141
142
  <a href="${BASE}/dashboard">${icon('activity')} Monitor activity</a>
143
+ ${s.instanceAdmin ? html`<a href="${BASE}/db-users">${icon('key')} Database users</a>` : ''}
142
144
  <a href="${BASE}/developers">${icon('key')} Change my password</a>
143
145
  </div>
144
146
  </div></details></li>
@@ -170,6 +172,9 @@ export function shell(s: Session, title: string, crumbs: [string, string?][], ma
170
172
  ${back ? html`<a class="ide-back" href="${back[1]}" title="Back to ${back[0]}">${bicon('back')}<span class="sr-only">Back to ${back[0]}</span></a>` : ''}
171
173
  <nav class="ide-crumbs" aria-label="Breadcrumb"><ol>${crumbs.map(([label, href], i) =>
172
174
  html`<li>${href && i < crumbs.length - 1 ? html`<a href="${href}">${label}</a>` : html`<span aria-current="page">${label}</span>`}</li>`)}</ol></nav>
175
+ <form method="get" action="${BASE}/palette" class="ide-search" role="search" data-palette>
176
+ <label class="sr-only" for="ide-search-q">Search the builder</label>
177
+ <input id="ide-search-q" name="q" type="search" placeholder="Search (Ctrl+K)" autocomplete="off" aria-keyshortcuts="Control+K"></form>
173
178
  <div class="ide-tools">${opts.toolbar ?? ''}</div>
174
179
  </header>
175
180
  ${s.workspace?.banner_label ? html`<div class="ide-banner ide-banner-${BANNER_COLORS.includes(s.workspace.banner_color ?? '') ? s.workspace.banner_color : 'blue'}" role="note" aria-label="Environment">${s.workspace.banner_label}</div>` : ''}
@@ -247,22 +252,23 @@ export const appHeader = (a: any, active: 'pages' | 'shared' | 'settings' | 'act
247
252
  };
248
253
 
249
254
  /** Tabs of the SQL Workshop. */
250
- export type WorkshopTab = 'home' | 'sql' | 'scripts' | 'quicksql' | 'query' | 'objects' | 'load' | 'unload' | 'sample' | 'ai' | 'ddl' | 'compare' | 'utilities';
255
+ export type WorkshopTab = 'home' | 'sql' | 'scripts' | 'quicksql' | 'query' | 'objects' | 'load' | 'unload' | 'sample' | 'ai' | 'ddl' | 'compare' | 'utilities' | 'charts' | 'modeler' | 'rest';
251
256
  export const workshopTabs = (active: WorkshopTab) => {
252
- const tab = (key: WorkshopTab, href: string, ic: string, label: string) =>
253
- html`<a class="ide-tab" href="${BASE}${href}"${active === key ? raw(' aria-current="page"') : ''}>${icon(ic)}<span>${label}</span></a>`;
254
- // (sprint 75) the main tools as tabs, the others in a More menu (the open one is a tab too), so the strip never scrolls
255
- const more: [WorkshopTab, string, string, string][] = [
256
- ['quicksql', '/sql/quick', 'bolt', 'Quick SQL'], ['query', '/sql/query', 'filter', 'Query Builder'], ['load', '/sql/load', 'upload', 'Load Data'],
257
- ['unload', '/sql/unload', 'download', 'Unload Data'], ['sample', '/sql/sample-data', 'layers', 'Sample Data'], ['ddl', '/sql/ddl', 'file', 'DDL'],
258
- ['compare', '/sql/compare', 'layers', 'Compare'], ['ai', '/sql/ai', 'bolt', 'AI'],
259
- ];
260
- const open = more.find(([k]) => k === active);
257
+ const tab = (key: string, href: string, ic: string, label: string) =>
258
+ html`<a class="ide-tab" href="${href}"${active === key ? raw(' aria-current="page"') : ''}>${icon(ic)}<span>${label}</span></a>`;
259
+ // (sprint 75) the main tools as tabs, the others in a More menu (the open one is a tab too), so the strip never
260
+ // scrolls; (sprint 96) the More menu under the Launchpad's headings, from SQL_TOOLS
261
+ const main = SQL_TOOLS.filter((t) => t.main);
262
+ const open = SQL_TOOLS.find((t) => !t.main && t.tab === active);
261
263
  return html`<nav class="ide-tabs sw-tabs u-mb1" aria-label="SQL Workshop">
262
- ${tab('home', '/sql', 'home', 'Home')}${tab('sql', '/sql/worksheet', 'code', 'SQL Worksheet')}${tab('objects', '/sql/objects', 'database', 'Object Browser')}${tab('scripts', '/sql/scripts', 'file', 'SQL Scripts')}${tab('utilities', '/sql/utilities', 'wrench', 'Utilities')}
263
- ${open ? tab(...open) : ''}
264
+ ${tab('home', `${BASE}/sql`, 'home', 'Home')}${main.map((t) => tab(t.tab!, t.href, t.icon, t.label))}
265
+ ${open ? tab(open.tab!, open.href, open.icon, open.label) : ''}
264
266
  <details class="menu sw-more"><summary class="ide-tab">${icon('chevron-down')}<span>More</span></summary>
265
- <div class="menu-panel align-right"><div class="menu-section menu-links">${more.map(([k, href, ic, label]) => html`<a href="${BASE}${href}"${k === active ? raw(' aria-current="page"') : ''}>${icon(ic)} ${label}</a>`)}</div></div></details></nav>`;
267
+ <div class="menu-panel align-right">${TOOL_GROUPS.map(([g, heading]) => {
268
+ // administrators' tools are on the Launchpad and the Utilities page, not in every developer's menu
269
+ const list = SQL_TOOLS.filter((t) => t.group === g && !t.main && !t.admin);
270
+ return list.length ? html`<div class="menu-section menu-links"><span class="small muted">${heading}</span>${list.map((t) => html`<a href="${t.href}"${t.tab && t.tab === active ? raw(' aria-current="page"') : ''}>${icon(t.icon)} ${t.label}</a>`)}</div>` : '';
271
+ })}</div></details></nav>`;
266
272
  };
267
273
 
268
274
  /** Number of app tile colours (.app-color-0 … -7 in app.css). */
@@ -9,6 +9,7 @@ import { back, bicon, BASE, csrf, developer, flash, send, shell, workshopTabs, t
9
9
  import { clearCompletions } from './code-editor.ts';
10
10
  import { createTemplate, listObjects, OB_TYPES, pickSchema, SYSTEM_SCHEMAS, type Obj } from './object-browser.ts';
11
11
  import { objectMenu } from './object-actions.ts';
12
+ import { askedSpec, chartControls, chartSpec, drawChart } from './sql-charts.ts';
12
13
  import { currentWorkspace, inWorkspaceSql, sessionSchema, usableSchemas, workshopDb, workshopRunsAs } from './workspaces.ts';
13
14
  import { applySubstitutions, explainStatement, pickStatements, runStatements, substitutionNames, textTable, type RunMode, type WorksheetRun } from '../sqlworksheet.ts';
14
15
  import { applyBinds, bindNames } from '../binds.ts';
@@ -30,7 +31,7 @@ import { unloadStatement } from '../unload.ts';
30
31
  const NAV_TYPES: [string, string][] = [['all', 'All Objects'], ...OB_TYPES.map(([t, label]) => [t, label] as [string, string]), ['policy', 'Policies']];
31
32
  const MODES: RunMode[] = ['statement', 'script', 'explain', 'autotrace'];
32
33
  const TABS = [
33
- ['result', 'Query Result'], ['output', 'Script Output'], ['notices', 'Notices'], ['explain', 'Explain Plan'], ['autotrace', 'Autotrace'], ['history', 'SQL History'],
34
+ ['result', 'Query Result'], ['chart', 'Chart'], ['output', 'Script Output'], ['notices', 'Notices'], ['explain', 'Explain Plan'], ['autotrace', 'Autotrace'], ['history', 'SQL History'],
34
35
  ] as const;
35
36
  type Tab = (typeof TABS)[number][0];
36
37
 
@@ -150,7 +151,7 @@ async function navigatorTree(db: Awaited<ReturnType<typeof workshopDb>>, schema:
150
151
  }
151
152
 
152
153
  /** The output panes of a run (empty: the page as opened). */
153
- function outputPanes(run: WorksheetRun | null, mode: RunMode | null, history: HistoryRow[], tabAsked: string): { panes: Record<Tab, Raw>; active: Tab; status: Raw } {
154
+ function outputPanes(run: WorksheetRun | null, mode: RunMode | null, history: HistoryRow[], tabAsked: string, chart: Raw | null): { panes: Record<Tab, Raw>; active: Tab; status: Raw } {
154
155
  const results = run?.results ?? [];
155
156
  const ran = results.filter((r) => r.status !== 'not_run');
156
157
  const query = [...ran].reverse().find((r) => r.columns);
@@ -175,6 +176,7 @@ function outputPanes(run: WorksheetRun | null, mode: RunMode | null, history: Hi
175
176
  <p class="muted small ws-info">${rowsLabel(query)} · ${query.ms} ms${query.more ? html` · showing the first ${query.data!.length}` : ''} · line ${query.line}</p>
176
177
  <span class="ws-spacer"></span>
177
178
  ${query.more ? html`<button class="btn btn-sm" name="mode" value="statement_all" title="Run the statement again and show up to ${ALL_ROWS.toLocaleString('en')} rows">All rows</button>` : ''}
179
+ <button class="btn btn-sm" name="mode" value="chart" title="Create Chart (Ctrl+F11)">${icon('chart')}<span>Chart</span></button>
178
180
  <button type="button" class="btn btn-sm" data-ws-copy hidden>${icon('copy')}<span>Copy</span></button>
179
181
  ${download}
180
182
  </div>
@@ -220,7 +222,7 @@ function outputPanes(run: WorksheetRun | null, mode: RunMode | null, history: Hi
220
222
  </li>`)}</ul>`
221
223
  : html`<p class="muted">Your statements show here after you run them.</p>`;
222
224
 
223
- const active: Tab = !run ? (TABS.find(([k]) => k === tabAsked)?.[0] ?? 'result') : mode === 'explain' ? 'explain' : mode === 'autotrace' ? 'autotrace' : mode === 'script' || !query || errors.length ? 'output' : 'result';
225
+ const active: Tab = chart && run ? 'chart' : !run ? (TABS.find(([k]) => k === tabAsked)?.[0] ?? 'result') : mode === 'explain' ? 'explain' : mode === 'autotrace' ? 'autotrace' : mode === 'script' || !query || errors.length ? 'output' : 'result';
224
226
  const time = new Date().toTimeString().slice(0, 8);
225
227
  const last = !run ? 'Ready.' : errors.length
226
228
  ? `Error${errors[0].errorLine ? ` at line ${errors[0].errorLine}` : ''}: ${errors[0].error}`
@@ -231,7 +233,7 @@ function outputPanes(run: WorksheetRun | null, mode: RunMode | null, history: Hi
231
233
  <span class="ws-count ${warnings ? 'is-warn' : ''}" title="Warnings">${icon('alert')} ${warnings}</span>
232
234
  <span class="ws-busy" hidden>${icon('refresh')} Running…</span>
233
235
  <span class="ws-last">${last}</span><span class="muted ws-time">${run ? time : ''}</span>`;
234
- return { panes: { result, output: log, notices, explain: planOf('explain', 'Explain Plan (F10) shows how PostgreSQL would run the statement at the cursor; nothing runs.'), autotrace: planOf('autotrace', 'Autotrace (F6) runs the statement at the cursor with EXPLAIN ANALYZE and rolls it back: actual rows, time and buffers per step.'), history: hist }, active, status };
236
+ return { panes: { result, chart: chart ?? html`<p class="muted">Create Chart (Ctrl+F11) draws the last query: a label column and number columns.</p>`, output: log, notices, explain: planOf('explain', 'Explain Plan (F10) shows how PostgreSQL would run the statement at the cursor; nothing runs.'), autotrace: planOf('autotrace', 'Autotrace (F6) runs the statement at the cursor with EXPLAIN ANALYZE and rolls it back: actual rows, time and buffers per step.'), history: hist }, active, status };
235
237
  }
236
238
 
237
239
  /** The applications whose database role a run can take (the developer's workspace). */
@@ -347,6 +349,7 @@ async function worksheet(req: Req, reply: FastifyReply) {
347
349
  const continueOnError = body.continue_on_error === 'on';
348
350
  // (sprint 93, DA-9) bind variables (:NAME) and substitution variables (&NAME.) asked for before a run
349
351
  let askValues: { binds: string[]; subs: string[]; mode: string } | null = null;
352
+ let chartAsked = false;
350
353
  const remembered: Record<string, string> = (() => {
351
354
  try {
352
355
  const v = JSON.parse(s.state.__WSBINDS ?? '{}');
@@ -358,6 +361,7 @@ async function worksheet(req: Req, reply: FastifyReply) {
358
361
  if (isPost && text.trim() && !['nav', 'save', 'save_as', 'rename', 'download'].includes(String(body.mode ?? ''))) {
359
362
  const m: RunMode = MODES.find((x) => x === body.mode) ?? 'statement';
360
363
  const allRows = body.mode === 'statement_all';
364
+ chartAsked = body.mode === 'chart';
361
365
  mode = m;
362
366
  const sel = /^\d{1,9}$/.test(String(body.sel_start ?? '')) && /^\d{1,9}$/.test(String(body.sel_end ?? '')) ? { start: Number(body.sel_start), end: Number(body.sel_end) } : undefined;
363
367
  const picked = pickStatements(text, m, sel);
@@ -410,8 +414,25 @@ async function worksheet(req: Req, reply: FastifyReply) {
410
414
  const history = (await owner.query<HistoryRow>(
411
415
  `select id, sql, status, rows, ms, error, runs, at from meta.sql_history where username = $1 and workspace_id = $2 order by id desc limit $3`,
412
416
  [s.username, currentWorkspace(s), HISTORY_SHOWN])).rows;
413
- const { panes, active, status } = outputPanes(run, mode, history, String(req.query?.tab ?? ''));
414
417
  const lastQuery = [...(run?.results ?? [])].reverse().find((r) => r.status === 'ok' && r.columns);
418
+ // (sprint 97, DA-6) Create Chart: the last query drawn, its settings and Save
419
+ let chartPane: Raw | null = null;
420
+ let chartCss = '';
421
+ if (chartAsked && lastQuery?.columns) {
422
+ const spec = chartSpec(lastQuery.columns, lastQuery.numeric ?? [], askedSpec(body));
423
+ if (!spec) chartPane = html`<p class="muted">Nothing to draw: the query needs a number column next to its labels.</p>`;
424
+ else {
425
+ const d = drawChart('', lastQuery.columns, lastQuery.data ?? [], spec);
426
+ chartCss = d.css;
427
+ chartPane = html`${chartControls(lastQuery.columns, spec)}
428
+ <div class="ch-actions"><button class="btn btn-sm" name="mode" value="chart">${icon('refresh')} Draw again</button>
429
+ <input type="hidden" name="chart_sql" value="${lastQuery.sql}">
430
+ <label class="sr-only" for="ws-chart-name">Chart name</label><input id="ws-chart-name" name="chart_name" placeholder="Chart name" maxlength="120">
431
+ <button class="btn btn-sm btn-hot" formaction="${BASE}/sql/charts">${icon('save')} Save chart</button></div>
432
+ <div class="ch-view">${d.body}</div>`;
433
+ }
434
+ }
435
+ const { panes, active, status } = outputPanes(run, mode, history, String(req.query?.tab ?? ''), chartPane);
415
436
  const downloadable = (() => {
416
437
  try {
417
438
  return lastQuery ? unloadStatement(lastQuery.sql) : null;
@@ -433,7 +454,7 @@ async function worksheet(req: Req, reply: FastifyReply) {
433
454
 
434
455
  const toolBtn = (m: RunMode, ic: string, label: string, key: string, hot = false) =>
435
456
  html`<button class="btn btn-sm${hot ? ' btn-hot' : ''}" name="mode" value="${m}" title="${label} (${key})" data-ws-key="${key}">${icon(ic)}<span>${label}</span></button>`;
436
- const main = html`<div class="ws-page">
457
+ const main = html`${chartCss ? html`<style nonce="${req.cspNonce}">${raw(chartCss.replace(/</g, ''))}</style>` : ''}<div class="ws-page">
437
458
  <h1 class="sr-only">SQL Worksheet</h1>
438
459
  ${workshopTabs('sql')}
439
460
  <form method="post" action="${BASE}/sql/worksheet" class="ws" id="ws" data-ran="${ran ? `${ran.start},${ran.end}` : ''}" data-error-line="${firstError?.errorLine ?? ''}"
@@ -479,7 +500,9 @@ async function worksheet(req: Req, reply: FastifyReply) {
479
500
  ${script ? html`<button class="btn btn-sm" name="mode" value="save_as">Save as new</button><button class="btn btn-sm" name="mode" value="rename">Rename</button>` : ''}
480
501
  <button class="btn btn-sm" name="mode" value="download">${icon('download')} Download</button>
481
502
  </div>
482
- <div class="ws-file-actions"><a class="btn btn-sm" href="${BASE}/sql/worksheet?new=1" data-ws-new>${icon('plus')} New</a><a class="btn btn-sm" href="#ws-files" data-ws-open>Open…</a></div>
503
+ <div class="ws-file-actions"><a class="btn btn-sm" href="${BASE}/sql/worksheet?new=1" data-ws-new>${icon('plus')} New</a><a class="btn btn-sm" href="#ws-files" data-ws-open>Open…</a>
504
+ ${script ? html`<input type="hidden" name="pin_href" value="${BASE}/sql/worksheet?script=${script.id}"><input type="hidden" name="pin_label" value="${script.name}"><input type="hidden" name="back" value="${BASE}/sql/worksheet?script=${script.id}">
505
+ <button class="btn btn-sm" formaction="${BASE}/pins" title="Pin this worksheet to the SQL Workshop home">${icon('pin')} Pin</button>` : ''}</div>
483
506
  </div></div></details>
484
507
  ${toolBtn('statement', 'play', 'Run Statement', 'Ctrl+Enter', true)}
485
508
  ${toolBtn('script', 'file', 'Run Script', 'F5')}
@@ -28,12 +28,13 @@ export const DEFAULT_WORKSPACE = 1;
28
28
  /** The workspaces a developer may use (administrators: all), and the current one, on the session object. */
29
29
  export async function loadWorkspaces(s: Session) {
30
30
  const rows = (
31
- await owner.query<WorkspaceRef>(
31
+ await owner.query<WorkspaceRef & { instance_admin?: boolean }>(
32
32
  `select w.id, w.name, w.message, w.banner_label, w.banner_color, w.isolated, w.sql_workshop, w.rest_services,
33
33
  coalesce((select json_agg(json_build_object('label', l.label, 'url', l.url, 'description', l.description) order by l.position, lower(l.label))
34
34
  from meta.workspace_link l where l.workspace_id = w.id), '[]') as links,
35
35
  exists (select 1 from meta.developer d where d.username = $1 and d.is_admin)
36
- or exists (select 1 from meta.workspace_member m where m.workspace_id = w.id and m.username = $1 and m.is_admin) as admin
36
+ or exists (select 1 from meta.workspace_member m where m.workspace_id = w.id and m.username = $1 and m.is_admin) as admin,
37
+ exists (select 1 from meta.developer d where d.username = $1 and d.is_admin) as instance_admin
37
38
  from meta.workspace w
38
39
  where exists (select 1 from meta.developer d where d.username = $1 and d.is_admin)
39
40
  or exists (select 1 from meta.workspace_member m where m.workspace_id = w.id and m.username = $1)
@@ -42,6 +43,7 @@ export async function loadWorkspaces(s: Session) {
42
43
  )
43
44
  ).rows;
44
45
  s.workspaces = rows;
46
+ s.instanceAdmin = !!rows[0]?.instance_admin;
45
47
  const wanted = Number(s.state.__WS);
46
48
  s.workspace = rows.find((w) => w.id === wanted) ?? rows[0] ?? null;
47
49
  }
@@ -72,7 +72,9 @@ export const KEPT = ['app_access', 'api_client', 'session', 'sso_pending', 'save
72
72
  // (081) the mail service an application sends through, and its queued and sent mail
73
73
  'app_mail_service', 'mail_queue',
74
74
  // (086) users' preferences
75
- 'user_preference'];
75
+ 'user_preference',
76
+ // (113) AutoREST: the tables and views the application publishes (SQL Workshop › RESTful Services)
77
+ 'rest_object'];
76
78
  /** Children of pages (replaced with their page). */
77
79
  const PAGE_CHILDREN = ['region', 'item', 'button', 'dynamic_action', 'validation', 'process', 'computation', 'branch'];
78
80
  /** References into replaced tables from kept data, repointed below: "table.column". */