pgkiln 0.46.0 → 0.47.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.
@@ -0,0 +1,219 @@
1
+ import type { FastifyInstance, FastifyReply } from 'fastify';
2
+ import { esc, html, raw, type Raw } from '../html.ts';
3
+ import { icon } from '../icons.ts';
4
+ import { clientIp, logActivity, type Session } from '../session.ts';
5
+ import { back, BASE, csrf, developer, flash, send, shell, workshopTabs, type Body, type Req } from './ui.ts';
6
+ import { SYSTEM_SCHEMAS } from './object-browser.ts';
7
+ import { clearCompletions } from './code-editor.ts';
8
+ import { usableSchemas, workshopDb } from './workspaces.ts';
9
+ import { checkTable, emptyTable, ON_DELETE, readTable, tableSql, type ColumnDef, type TableDef } from '../tabledef.ts';
10
+
11
+ // (sprint 95, Database Actions DA-8: Table Properties) Create a table, edit one, or start from one as a template, in
12
+ // a form: columns (name, type, not null, default, identity, comment, primary key), unique keys, foreign keys,
13
+ // checks, indexes, the comment, row level security and, for a new table, partitioning. Preview DDL shows the exact
14
+ // CREATE or ALTER statements; Apply runs them in one transaction with the workshop's connection (nothing is
15
+ // changed when one fails); Open in the worksheet puts them there to review. The form posts the whole definition;
16
+ // readTable() gives the table as it is now, so the statements are always made against the catalog, not against
17
+ // what the page showed. Without JavaScript the sections stack and spare empty rows add columns and keys.
18
+
19
+ const SPARE = 3; // empty rows for new columns, keys, checks and indexes (the page adds more with script)
20
+ const TYPES = ['bigint', 'integer', 'smallint', 'numeric', 'numeric(12,2)', 'real', 'double precision', 'text', 'varchar(200)', 'char(1)', 'boolean', 'date',
21
+ 'timestamp', 'timestamptz', 'time', 'interval', 'uuid', 'jsonb', 'json', 'bytea', 'inet', 'text[]', 'integer[]'];
22
+
23
+ const rows = (b: Body, prefix: string) => {
24
+ const ix = [...new Set(Object.keys(b).map((k) => new RegExp(`^${prefix}\\.(\\d{1,3})\\.`).exec(k)?.[1]).filter((x): x is string => !!x))].map(Number).sort((x, y) => x - y);
25
+ return ix.map((i) => (f: string) => String(b[`${prefix}.${i}.${f}`] ?? '').trim());
26
+ };
27
+
28
+ /** The definition the form posted; generated expressions and indexes the form can't edit come from the table as it is. */
29
+ function fromForm(b: Body, schema: string, old: TableDef | null): TableDef {
30
+ const def = emptyTable(schema);
31
+ def.name = String(b.name ?? '').trim();
32
+ def.comment = String(b.comment ?? '').trim();
33
+ def.pkName = String(b.pk_name ?? '').trim();
34
+ def.rls = b.rls === 'on';
35
+ def.forceRls = b.force_rls === 'on';
36
+ if (!old) {
37
+ def.partitionBy = (['range', 'list', 'hash'] as const).find((x) => x === b.part_by) ?? '';
38
+ def.partitionKey = def.partitionBy ? String(b.part_key ?? '').trim() : '';
39
+ }
40
+ for (const r of rows(b, 'col')) {
41
+ if (r('drop') === 'on' || (!r('name') && !r('type'))) continue;
42
+ const orig = old?.columns.find((c) => c.orig === r('orig')) ? r('orig') : '';
43
+ const was = old?.columns.find((c) => c.orig === orig);
44
+ const identity = r('identity') === 'always' || r('identity') === 'by default' ? (r('identity') as ColumnDef['identity']) : '';
45
+ def.columns.push({ name: r('name'), orig, type: r('type'), notNull: r('nn') === 'on', default: r('def'), identity, generated: was?.generated ?? '', comment: r('comment'), pk: r('pk') === 'on' });
46
+ }
47
+ for (const r of rows(b, 'uk')) if (r('drop') !== 'on' && (r('name') || r('cols'))) def.uniques.push({ name: r('name'), columns: r('cols') });
48
+ for (const r of rows(b, 'fk')) if (r('drop') !== 'on' && (r('name') || r('cols'))) def.fks.push({ name: r('name'), columns: r('cols'), refTable: r('ref'), refColumns: r('refcols'), onDelete: r('del') || 'no action' });
49
+ for (const r of rows(b, 'ck')) if (r('drop') !== 'on' && (r('name') || r('expr'))) def.checks.push({ name: r('name'), expr: r('expr') });
50
+ for (const r of rows(b, 'ix')) if (r('drop') !== 'on' && (r('name') || r('cols'))) def.indexes.push({ name: r('name'), columns: r('cols'), unique: r('unique') === 'on' });
51
+ // indexes the form can't express stay as they are
52
+ for (const i of old?.indexes ?? []) if (i.fixed) def.indexes.push(i);
53
+ return def;
54
+ }
55
+
56
+ /** A copy of a table under a new name, for "Use as template": new names for its constraints and indexes too. */
57
+ function asTemplate(t: TableDef): TableDef {
58
+ const name = `${t.name}_copy`.slice(0, 63);
59
+ const rename = (n: string) => (n.includes(t.name) ? n.replace(t.name, name) : `${n}_copy`).slice(0, 63);
60
+ return {
61
+ ...structuredClone(t), name, pkName: t.pkName ? rename(t.pkName) : '',
62
+ columns: t.columns.map((c) => ({ ...c, orig: '' })),
63
+ uniques: t.uniques.map((k) => ({ ...k, name: rename(k.name) })),
64
+ fks: t.fks.map((k) => ({ ...k, name: rename(k.name) })),
65
+ checks: t.checks.map((k) => ({ ...k, name: rename(k.name) })),
66
+ indexes: t.indexes.filter((i) => !i.fixed).map((i) => ({ ...i, name: rename(i.name) })),
67
+ };
68
+ }
69
+
70
+ async function tables(db: Awaited<ReturnType<typeof workshopDb>>) {
71
+ return (await db.query<{ q: string }>(
72
+ `select c.oid::regclass::text as q from pg_class c join pg_namespace n on n.oid = c.relnamespace
73
+ where c.relkind in ('r', 'p') and not c.relispartition and n.nspname !~ '^pg_' and n.nspname <> 'information_schema' and not (n.nspname = any ($1))
74
+ and has_schema_privilege(n.oid, 'USAGE') order by 1 limit 2000`, [SYSTEM_SCHEMAS])).rows.map((r) => r.q);
75
+ }
76
+
77
+ function form(s: Session, def: TableDef, opts: { editing: TableDef | null; schemas: string[]; refTables: string[]; ddl: string[]; errors: string[]; output: Raw | ''; tab: string; ref: string }): Raw {
78
+ const { editing } = opts;
79
+ const chk = (on: boolean) => (on ? raw(' checked') : '');
80
+ const sel = (v: string, cur: string) => (v === cur ? raw(' selected') : '');
81
+ const typeList = html`<datalist id="te-types">${TYPES.map((t) => html`<option value="${t}">`)}</datalist>`;
82
+ const colRow = (c: ColumnDef | null, i: number) => html`<tr class="te-row${c ? '' : ' te-spare'}">
83
+ <td class="te-move"><button type="button" class="btn btn-sm btn-icon" data-te-up title="Move up" hidden>↑</button><button type="button" class="btn btn-sm btn-icon" data-te-down title="Move down" hidden>↓</button></td>
84
+ <td><input type="hidden" name="col.${i}.orig" value="${c?.orig ?? ''}"><input id="te-c${i}-name" aria-label="Name" name="col.${i}.name" value="${c?.name ?? ''}" autocomplete="off" spellcheck="false"></td>
85
+ <td><input id="te-c${i}-type" aria-label="Type" name="col.${i}.type" value="${c?.type ?? ''}" list="te-types" autocomplete="off" spellcheck="false"></td>
86
+ <td class="te-c"><input type="checkbox" aria-label="Primary key" id="te-c${i}-pk" name="col.${i}.pk"${chk(!!c?.pk)}></td>
87
+ <td class="te-c"><input type="checkbox" aria-label="Not null" id="te-c${i}-nn" name="col.${i}.nn"${chk(!!c?.notNull)}></td>
88
+ <td>${c?.generated
89
+ ? html`<code class="te-generated" title="Generated (stored)">= ${c.generated}</code><input type="hidden" name="col.${i}.def" value="">`
90
+ : html`<input id="te-c${i}-def" aria-label="Default" name="col.${i}.def" value="${c?.default ?? ''}" class="code" spellcheck="false">`}</td>
91
+ <td><select id="te-c${i}-id" aria-label="Identity" name="col.${i}.identity"><option value="">-</option><option value="by default"${sel('by default', c?.identity ?? '')}>by default</option><option value="always"${sel('always', c?.identity ?? '')}>always</option></select></td>
92
+ <td><input id="te-c${i}-cm" aria-label="Comment" name="col.${i}.comment" value="${c?.comment ?? ''}"></td>
93
+ <td class="te-c"><label class="te-drop"><input type="checkbox" name="col.${i}.drop"><span>Remove</span></label></td></tr>`;
94
+ const simpleRows = <T,>(list: T[], prefix: string, cells: (x: T | null, i: number) => Raw) =>
95
+ [...list, ...Array<null>(SPARE).fill(null)].map((x, i) => html`<tr class="te-row${x ? '' : ' te-spare'}">${cells(x, i)}<td class="te-c"><label class="te-drop"><input type="checkbox" name="${prefix}.${i}.drop"><span>Remove</span></label></td></tr>`);
96
+ // extra: attributes written by this file (constants, or values escaped with esc())
97
+ const input = (name: string, value: string, label: string, extra = '') => html`<input id="te-${name}" aria-label="${label}" name="${name}" value="${value}" autocomplete="off" spellcheck="false"${extra ? raw(` ${extra}`) : ''}>`;
98
+ const sections: [string, string, Raw][] = [
99
+ ['columns', 'Columns', html`<div class="table-wrap"><table class="report te-table" data-te-grid="col"><thead><tr><th></th><th>Name</th><th>Type</th><th>Key</th><th>Not null</th><th>Default</th><th>Identity</th><th>Comment</th><th></th></tr></thead>
100
+ <tbody>${[...def.columns, ...Array<null>(SPARE).fill(null)].map((c, i) => colRow(c, i))}</tbody></table></div>
101
+ <div class="buttons"><button type="button" class="btn btn-sm" data-te-add="col" hidden>${icon('plus')} Add column</button></div>
102
+ <div class="field u-mt1"><label class="label" for="te-pk_name">Primary key name</label>${input('pk_name', def.pkName, 'Primary key name', `placeholder="${esc(def.name ? `${def.name}_pkey` : 'table_pkey')}"`)}<small class="help">Tick Key on the columns of the primary key.</small></div>`],
103
+ ['keys', 'Unique keys', html`<div class="table-wrap"><table class="report te-table" data-te-grid="uk"><thead><tr><th>Name</th><th>Columns (comma-separated)</th><th></th></tr></thead><tbody>
104
+ ${simpleRows(def.uniques, 'uk', (k, i) => html`<td>${input(`uk.${i}.name`, k?.name ?? '', 'Name')}</td><td>${input(`uk.${i}.cols`, k?.columns ?? '', 'Columns')}</td>`)}</tbody></table></div>`],
105
+ ['fks', 'Foreign keys', html`<div class="table-wrap"><table class="report te-table" data-te-grid="fk"><thead><tr><th>Name</th><th>Columns</th><th>References</th><th>Their columns</th><th>On delete</th><th></th></tr></thead><tbody>
106
+ ${simpleRows(def.fks, 'fk', (f, i) => html`<td>${input(`fk.${i}.name`, f?.name ?? '', 'Name')}</td><td>${input(`fk.${i}.cols`, f?.columns ?? '', 'Columns')}</td>
107
+ <td><select id="te-fk${i}-ref" aria-label="References" name="fk.${i}.ref"><option value="">-</option>${opts.refTables.map((t) => html`<option value="${t}"${sel(t, f?.refTable ?? '')}>${t}</option>`)}</select></td>
108
+ <td>${input(`fk.${i}.refcols`, f?.refColumns ?? '', 'Their columns', 'placeholder="its primary key"')}</td>
109
+ <td><select id="te-fk${i}-del" aria-label="On delete" name="fk.${i}.del">${ON_DELETE.map((d) => html`<option value="${d}"${sel(d, f?.onDelete ?? 'no action')}>${d}</option>`)}</select></td>`)}</tbody></table></div>`],
110
+ ['checks', 'Checks', html`<div class="table-wrap"><table class="report te-table" data-te-grid="ck"><thead><tr><th>Name</th><th>Condition</th><th></th></tr></thead><tbody>
111
+ ${simpleRows(def.checks, 'ck', (k, i) => html`<td>${input(`ck.${i}.name`, k?.name ?? '', 'Name')}</td><td>${input(`ck.${i}.expr`, k?.expr ?? '', 'Condition', 'class="code" placeholder="salary &gt;= 0"')}</td>`)}</tbody></table></div>`],
112
+ ['indexes', 'Indexes', html`<div class="table-wrap"><table class="report te-table" data-te-grid="ix"><thead><tr><th>Name</th><th>Columns or expressions</th><th>Unique</th><th></th></tr></thead><tbody>
113
+ ${simpleRows(def.indexes.filter((i) => !i.fixed), 'ix', (x, i) => html`<td>${input(`ix.${i}.name`, x?.name ?? '', 'Name')}</td><td>${input(`ix.${i}.cols`, x?.columns ?? '', 'Columns', 'class="code" placeholder="lower(name), created_at desc"')}</td>
114
+ <td class="te-c"><input type="checkbox" aria-label="Unique" id="te-ix${i}-u" name="ix.${i}.unique"${chk(!!x?.unique)}></td>`)}</tbody></table></div>
115
+ ${def.indexes.some((i) => i.fixed) ? html`<p class="muted small">Kept as they are (another method or a condition): ${def.indexes.filter((i) => i.fixed).map((i) => html`<code>${i.fixed}</code> `)}</p>` : ''}`],
116
+ ['more', 'Comment and security', html`<div class="field"><label class="label" for="te-comment">Comment</label><textarea id="te-comment" name="comment" rows="2">${def.comment}</textarea></div>
117
+ <label class="check"><input type="checkbox" name="rls"${chk(def.rls)}> Row level security: rows only through policies</label>
118
+ <label class="check"><input type="checkbox" name="force_rls"${chk(def.forceRls)}> Force it for the table's owner too</label>
119
+ ${editing ? '' : html`<fieldset class="prop-group u-mt1"><legend>Partitioning</legend><div class="form-grid">
120
+ <div class="field"><label class="label" for="te-part_by">Partition by</label><select id="te-part_by" name="part_by"><option value="">Not partitioned</option>${(['range', 'list', 'hash'] as const).map((p) => html`<option value="${p}"${sel(p, def.partitionBy)}>${p}</option>`)}</select></div>
121
+ <div class="field"><label class="label" for="te-part_key">Key</label>${input('part_key', def.partitionKey, 'Key', 'class="code" placeholder="created_at"')}</div></div>
122
+ <small class="help">The partitions themselves are tables made with CREATE TABLE … PARTITION OF in the worksheet.</small></fieldset>`}`],
123
+ ['ddl', 'DDL', opts.ddl.length ? html`<pre class="source te-ddl">${opts.ddl.join('\n\n')}</pre>` : html`<p class="muted">${editing ? 'No changes yet: the table is as the form shows it.' : 'Fill in the table and press Preview DDL.'}</p>`],
124
+ ['output', 'Output', opts.output || html`<p class="muted">Apply shows what happened here.</p>`],
125
+ ];
126
+ const tab = sections.some(([k]) => k === opts.tab) ? opts.tab : opts.errors.length ? 'columns' : 'columns';
127
+ return html`<form method="post" action="${BASE}/sql/objects/table" class="te" data-te-tab="${tab}">${csrf(s)}
128
+ <input type="hidden" name="ref" value="${opts.ref}">${typeList}
129
+ ${opts.errors.length ? html`<div class="alert alert-error" role="alert"><ul>${opts.errors.map((e) => html`<li>${e}</li>`)}</ul></div>` : ''}
130
+ <div class="form-grid te-head">
131
+ <div class="field"><label class="label" for="te-schema">Schema</label>${editing
132
+ ? html`<input id="te-schema" value="${def.schema}" readonly><input type="hidden" name="schema" value="${def.schema}">`
133
+ : html`<select id="te-schema" name="schema">${opts.schemas.map((n) => html`<option value="${n}"${sel(n, def.schema)}>${n}</option>`)}</select>`}</div>
134
+ <div class="field"><label class="label" for="te-name">Table name</label><input id="te-name" name="name" value="${def.name}" required autocomplete="off" spellcheck="false"></div>
135
+ </div>
136
+ <nav class="te-tabs" role="tablist" aria-label="Table properties" hidden>${sections.map(([k, label]) => html`<button type="button" role="tab" aria-selected="${k === tab ? 'true' : 'false'}" data-te-show="${k}">${label}</button>`)}</nav>
137
+ ${sections.map(([k, label, body]) => html`<section class="te-section" data-te-section="${k}"><h2>${label}</h2>${body}</section>`)}
138
+ <div class="buttons te-buttons">
139
+ <button class="btn" name="mode" value="preview">${icon('eye')} Preview DDL</button>
140
+ <button class="btn btn-hot" name="mode" value="apply">${icon('check')} Apply</button>
141
+ <button class="btn" name="mode" value="worksheet">${icon('code')} Open in the worksheet</button>
142
+ <a class="btn" href="${editing ? `${BASE}/sql/objects?${new URLSearchParams({ schema: def.schema, k: 'table', o: opts.ref })}` : `${BASE}/sql/objects?${new URLSearchParams({ schema: def.schema })}`}">Cancel</a>
143
+ </div></form>`;
144
+ }
145
+
146
+ async function page(req: Req, reply: FastifyReply) {
147
+ const s = await developer(req, reply);
148
+ if (!s) return;
149
+ const db = await workshopDb(s);
150
+ const isPost = req.method === 'POST';
151
+ const b: Body = isPost ? (req.body ?? {}) : (req.query ?? {});
152
+ const schemas = (await usableSchemas(db)).filter((n) => !SYSTEM_SCHEMAS.includes(n));
153
+ const refTables = await tables(db);
154
+ // the table being edited, read again from the catalog on every request
155
+ const ref = String((isPost ? b.ref : b.o) ?? '');
156
+ const editing = ref ? await readTable(db, ref) : null;
157
+ if (ref && (!editing || SYSTEM_SCHEMAS.includes(editing.schema) || !schemas.includes(editing.schema))) return reply.code(404).send('Not found');
158
+ let def: TableDef;
159
+ let errors: string[] = [];
160
+ let ddl: string[] = [];
161
+ let output: Raw | '' = '';
162
+ let tab = 'columns';
163
+ if (!isPost) {
164
+ const template = typeof b.template === 'string' && b.template ? await readTable(db, b.template) : null;
165
+ if (b.template && (!template || !schemas.includes(template.schema))) return reply.code(404).send('Not found');
166
+ def = editing ?? (template ? asTemplate(template) : emptyTable(schemas.includes(String(b.schema)) ? String(b.schema) : (schemas[0] ?? 'public')));
167
+ } else {
168
+ const schema = editing ? editing.schema : String(b.schema ?? '');
169
+ if (!schemas.includes(schema)) return reply.code(404).send('Not found');
170
+ def = fromForm(b, schema, editing);
171
+ errors = checkTable(def, editing);
172
+ if (!errors.length) ddl = tableSql(def, editing);
173
+ tab = 'ddl';
174
+ if (!errors.length && b.mode === 'worksheet') {
175
+ s.state.__SQLDRAFT = ddl.join('\n\n') || '-- no changes';
176
+ flash(s, 'Review the statements and run them: nothing has been changed yet.');
177
+ return back(reply, s, `${BASE}/sql/worksheet`);
178
+ }
179
+ if (!errors.length && b.mode === 'apply' && ddl.length) {
180
+ const c = await db.pool.connect();
181
+ let failed = '';
182
+ let at = '';
183
+ try {
184
+ await c.query('begin');
185
+ for (const x of ddl) {
186
+ at = x;
187
+ await c.query(x);
188
+ }
189
+ await c.query('commit');
190
+ } catch (e) {
191
+ failed = (e as Error).message;
192
+ await c.query('rollback').catch(() => {});
193
+ } finally {
194
+ c.release(true);
195
+ }
196
+ await logActivity({ username: s.username, event: 'sql_command', ip: clientIp(req), detail: ddl.join(';\n').slice(0, 2000) });
197
+ clearCompletions();
198
+ if (!failed) {
199
+ flash(s, `${editing ? 'Changed' : 'Created'} ${def.schema}.${def.name}: ${ddl.length} statement${ddl.length === 1 ? '' : 's'}.`);
200
+ const now = await readTable(db, `${pgIdent(def.schema)}.${pgIdent(def.name)}`);
201
+ return back(reply, s, `${BASE}/sql/objects/table?${new URLSearchParams({ o: now ? `${pgIdent(now.schema)}.${pgIdent(now.name)}` : '' })}`);
202
+ }
203
+ output = html`<div class="alert alert-error" role="alert"><p><strong>Nothing was changed</strong>: the statements ran in one transaction, which was rolled back.</p><p>${failed}</p><pre class="source">${at}</pre></div>`;
204
+ tab = 'output';
205
+ }
206
+ if (errors.length) tab = 'columns';
207
+ }
208
+ const title = editing ? `Table ${editing.schema}.${editing.name}` : 'Create table';
209
+ const main = html`<h1 class="u-mb1">${title}</h1>${workshopTabs('objects')}
210
+ ${form(s, def, { editing, schemas, refTables, ddl, errors, output, tab, ref: editing ? ref : '' })}`;
211
+ return send(reply, s, shell(s, title, [['SQL Workshop', `${BASE}/sql`], ['Object Browser', `${BASE}/sql/objects`], [title]], main, 'sql'));
212
+ }
213
+
214
+ const pgIdent = (n: string) => (/^[a-z_][a-z0-9_$]*$/.test(n) ? n : `"${n.replace(/"/g, '""')}"`);
215
+
216
+ export async function tableEditorRoutes(app: FastifyInstance) {
217
+ app.get(`${BASE}/sql/objects/table`, page);
218
+ app.post(`${BASE}/sql/objects/table`, page);
219
+ }
package/src/builder/ui.ts CHANGED
@@ -51,8 +51,8 @@ export type Section = 'apps' | 'users' | 'sql' | 'developers' | 'gallery';
51
51
  // Builder-only stylesheets and scripts, loaded on every builder page after
52
52
  // app.css / app.js (the runtime never loads them). Add new builder assets
53
53
  // here, and only here.
54
- export const BUILDER_STYLES = ['/static/builder.css', '/static/code-editor.css'];
55
- export const BUILDER_SCRIPTS = ['/static/builder.js', '/static/code-editor.js'];
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'];
56
56
  export const builderHead = () =>
57
57
  html`${BUILDER_STYLES.map((href) => html`<link rel="stylesheet" href="${href}">\n`)}${BUILDER_SCRIPTS.map((src) => html`<script src="${src}" defer></script>\n`)}`;
58
58
 
@@ -259,7 +259,7 @@ export const workshopTabs = (active: WorkshopTab) => {
259
259
  ];
260
260
  const open = more.find(([k]) => k === active);
261
261
  return html`<nav class="ide-tabs sw-tabs u-mb1" aria-label="SQL Workshop">
262
- ${tab('home', '/sql', 'home', 'Home')}${tab('objects', '/sql/objects', 'database', 'Object Browser')}${tab('sql', '/sql/commands', 'code', 'SQL Commands')}${tab('scripts', '/sql/scripts', 'file', 'SQL Scripts')}${tab('utilities', '/sql/utilities', 'wrench', 'Utilities')}
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
263
  ${open ? tab(...open) : ''}
264
264
  <details class="menu sw-more"><summary class="ide-tab">${icon('chevron-down')}<span>More</span></summary>
265
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>`;
@@ -21,12 +21,12 @@ import {
21
21
  type UnloadOptions,
22
22
  } from '../unload.ts';
23
23
  import { BASE, csrf, developer, region, send, shell, workshopTabs, type Body, type Req } from './ui.ts';
24
- import { workshopDb } from './workspaces.ts';
24
+ import { sessionSchema, workshopDb } from './workspaces.ts';
25
25
 
26
26
  // SQL Workshop → Unload Data (APEX: Data Workshop → Unload Data): a table or
27
27
  // view (chosen columns, an optional WHERE and ORDER BY) or a query, to CSV,
28
28
  // JSON, Excel or XML, streamed from a cursor (src/unload.ts). Runs on a
29
- // connection of its own as the owner, like SQL Commands, but in a READ ONLY
29
+ // connection of its own as the owner, like the SQL Worksheet, but in a READ ONLY
30
30
  // transaction with a statement timeout; the connection is closed afterwards
31
31
  // so nothing the query sets can leak into the pool. The table step is a GET
32
32
  // form and the download a POST form (CSRF), so it works without JavaScript.
@@ -100,7 +100,7 @@ export async function unloadRoutes(app: FastifyInstance) {
100
100
  return html`
101
101
  <fieldset class="prop-group u-mt125"><legend>Format</legend>
102
102
  <div class="unload-formats" role="radiogroup" aria-label="Format">${UNLOAD_FORMATS.map(
103
- (f) => html`<label class="check"><input type="radio" name="format" value="${f}"${fmt === f ? raw(' checked') : ''}> ${{ csv: 'CSV', json: 'JSON', xlsx: 'Excel (.xlsx)', xml: 'XML' }[f]}</label>`,
103
+ (f) => html`<label class="check"><input type="radio" name="format" value="${f}"${fmt === f ? raw(' checked') : ''}> ${{ csv: 'CSV', json: 'JSON', xlsx: 'Excel (.xlsx)', xml: 'XML', sql: 'SQL inserts' }[f]}</label>`,
104
104
  )}</div>
105
105
  </fieldset>
106
106
  <fieldset class="prop-group"><legend>CSV</legend><div class="form-grid">
@@ -213,11 +213,15 @@ export async function unloadRoutes(app: FastifyInstance) {
213
213
  if (typeof opts === 'string') return refuse(opts);
214
214
 
215
215
  const started = performance.now();
216
- const c = await (await workshopDb(s)).pool.connect();
216
+ const db = await workshopDb(s);
217
+ const schema = await sessionSchema(s, db);
218
+ const c = await db.pool.connect();
217
219
  let opened;
218
220
  try {
219
221
  await c.query('begin transaction read only');
220
222
  await c.query(`select set_config('statement_timeout', $1, true)`, [timeout()]);
223
+ // (sprint 92) the session's schema first in the search path, like the SQL Worksheet (its downloads come here)
224
+ if (schema) await c.query(`select set_config('search_path', $1, true)`, [`${pg.escapeIdentifier(schema)}, public`]);
221
225
  opened = await openUnload(c, sql, opts);
222
226
  } catch (e) {
223
227
  await c.query('rollback').catch(() => {});