pgkiln 0.36.0 → 0.37.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.
- package/SECURITY.md +11 -9
- package/db/migrations/090_workspace_isolation.sql +512 -0
- package/docs/apex-feature-parity.md +6 -6
- package/docs/guide/03-builder.md +49 -6
- package/docs/guide/12-development.md +6 -2
- package/examples/hr/hr_40_sample_data.sql +1 -1
- package/package.json +1 -1
- package/src/app.ts +18 -0
- package/src/automations.ts +3 -2
- package/src/builder/advisor.ts +11 -5
- package/src/builder/ai-builder.ts +27 -18
- package/src/builder/blueprints.ts +7 -7
- package/src/builder/code-editor.ts +3 -2
- package/src/builder/dataload.ts +24 -14
- package/src/builder/ddl-tools.ts +7 -4
- package/src/builder/documents.ts +2 -1
- package/src/builder/home.ts +6 -2
- package/src/builder/newapp.ts +21 -3
- package/src/builder/quicksql.ts +3 -2
- package/src/builder/report-settings.ts +4 -2
- package/src/builder/routes.ts +2 -2
- package/src/builder/sampledata.ts +22 -18
- package/src/builder/scripts.ts +27 -16
- package/src/builder/sql.ts +15 -10
- package/src/builder/unload.ts +3 -2
- package/src/builder/workspaces.ts +175 -15
- package/src/db.ts +36 -6
- package/src/ddl.ts +2 -1
- package/src/debug.ts +3 -2
- package/src/migrate.ts +3 -0
- package/src/process-jobs.ts +7 -2
- package/src/restsync.ts +3 -2
- package/src/runtime/routes.ts +4 -1
- package/src/sampledata.ts +3 -3
- package/src/session.ts +5 -2
- package/src/websources.ts +3 -1
- package/src/workflow.ts +65 -9
- package/src/workspace-db.ts +166 -0
package/src/builder/ddl-tools.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { html, raw, type Raw } from '../html.ts';
|
|
|
6
6
|
import { icon } from '../icons.ts';
|
|
7
7
|
import { clientIp, logActivity } from '../session.ts';
|
|
8
8
|
import { BASE, developer, region, send, shell, workshopTabs, type Req } from './ui.ts';
|
|
9
|
+
import { workshopDb } from './workspaces.ts';
|
|
9
10
|
|
|
10
11
|
// SQL Workshop → Generate DDL and Schema Comparison (APEX: SQL Workshop →
|
|
11
12
|
// Utilities). Both only read the catalog (src/ddl.ts); the scripts they
|
|
@@ -32,7 +33,8 @@ export async function ddlToolRoutes(app: FastifyInstance) {
|
|
|
32
33
|
app.get(`${BASE}/sql/ddl`, async (req: Req, reply) => {
|
|
33
34
|
const s = await developer(req, reply);
|
|
34
35
|
if (!s) return;
|
|
35
|
-
const
|
|
36
|
+
const db = await workshopDb(s);
|
|
37
|
+
const schemas = await userSchemas(db, { usable: db !== owner });
|
|
36
38
|
const q = req.query ?? {};
|
|
37
39
|
const schema = schemas.includes(q.schema ?? '') ? q.schema! : '';
|
|
38
40
|
// the first visit: every type, comments and grants
|
|
@@ -42,7 +44,7 @@ export async function ddlToolRoutes(app: FastifyInstance) {
|
|
|
42
44
|
let result: Raw | '' = '';
|
|
43
45
|
if (schema) {
|
|
44
46
|
try {
|
|
45
|
-
const objects = pick(await readSchema(
|
|
47
|
+
const objects = pick(await readSchema(db.pool, schema), types, opts);
|
|
46
48
|
const script = ddlScript(schema, objects);
|
|
47
49
|
await logActivity({ username: s.username, event: 'generate_ddl', ip: clientIp(req), detail: `${schema}: ${objects.length} objects` });
|
|
48
50
|
if (q.download === '1')
|
|
@@ -73,7 +75,8 @@ export async function ddlToolRoutes(app: FastifyInstance) {
|
|
|
73
75
|
app.get(`${BASE}/sql/compare`, async (req: Req, reply) => {
|
|
74
76
|
const s = await developer(req, reply);
|
|
75
77
|
if (!s) return;
|
|
76
|
-
const
|
|
78
|
+
const db = await workshopDb(s);
|
|
79
|
+
const schemas = await userSchemas(db, { usable: db !== owner });
|
|
77
80
|
const q = req.query ?? {};
|
|
78
81
|
const source = schemas.includes(q.source ?? '') ? q.source! : '';
|
|
79
82
|
const target = schemas.includes(q.target ?? '') ? q.target! : '';
|
|
@@ -81,7 +84,7 @@ export async function ddlToolRoutes(app: FastifyInstance) {
|
|
|
81
84
|
if (source && target && source === target) result = html`<div class="alert alert-error" role="alert">Choose two different schemas.</div>`;
|
|
82
85
|
else if (source && target) {
|
|
83
86
|
try {
|
|
84
|
-
const [a, b] = await Promise.all([readSchema(
|
|
87
|
+
const [a, b] = await Promise.all([readSchema(db.pool, source), readSchema(db.pool, target)]);
|
|
85
88
|
const { differences, same } = compare(a, b);
|
|
86
89
|
const script = changeScript(source, target, a, b);
|
|
87
90
|
await logActivity({ username: s.username, event: 'schema_compare', ip: clientIp(req), detail: `${source} → ${target}: ${differences.length} differences` });
|
package/src/builder/documents.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { FastifyInstance } from 'fastify';
|
|
|
2
2
|
import pg from 'pg';
|
|
3
3
|
import { applyBinds } from '../binds.ts';
|
|
4
4
|
import { owner } from '../db.ts';
|
|
5
|
+
import { devDbForApp } from '../workspace-db.ts';
|
|
5
6
|
import { html } from '../html.ts';
|
|
6
7
|
import { english } from '../i18n.ts';
|
|
7
8
|
import { documentData, documentPdf, fillTemplate, TemplateError } from '../runtime/document.ts';
|
|
@@ -42,7 +43,7 @@ export async function documentRoutes(app: FastifyInstance) {
|
|
|
42
43
|
const tpl = a && /^\d+$/.test(tid) ? await owner.one('select * from meta.document_template where id = $1 and app_id = $2', [tid, a.id]) : undefined;
|
|
43
44
|
if (!a || !tpl) return reply.code(404).send('Not found');
|
|
44
45
|
const binds = previewBinds(String(req.query.binds ?? ''));
|
|
45
|
-
const c = await
|
|
46
|
+
const c = await (await devDbForApp(a.id)).pool.connect();
|
|
46
47
|
try {
|
|
47
48
|
await c.query('begin');
|
|
48
49
|
await c.query(`set local statement_timeout = '10s'`);
|
package/src/builder/home.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { icon } from '../icons.ts';
|
|
|
5
5
|
import { APP_COLORS, BASE, csrf, developer, input, region, select, send, shell, type Req } from './ui.ts';
|
|
6
6
|
import type { Session } from '../session.ts';
|
|
7
7
|
import { isAdmin } from './locks.ts';
|
|
8
|
-
import { currentWorkspace, inWorkspaceSql } from './workspaces.ts';
|
|
8
|
+
import { currentWorkspace, inWorkspaceSql, workspaceAdmin } from './workspaces.ts';
|
|
9
9
|
|
|
10
10
|
// The workspace pages of the builder, laid out like APEX's App Builder: the
|
|
11
11
|
// home page (tiles, a searchable list of applications as a report or as
|
|
@@ -285,7 +285,11 @@ export async function homeRoutes(app: FastifyInstance) {
|
|
|
285
285
|
${card(`${BASE}/sql`, icon('database'), 'SQL Workshop', 'Run SQL, browse objects and load data.')}
|
|
286
286
|
${card(`${BASE}/dashboard`, icon('activity'), 'Dashboard', 'Usage and problems across the workspace.')}
|
|
287
287
|
${(await isAdmin(s.username)) ? card(`${BASE}/instance`, icon('settings'), 'Instance settings', 'Session length, sign-in throttling and the server\'s configuration (administrators).') : ''}
|
|
288
|
-
${(await isAdmin(s.username))
|
|
288
|
+
${(await isAdmin(s.username))
|
|
289
|
+
? card(`${BASE}/workspaces`, icon('layers'), 'Workspaces', 'Groups of applications and their developers (administrators).')
|
|
290
|
+
: s.workspace && (await workspaceAdmin(s.username, s.workspace.id))
|
|
291
|
+
? card(`${BASE}/workspaces/${s.workspace.id}`, icon('layers'), `Workspace ${s.workspace.name}`, 'Its name and developers (workspace administrators).')
|
|
292
|
+
: ''}
|
|
289
293
|
${(await isAdmin(s.username)) ? card(`${BASE}/ai`, icon('bolt'), 'AI services', 'Claude and OpenAI for the applications: models, keys, limits and usage (administrators).') : ''}
|
|
290
294
|
${(await isAdmin(s.username)) ? card(`${BASE}/mail`, icon('send'), 'Mail services', 'SMTP, Microsoft 365, Amazon SES, Resend, Postmark, SendGrid or Mailgun for the applications\' e-mail (administrators).') : ''}
|
|
291
295
|
${(await isAdmin(s.username)) ? card(`${BASE}/installation`, icon('history'), 'Installation', 'Version, install and upgrade runs, applied migrations (administrators).') : ''}
|
package/src/builder/newapp.ts
CHANGED
|
@@ -64,9 +64,27 @@ export async function createApp(c: Client, a: CheckedApp): Promise<{ id: number;
|
|
|
64
64
|
const S = pg.escapeIdentifier(a.schema);
|
|
65
65
|
const R = pg.escapeIdentifier(a.role);
|
|
66
66
|
// The parsing schema: a role that can only use this schema.
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
await c.query(
|
|
67
|
+
// (090) an isolated workspace's application gets a new role, and a schema that is new (owned by that
|
|
68
|
+
// role) or one of the workspace's own: never another workspace's
|
|
69
|
+
const isolated = (await c.query('select 1 from meta.workspace where id = $1 and isolated', [a.workspaceId])).rowCount;
|
|
70
|
+
const roleExists = (await c.query('select 1 from pg_roles where rolname = $1', [a.role])).rowCount;
|
|
71
|
+
if (isolated) {
|
|
72
|
+
// (a taken alias fails below, with its own message)
|
|
73
|
+
const aliasTaken = (await c.query('select 1 from meta.app where alias = $1', [a.alias])).rowCount;
|
|
74
|
+
if (roleExists && !aliasTaken) throw new Error(`The database role ${a.role} exists already: choose another alias.`);
|
|
75
|
+
const foreign = await c.query(
|
|
76
|
+
`select 1 from pg_namespace n where n.nspname = $1
|
|
77
|
+
and n.nspowner::regrole::text not in (select role_name from meta.workspace_role where workspace_id = $2)`,
|
|
78
|
+
[a.schema, a.workspaceId],
|
|
79
|
+
);
|
|
80
|
+
if (foreign.rowCount) throw new Error(`The schema ${a.schema} exists already and doesn't belong to this workspace: choose another schema.`);
|
|
81
|
+
}
|
|
82
|
+
if (!roleExists) await c.query(`create role ${R} nologin`);
|
|
83
|
+
await c.query(`create schema if not exists ${S}${isolated ? ` authorization ${R}` : ''}`);
|
|
84
|
+
if (isolated) {
|
|
85
|
+
// a name the workspace lists already (an application deleted, its role dropped) is its own; another workspace's is refused
|
|
86
|
+
await c.query('insert into meta.workspace_role (workspace_id, role_name) values ($1, $2) on conflict (workspace_id, role_name) do nothing', [a.workspaceId, a.role]);
|
|
87
|
+
} else await c.query(`grant ${R} to pgkiln_runtime`);
|
|
70
88
|
await c.query(`grant usage on schema ${S} to ${R}`);
|
|
71
89
|
await c.query(`grant select, insert, update, delete on all tables in schema ${S} to ${R}`);
|
|
72
90
|
await c.query(`grant usage, select on all sequences in schema ${S} to ${R}`);
|
package/src/builder/quicksql.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { quickSql } from '../quicksql.ts';
|
|
|
6
6
|
import { clientIp, type Session } from '../session.ts';
|
|
7
7
|
import { runAndRecord, runOptions, SCRIPT_MAX_MB } from './scripts.ts';
|
|
8
8
|
import { back, BASE, csrf, developer, flash, region, send, shell, workshopTabs, type Req } from './ui.ts';
|
|
9
|
+
import { currentWorkspace } from './workspaces.ts';
|
|
9
10
|
|
|
10
11
|
// SQL Workshop → Quick SQL: shorthand → DDL preview, then save it as a SQL
|
|
11
12
|
// script or run it (as a script, so the results per statement are kept).
|
|
@@ -85,8 +86,8 @@ export async function quickSqlRoutes(app: FastifyInstance) {
|
|
|
85
86
|
return back(reply, s, `${BASE}/sql/scripts/runs/${id}`);
|
|
86
87
|
}
|
|
87
88
|
let unique = name;
|
|
88
|
-
for (let n = 2; await owner.one('select 1 from meta.sql_script where name = $1', [unique]); n++) unique = `${name} (${n})`;
|
|
89
|
-
const r = await owner.one(`insert into meta.sql_script (name, content, created_by, updated_by) values ($1, $2, $3, $3) returning id`, [unique, content, s.username]);
|
|
89
|
+
for (let n = 2; await owner.one('select 1 from meta.sql_script where name = $1 and workspace_id = $2', [unique, currentWorkspace(s)]); n++) unique = `${name} (${n})`;
|
|
90
|
+
const r = await owner.one(`insert into meta.sql_script (name, content, created_by, updated_by, workspace_id) values ($1, $2, $3, $3, $4) returning id`, [unique, content, s.username, currentWorkspace(s)]);
|
|
90
91
|
flash(s, `Saved as script “${unique}”.`);
|
|
91
92
|
return back(reply, s, `${BASE}/sql/scripts/${r!.id}`);
|
|
92
93
|
});
|
|
@@ -3,6 +3,7 @@ import { designSql } from './websources.ts';
|
|
|
3
3
|
import pg from 'pg';
|
|
4
4
|
import { applyBinds } from '../binds.ts';
|
|
5
5
|
import { owner } from '../db.ts';
|
|
6
|
+
import { devDbForApp } from '../workspace-db.ts';
|
|
6
7
|
import { html, raw } from '../html.ts';
|
|
7
8
|
import { formatMaskError } from '../runtime/format.ts';
|
|
8
9
|
import { heading } from '../runtime/items.ts';
|
|
@@ -19,12 +20,13 @@ const PAGE_SIZES = ['5', '10', '15', '25', '50', '100'];
|
|
|
19
20
|
/** The column names of a report's query: run with limit 0, as the app's role, then rolled back. */
|
|
20
21
|
export async function reportColumns(appId: number, source: string | null): Promise<{ columns: string[] } | { error: string }> {
|
|
21
22
|
if (!source?.trim()) return { error: 'The region has no source query yet.' };
|
|
22
|
-
|
|
23
|
+
// the role from pgkiln's tables (the owner); the query on the developer's connection (090)
|
|
24
|
+
const role = (await owner.one<{ db_role: string | null }>('select db_role from meta.app where id = $1', [appId]))?.db_role;
|
|
25
|
+
const c = await (await devDbForApp(appId)).pool.connect();
|
|
23
26
|
try {
|
|
24
27
|
await c.query('begin');
|
|
25
28
|
await c.query(`set local statement_timeout = '5s'`);
|
|
26
29
|
await c.query(`select set_config('pgkiln.app_id', $1, true)`, [String(appId)]);
|
|
27
|
-
const role = (await c.query('select db_role from meta.app where id = $1', [appId])).rows[0]?.db_role;
|
|
28
30
|
if (role) await c.query(`set local role ${pg.escapeIdentifier(role)}`);
|
|
29
31
|
const sql = applyBinds(source.trim().replace(/;+\s*$/, ''), {});
|
|
30
32
|
const res = await c.query(`select * from (\n${sql}\n) "__q" limit 0`);
|
package/src/builder/routes.ts
CHANGED
|
@@ -17,7 +17,7 @@ import { APP_COLORS, appHeader, back, BASE, builderHead, csrf, developer, flash,
|
|
|
17
17
|
import { appOr404 } from './forms.ts';
|
|
18
18
|
import { docToFiles, filesToZip } from '../appfiles.ts';
|
|
19
19
|
import { homeRoutes, rememberApp } from './home.ts';
|
|
20
|
-
import { appAllowed, currentWorkspace, placeApp, workspaceRoutes } from './workspaces.ts';
|
|
20
|
+
import { appAllowed, currentWorkspace, placeApp, workshopDb, workspaceRoutes } from './workspaces.ts';
|
|
21
21
|
import { instanceRoutes } from './instance.ts';
|
|
22
22
|
import { saveTimeZoneSettings, timeZoneSettings } from './globalization.ts';
|
|
23
23
|
import { WIZARD_KINDS, wizardRoutes, wizardTables } from './wizards.ts';
|
|
@@ -170,7 +170,7 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
170
170
|
await placeApp(c, made.id, ws);
|
|
171
171
|
// (sprint 51) the Advisor on the imported application, before the commit: errors refuse it
|
|
172
172
|
if (req.body?.check === 'true') {
|
|
173
|
-
const errors = (await advise(made.id, c)).findings.filter((f) => f.severity === 'error');
|
|
173
|
+
const errors = (await advise(made.id, c, { db: await workshopDb(s) })).findings.filter((f) => f.severity === 'error');
|
|
174
174
|
if (errors.length)
|
|
175
175
|
throw new Error(`the check found ${errors.length} error(s), nothing was imported: ${errors.slice(0, 5).map((f) =>
|
|
176
176
|
`${f.entry ? `${f.entry.pageNo !== null ? `page ${f.entry.pageNo}, ` : ''}${f.entry.label}` : 'database'} (${f.field}): ${f.message}`).join('; ')}${errors.length > 5 ? '; …' : ''}. Fix them, or import without the check.`);
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
} from '../sampledata.ts';
|
|
32
32
|
import { clientIp, logActivity, type Session } from '../session.ts';
|
|
33
33
|
import { back, BASE, csrf, developer, flash, region, send, shell, workshopTabs, type Body, type Req } from './ui.ts';
|
|
34
|
+
import { currentWorkspace, workshopDb } from './workspaces.ts';
|
|
34
35
|
|
|
35
36
|
// SQL Workshop → Sample Data (APEX 26.1: Data Generator): generate realistic
|
|
36
37
|
// rows for one or more tables of a schema (src/sampledata.ts). Step 1 picks a
|
|
@@ -58,9 +59,9 @@ interface Saved {
|
|
|
58
59
|
tables: TableSpec[];
|
|
59
60
|
}
|
|
60
61
|
|
|
61
|
-
async function saved(id: unknown) {
|
|
62
|
+
async function saved(s: Session, id: unknown) {
|
|
62
63
|
if (!/^\d{1,9}$/.test(String(id ?? ''))) return undefined;
|
|
63
|
-
return owner.one<Saved>('select * from meta.data_generator where id = $1', [id]);
|
|
64
|
+
return owner.one<Saved>('select * from meta.data_generator where id = $1 and workspace_id = $2', [id, currentWorkspace(s)]);
|
|
64
65
|
}
|
|
65
66
|
|
|
66
67
|
/** The definition posted by the generator form (never trusted: plan() checks it against the catalog). */
|
|
@@ -108,12 +109,13 @@ export async function sampleDataRoutes(app: FastifyInstance) {
|
|
|
108
109
|
|
|
109
110
|
const intro = html`<p class="muted">Generate realistic rows for the tables of a schema: names, e-mail addresses, dates and numbers in a range, values from a list,
|
|
110
111
|
foreign keys that pick existing parent rows. Preview them, insert them in one transaction (parents first) or download them as SQL or CSV.
|
|
111
|
-
Runs as the builder's owner connection, like <a href="${BASE}/sql">SQL Commands</a>.</p>`;
|
|
112
|
+
Runs as the builder's owner connection (an isolated workspace's developer login for its developers), like <a href="${BASE}/sql">SQL Commands</a>.</p>`;
|
|
112
113
|
|
|
113
114
|
// ------------------------------------------------------------ step 1 and 2: schema and tables
|
|
114
115
|
const start = async (s: Session, reply: FastifyReply, schema: string | undefined) => {
|
|
115
|
-
const
|
|
116
|
-
const
|
|
116
|
+
const db = await workshopDb(s);
|
|
117
|
+
const all = (await schemas(db.pool)).filter((x) => db === owner || x.usable);
|
|
118
|
+
const gens = (await owner.query<Saved & { updated_at: string; updated_by: string }>('select id, name, description, schema_name, tables, updated_at, updated_by from meta.data_generator where workspace_id = $1 order by lower(name)', [currentWorkspace(s)])).rows;
|
|
117
119
|
const savedList = gens.length
|
|
118
120
|
? html`<ul class="sd-saved">${gens.map(
|
|
119
121
|
(g) => html`<li><a href="${sdUrl}/${g.id}">${g.name}</a> <span class="muted small">${g.schema_name}: ${g.tables.map((t) => `${t.table} (${t.rows})`).join(', ')}</span>
|
|
@@ -129,7 +131,7 @@ export async function sampleDataRoutes(app: FastifyInstance) {
|
|
|
129
131
|
<button class="btn">Choose</button></form>`;
|
|
130
132
|
let tablesForm: Raw | '' = '';
|
|
131
133
|
if (chosen) {
|
|
132
|
-
const tables = await schemaTables(
|
|
134
|
+
const tables = await schemaTables(db.pool, chosen);
|
|
133
135
|
tablesForm = html`<form method="get" action="${sdUrl}" class="u-mt1"><input type="hidden" name="schema" value="${chosen}">
|
|
134
136
|
<fieldset class="prop-group"><legend>Tables of ${chosen}</legend><div class="qb-pick">${tables.map(
|
|
135
137
|
(t) => html`<label class="check"><input type="checkbox" name="t" value="${t.name}"> ${t.name} <span class="muted small">${t.estimate >= 0 ? `~${t.estimate.toLocaleString('en')} rows` : ''}</span></label>`,
|
|
@@ -155,23 +157,24 @@ export async function sampleDataRoutes(app: FastifyInstance) {
|
|
|
155
157
|
}
|
|
156
158
|
|
|
157
159
|
/** The tables of a definition with their catalog info; specs proposed for columns the definition doesn't mention. */
|
|
158
|
-
const describeAll = async (def: GeneratorDef) => {
|
|
160
|
+
const describeAll = async (s: Session, def: GeneratorDef) => {
|
|
161
|
+
const db = (await workshopDb(s)).pool;
|
|
159
162
|
const out: { info: TableInfo; spec: TableSpec }[] = [];
|
|
160
163
|
const missing: string[] = [];
|
|
161
164
|
for (const t of def.tables) {
|
|
162
|
-
const info = await describe(
|
|
165
|
+
const info = await describe(db, def.schema, t.table);
|
|
163
166
|
if (!info) {
|
|
164
167
|
missing.push(t.table);
|
|
165
168
|
continue;
|
|
166
169
|
}
|
|
167
|
-
const spec = tableSpec(info, DEFAULT_ROWS, t, undefined, await nextValues(
|
|
170
|
+
const spec = tableSpec(info, DEFAULT_ROWS, t, undefined, await nextValues(db, info));
|
|
168
171
|
out.push({ info, spec: { ...spec, rows: Number.isInteger(t.rows) ? t.rows : DEFAULT_ROWS } });
|
|
169
172
|
}
|
|
170
173
|
return { tables: out, missing };
|
|
171
174
|
};
|
|
172
175
|
|
|
173
176
|
const editor = async (s: Session, reply: FastifyReply, st: FormState, code = 200) => {
|
|
174
|
-
const { tables, missing } = await describeAll(st.def);
|
|
177
|
+
const { tables, missing } = await describeAll(s, st.def);
|
|
175
178
|
const title = st.id ? (st.name ?? 'Generator') : 'New generator';
|
|
176
179
|
const columnRow = (i: number, j: number, c: ColumnInfo, spec: TableSpec) => {
|
|
177
180
|
const cs = spec.columns.find((x) => x.column === c.name)!;
|
|
@@ -246,7 +249,7 @@ export async function sampleDataRoutes(app: FastifyInstance) {
|
|
|
246
249
|
app.get(`${sdUrl}/:id`, async (req: Req, reply) => {
|
|
247
250
|
const s = await developer(req, reply);
|
|
248
251
|
if (!s) return;
|
|
249
|
-
const g = await saved(req.params.id);
|
|
252
|
+
const g = await saved(s, req.params.id);
|
|
250
253
|
if (!g) return reply.code(404).send('Not found');
|
|
251
254
|
return editor(s, reply, { id: g.id, name: g.name, description: g.description ?? '', def: cleanDef({ schema: g.schema_name, seed: g.seed, tables: g.tables }) });
|
|
252
255
|
});
|
|
@@ -256,7 +259,7 @@ export async function sampleDataRoutes(app: FastifyInstance) {
|
|
|
256
259
|
if (!s) return;
|
|
257
260
|
const b = req.body ?? {};
|
|
258
261
|
const def = defFromBody(b);
|
|
259
|
-
const g = b.id ? await saved(b.id) : undefined;
|
|
262
|
+
const g = b.id ? await saved(s, b.id) : undefined;
|
|
260
263
|
if (b.id && !g) return reply.code(404).send('Not found');
|
|
261
264
|
const st: FormState = { def, id: g?.id, name: (b.name ?? '').trim(), description: (b.description ?? '').trim() };
|
|
262
265
|
const refuse = (error: string) => editor(s, reply, { ...st, error }, 422);
|
|
@@ -266,13 +269,13 @@ export async function sampleDataRoutes(app: FastifyInstance) {
|
|
|
266
269
|
if (!st.name) return refuse('Give the generator a name to save it.');
|
|
267
270
|
if (st.name.length > 100 || (st.description ?? '').length > 1000) return refuse('The name is at most 100 characters, the description 1000.');
|
|
268
271
|
if (!allowedSchema(def.schema)) return refuse('Choose a schema of your own: not meta, information_schema or pg_*.');
|
|
269
|
-
const { tables } = await describeAll(def);
|
|
272
|
+
const { tables } = await describeAll(s, def);
|
|
270
273
|
if (!tables.length) return refuse('Choose at least one table.');
|
|
271
274
|
const json = JSON.stringify(tables.map(({ spec }) => spec));
|
|
272
275
|
try {
|
|
273
276
|
const id = g
|
|
274
277
|
? (await owner.one('update meta.data_generator set name = $2, description = $3, schema_name = $4, seed = $5, tables = $6, updated_by = $7, updated_at = now() where id = $1 returning id', [g.id, st.name, st.description || null, def.schema, def.seed, json, s.username])).id
|
|
275
|
-
: (await owner.one('insert into meta.data_generator (name, description, schema_name, seed, tables, created_by, updated_by) values ($1, $2, $3, $4, $5, $6, $6) returning id', [st.name, st.description || null, def.schema, def.seed, json, s.username])).id;
|
|
278
|
+
: (await owner.one('insert into meta.data_generator (name, description, schema_name, seed, tables, created_by, updated_by, workspace_id) values ($1, $2, $3, $4, $5, $6, $6, $7) returning id', [st.name, st.description || null, def.schema, def.seed, json, s.username, currentWorkspace(s)])).id;
|
|
276
279
|
flash(s, `Generator ${st.name} saved.`);
|
|
277
280
|
return back(reply, s, `${sdUrl}/${id}`);
|
|
278
281
|
} catch (e) {
|
|
@@ -282,7 +285,8 @@ export async function sampleDataRoutes(app: FastifyInstance) {
|
|
|
282
285
|
}
|
|
283
286
|
if (!['preview', 'insert', 'sql', 'csv'].includes(action)) return refuse('Unknown action.');
|
|
284
287
|
|
|
285
|
-
const
|
|
288
|
+
const db = await workshopDb(s);
|
|
289
|
+
const p = await plan(db.pool, def);
|
|
286
290
|
if (p.problems.length) return refuse(p.problems.join(' '));
|
|
287
291
|
const seed = def.seed ?? randomSeed();
|
|
288
292
|
st.def = { ...def, seed };
|
|
@@ -290,7 +294,7 @@ export async function sampleDataRoutes(app: FastifyInstance) {
|
|
|
290
294
|
if (action === 'sql' || action === 'csv') {
|
|
291
295
|
let generated;
|
|
292
296
|
try {
|
|
293
|
-
generated = await generateAll(
|
|
297
|
+
generated = await generateAll(db.pool, p.tables, seed);
|
|
294
298
|
} catch (e) {
|
|
295
299
|
if (e instanceof SampleDataError) return refuse(e.message);
|
|
296
300
|
throw e;
|
|
@@ -309,7 +313,7 @@ export async function sampleDataRoutes(app: FastifyInstance) {
|
|
|
309
313
|
|
|
310
314
|
// preview and insert: the same run, rolled back or committed
|
|
311
315
|
const started = performance.now();
|
|
312
|
-
const c = await
|
|
316
|
+
const c = await db.pool.connect();
|
|
313
317
|
let result: RunResult;
|
|
314
318
|
try {
|
|
315
319
|
await c.query('begin');
|
|
@@ -340,7 +344,7 @@ export async function sampleDataRoutes(app: FastifyInstance) {
|
|
|
340
344
|
app.post(`${sdUrl}/:id/delete`, async (req: Req, reply) => {
|
|
341
345
|
const s = await developer(req, reply);
|
|
342
346
|
if (!s) return;
|
|
343
|
-
const g = await saved(req.params.id);
|
|
347
|
+
const g = await saved(s, req.params.id);
|
|
344
348
|
if (!g) return reply.code(404).send('Not found');
|
|
345
349
|
await owner.query('delete from meta.data_generator where id = $1', [g.id]);
|
|
346
350
|
flash(s, `Generator ${g.name} deleted.`);
|
package/src/builder/scripts.ts
CHANGED
|
@@ -7,12 +7,14 @@ import { clientIp, logActivity, type Session } from '../session.ts';
|
|
|
7
7
|
import { runScript, splitScript, type ScriptRun, type StatementResult } from '../sqlscript.ts';
|
|
8
8
|
import { clearCompletions } from './code-editor.ts';
|
|
9
9
|
import { back, BASE, csrf, developer, flash, region, send, shell, workshopTabs, type Body, type Req } from './ui.ts';
|
|
10
|
+
import { currentWorkspace, workshopDb } from './workspaces.ts';
|
|
10
11
|
|
|
11
12
|
// SQL Workshop → SQL Scripts: saved scripts (create, edit, upload, download,
|
|
12
13
|
// delete), run with a result per statement (stop at the first error or go on,
|
|
13
14
|
// optionally in one transaction), and the run history. Scripts run on the
|
|
14
15
|
// owner connection, like SQL Commands, on a connection of their own that is
|
|
15
|
-
// closed afterwards (a script may SET ROLE or change settings).
|
|
16
|
+
// closed afterwards (a script may SET ROLE or change settings). Scripts and runs belong
|
|
17
|
+
// to the current workspace; an isolated workspace's run on its developer login (090).
|
|
16
18
|
|
|
17
19
|
/** Largest script (upload or editor), in MB. */
|
|
18
20
|
export const SCRIPT_MAX_MB = 5;
|
|
@@ -30,7 +32,7 @@ export interface RunRequest {
|
|
|
30
32
|
/** Run a script, record the run (history + activity log) and return the run's id. */
|
|
31
33
|
export async function runAndRecord(s: Session, ip: string, r: RunRequest): Promise<{ id: string; run: ScriptRun }> {
|
|
32
34
|
const statements = splitScript(r.content);
|
|
33
|
-
const c = await
|
|
35
|
+
const c = await (await workshopDb(s)).pool.connect();
|
|
34
36
|
let run: ScriptRun;
|
|
35
37
|
try {
|
|
36
38
|
run = await runScript(c, statements, { stopOnError: r.stopOnError, transaction: r.transaction });
|
|
@@ -40,12 +42,17 @@ export async function runAndRecord(s: Session, ip: string, r: RunRequest): Promi
|
|
|
40
42
|
}
|
|
41
43
|
clearCompletions(); // the script may have changed tables or grants
|
|
42
44
|
const saved = await owner.one<{ id: string }>(
|
|
43
|
-
`insert into meta.sql_script_run (script_id, script_name, run_by, elapsed_ms, stop_on_error, transactional, statements, succeeded, failed, rolled_back, results)
|
|
44
|
-
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) returning id`,
|
|
45
|
+
`insert into meta.sql_script_run (script_id, script_name, run_by, elapsed_ms, stop_on_error, transactional, statements, succeeded, failed, rolled_back, results, workspace_id)
|
|
46
|
+
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) returning id`,
|
|
45
47
|
[r.scriptId, r.name, s.username, run.ms, r.stopOnError, r.transaction, run.statements, run.succeeded, run.failed, run.rolledBack,
|
|
46
|
-
JSON.stringify(run.commitError ? [...run.results, { n: 0, line: 0, sql: 'COMMIT', status: 'error', error: run.commitError, ms: 0 }] : run.results)
|
|
48
|
+
JSON.stringify(run.commitError ? [...run.results, { n: 0, line: 0, sql: 'COMMIT', status: 'error', error: run.commitError, ms: 0 }] : run.results),
|
|
49
|
+
currentWorkspace(s)],
|
|
50
|
+
);
|
|
51
|
+
await owner.query(
|
|
52
|
+
`delete from meta.sql_script_run where workspace_id = $2
|
|
53
|
+
and id <= (select id from meta.sql_script_run where workspace_id = $2 order by id desc offset $1 limit 1)`,
|
|
54
|
+
[KEEP_RUNS, currentWorkspace(s)],
|
|
47
55
|
);
|
|
48
|
-
await owner.query(`delete from meta.sql_script_run where id <= (select id from meta.sql_script_run order by id desc offset $1 limit 1)`, [KEEP_RUNS]);
|
|
49
56
|
await logActivity({
|
|
50
57
|
username: s.username,
|
|
51
58
|
event: 'sql_script',
|
|
@@ -121,10 +128,12 @@ export async function scriptRoutes(app: FastifyInstance) {
|
|
|
121
128
|
r.started_at as last_run, r.failed as last_failed
|
|
122
129
|
from meta.sql_script s
|
|
123
130
|
left join lateral (select started_at, failed from meta.sql_script_run x where x.script_id = s.id order by x.id desc limit 1) r on true
|
|
131
|
+
where s.workspace_id = $1
|
|
124
132
|
order by lower(s.name)`,
|
|
133
|
+
[currentWorkspace(s)],
|
|
125
134
|
)
|
|
126
135
|
).rows;
|
|
127
|
-
const runs = (await owner.query('select * from meta.sql_script_run order by id desc limit 20')).rows;
|
|
136
|
+
const runs = (await owner.query('select * from meta.sql_script_run where workspace_id = $1 order by id desc limit 20', [currentWorkspace(s)])).rows;
|
|
128
137
|
const list = scripts.length
|
|
129
138
|
? html`<div class="table-wrap"><table class="report"><thead><tr><th>Script</th><th>Updated</th><th class="num">Size</th><th>Last run</th><th>File</th></tr></thead><tbody>
|
|
130
139
|
${scripts.map((x) => html`<tr><td><a href="${BASE}/sql/scripts/${x.id}">${x.name}</a>${x.description ? html`<br><span class="muted small">${x.description}</span>` : ''}</td>
|
|
@@ -193,7 +202,7 @@ export async function scriptRoutes(app: FastifyInstance) {
|
|
|
193
202
|
app.get(`${BASE}/sql/scripts/:id`, async (req: Req, reply) => {
|
|
194
203
|
const s = await developer(req, reply);
|
|
195
204
|
if (!s) return;
|
|
196
|
-
const row = /^\d+$/.test(req.params.id) ? await owner.one('select * from meta.sql_script where id = $1', [req.params.id]) : undefined;
|
|
205
|
+
const row = /^\d+$/.test(req.params.id) ? await owner.one('select * from meta.sql_script where id = $1 and workspace_id = $2', [req.params.id, currentWorkspace(s)]) : undefined;
|
|
197
206
|
if (!row) return reply.code(404).send('Not found');
|
|
198
207
|
return editor(req, reply, s, row);
|
|
199
208
|
});
|
|
@@ -220,15 +229,15 @@ export async function scriptRoutes(app: FastifyInstance) {
|
|
|
220
229
|
try {
|
|
221
230
|
if (id) {
|
|
222
231
|
const r = await owner.one(
|
|
223
|
-
'update meta.sql_script set name = $2, description = nullif($3, \'\'), content = $4, updated_by = $5, updated_at = now() where id = $1 returning id',
|
|
224
|
-
[id, name, b.description ?? '', content, s.username],
|
|
232
|
+
'update meta.sql_script set name = $2, description = nullif($3, \'\'), content = $4, updated_by = $5, updated_at = now() where id = $1 and workspace_id = $6 returning id',
|
|
233
|
+
[id, name, b.description ?? '', content, s.username, currentWorkspace(s)],
|
|
225
234
|
);
|
|
226
235
|
if (!r) return reply.code(404).send('Not found');
|
|
227
236
|
scriptId = r.id;
|
|
228
237
|
} else {
|
|
229
238
|
const r = await owner.one(
|
|
230
|
-
`insert into meta.sql_script (name, description, content, created_by, updated_by) values ($1, nullif($2, ''), $3, $4, $4) returning id`,
|
|
231
|
-
[name, b.description ?? '', content, s.username],
|
|
239
|
+
`insert into meta.sql_script (name, description, content, created_by, updated_by, workspace_id) values ($1, nullif($2, ''), $3, $4, $4, $5) returning id`,
|
|
240
|
+
[name, b.description ?? '', content, s.username, currentWorkspace(s)],
|
|
232
241
|
);
|
|
233
242
|
scriptId = r!.id;
|
|
234
243
|
}
|
|
@@ -260,6 +269,8 @@ export async function scriptRoutes(app: FastifyInstance) {
|
|
|
260
269
|
if (!s) return;
|
|
261
270
|
if (!/^\d+$/.test(req.params.id)) return reply.code(404).send('Not found');
|
|
262
271
|
const r = await owner.tx(async (c) => {
|
|
272
|
+
const own = (await c.query('select 1 from meta.sql_script where id = $1 and workspace_id = $2', [req.params.id, currentWorkspace(s)])).rowCount;
|
|
273
|
+
if (!own) return undefined;
|
|
263
274
|
await c.query('delete from meta.sql_script_run where script_id = $1', [req.params.id]);
|
|
264
275
|
return (await c.query('delete from meta.sql_script where id = $1 returning name', [req.params.id])).rows[0];
|
|
265
276
|
});
|
|
@@ -270,7 +281,7 @@ export async function scriptRoutes(app: FastifyInstance) {
|
|
|
270
281
|
app.get(`${BASE}/sql/scripts/:id/download`, async (req: Req, reply) => {
|
|
271
282
|
const s = await developer(req, reply);
|
|
272
283
|
if (!s) return;
|
|
273
|
-
const row = /^\d+$/.test(req.params.id) ? await owner.one('select name, content from meta.sql_script where id = $1', [req.params.id]) : undefined;
|
|
284
|
+
const row = /^\d+$/.test(req.params.id) ? await owner.one('select name, content from meta.sql_script where id = $1 and workspace_id = $2', [req.params.id, currentWorkspace(s)]) : undefined;
|
|
274
285
|
if (!row) return reply.code(404).send('Not found');
|
|
275
286
|
return reply
|
|
276
287
|
.header('content-type', 'application/sql; charset=utf-8')
|
|
@@ -302,8 +313,8 @@ export async function scriptRoutes(app: FastifyInstance) {
|
|
|
302
313
|
if (content.includes('\0')) return fail('The file is not a text file.');
|
|
303
314
|
const base = file.filename.replace(/^.*[\\/]/, '').replace(/\.sql$/i, '').slice(0, 180) || 'script';
|
|
304
315
|
let name = base;
|
|
305
|
-
for (let n = 2; await owner.one('select 1 from meta.sql_script where name = $1', [name]); n++) name = `${base} (${n})`;
|
|
306
|
-
const r = await owner.one(`insert into meta.sql_script (name, content, created_by, updated_by) values ($1, $2, $3, $3) returning id`, [name, content, s.username]);
|
|
316
|
+
for (let n = 2; await owner.one('select 1 from meta.sql_script where name = $1 and workspace_id = $2', [name, currentWorkspace(s)]); n++) name = `${base} (${n})`;
|
|
317
|
+
const r = await owner.one(`insert into meta.sql_script (name, content, created_by, updated_by, workspace_id) values ($1, $2, $3, $3, $4) returning id`, [name, content, s.username, currentWorkspace(s)]);
|
|
307
318
|
flash(s, `Script “${name}” uploaded.`);
|
|
308
319
|
return back(reply, s, `${BASE}/sql/scripts/${r!.id}`);
|
|
309
320
|
});
|
|
@@ -312,7 +323,7 @@ export async function scriptRoutes(app: FastifyInstance) {
|
|
|
312
323
|
app.get(`${BASE}/sql/scripts/runs/:run`, async (req: Req, reply) => {
|
|
313
324
|
const s = await developer(req, reply);
|
|
314
325
|
if (!s) return;
|
|
315
|
-
const run = /^\d+$/.test(req.params.run) ? await owner.one('select * from meta.sql_script_run where id = $1', [req.params.run]) : undefined;
|
|
326
|
+
const run = /^\d+$/.test(req.params.run) ? await owner.one('select * from meta.sql_script_run where id = $1 and workspace_id = $2', [req.params.run, currentWorkspace(s)]) : undefined;
|
|
316
327
|
if (!run) return reply.code(404).send('Not found');
|
|
317
328
|
const exists = run.script_id ? await owner.one('select id from meta.sql_script where id = $1', [run.script_id]) : undefined;
|
|
318
329
|
const options = `${run.stop_on_error ? 'stop on error' : 'continue on error'}${run.transactional ? ', one transaction' : ''}`;
|
package/src/builder/sql.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { FastifyInstance, FastifyReply } from 'fastify';
|
|
2
2
|
import pg from 'pg';
|
|
3
|
-
import { owner } from '../db.ts';
|
|
4
3
|
import { html, raw, type Raw } from '../html.ts';
|
|
5
4
|
import { icon } from '../icons.ts';
|
|
6
5
|
import { BASE, csrf, developer, region, send, shell, workshopTabs, type Req } from './ui.ts';
|
|
@@ -11,8 +10,10 @@ import { quickSqlRoutes } from './quicksql.ts';
|
|
|
11
10
|
import { queryBuilderRoutes } from './querybuilder.ts';
|
|
12
11
|
import { unloadRoutes } from './unload.ts';
|
|
13
12
|
import { ddlToolRoutes } from './ddl-tools.ts';
|
|
13
|
+
import { workshopDb, workshopRunsAs } from './workspaces.ts';
|
|
14
14
|
|
|
15
|
-
// SQL Workshop: SQL commands and the object browser (owner connection
|
|
15
|
+
// SQL Workshop: SQL commands and the object browser (owner connection; an isolated
|
|
16
|
+
// workspace's developer login for its developers, see workshopDb).
|
|
16
17
|
|
|
17
18
|
export async function sqlRoutes(app: FastifyInstance) {
|
|
18
19
|
// SQL Scripts, Quick SQL and the query builder (sprint 31)
|
|
@@ -35,12 +36,13 @@ export async function sqlRoutes(app: FastifyInstance) {
|
|
|
35
36
|
if (!s) return;
|
|
36
37
|
const sql = req.body?.sql ?? '';
|
|
37
38
|
let result: Raw | '' = '';
|
|
39
|
+
const db = await workshopDb(s);
|
|
38
40
|
if (req.method === 'POST' && sql.trim()) {
|
|
39
41
|
const started = performance.now();
|
|
40
42
|
try {
|
|
41
43
|
// a connection of its own, closed afterwards: the SQL may SET ROLE or change settings,
|
|
42
44
|
// which must not leak into the builder's pooled owner connections
|
|
43
|
-
const c = await
|
|
45
|
+
const c = await db.pool.connect();
|
|
44
46
|
let out;
|
|
45
47
|
try {
|
|
46
48
|
out = await c.query({ text: sql, rowMode: 'array' });
|
|
@@ -59,7 +61,7 @@ export async function sqlRoutes(app: FastifyInstance) {
|
|
|
59
61
|
await logActivity({ username: s.username, event: 'sql_command', ip: clientIp(req), elapsedMs: Math.round(performance.now() - started), detail: sql.length > 2000 ? `${sql.slice(0, 2000)}…` : sql });
|
|
60
62
|
}
|
|
61
63
|
const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('sql')}
|
|
62
|
-
<p class="muted">Runs as
|
|
64
|
+
<p class="muted">Runs as ${workshopRunsAs(db)} (not as an application role). Multiple statements are allowed; results of the last one are shown.</p>
|
|
63
65
|
<form method="post">${csrf(s)}
|
|
64
66
|
<textarea name="sql" class="code sql-editor" rows="12" spellcheck="false" aria-label="SQL" data-code="plpgsql">${sql || 'select current_user, version();'}</textarea>
|
|
65
67
|
<div class="buttons"><button class="btn btn-hot">${icon('play')} Run (Ctrl+Enter)</button></div>
|
|
@@ -73,17 +75,20 @@ export async function sqlRoutes(app: FastifyInstance) {
|
|
|
73
75
|
app.get(`${BASE}/sql/objects`, async (req: Req, reply) => {
|
|
74
76
|
const s = await developer(req, reply);
|
|
75
77
|
if (!s) return;
|
|
78
|
+
const db = await workshopDb(s);
|
|
76
79
|
const objects = (
|
|
77
|
-
await
|
|
80
|
+
await db.query(
|
|
78
81
|
`select n.nspname as schema, c.relname as name, c.oid::regclass::text as qname,
|
|
79
82
|
case c.relkind when 'r' then 'table' when 'p' then 'table' when 'v' then 'view' when 'm' then 'view' end as kind,
|
|
80
83
|
c.relrowsecurity as rls
|
|
81
84
|
from pg_class c join pg_namespace n on n.oid = c.relnamespace
|
|
82
85
|
where c.relkind in ('r', 'p', 'v', 'm') and n.nspname !~ '^pg_' and n.nspname not in ('information_schema')
|
|
86
|
+
and has_schema_privilege(n.oid, 'USAGE')
|
|
83
87
|
union all
|
|
84
88
|
select n.nspname, p.proname || '(' || pg_get_function_identity_arguments(p.oid) || ')', p.oid::regprocedure::text, 'function', false
|
|
85
89
|
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
|
86
90
|
where n.nspname !~ '^pg_' and n.nspname not in ('information_schema') and p.prokind in ('f', 'p')
|
|
91
|
+
and has_schema_privilege(n.oid, 'USAGE')
|
|
87
92
|
order by 1, 4 desc, 2`,
|
|
88
93
|
)
|
|
89
94
|
).rows;
|
|
@@ -92,24 +97,24 @@ export async function sqlRoutes(app: FastifyInstance) {
|
|
|
92
97
|
let detail: Raw = html`<p class="muted">Select a table, view or function.</p>`;
|
|
93
98
|
if (selected) {
|
|
94
99
|
if (selected.kind === 'function') {
|
|
95
|
-
const src = await
|
|
100
|
+
const src = await db.one('select pg_get_functiondef($1::regprocedure) as def', [selected.qname]);
|
|
96
101
|
detail = region(selected.qname, html`<pre class="source">${src.def}</pre>`);
|
|
97
102
|
} else {
|
|
98
103
|
const [cols, policies, grants, data] = await Promise.all([
|
|
99
|
-
|
|
104
|
+
db.query(
|
|
100
105
|
`select a.attname as column, format_type(a.atttypid, a.atttypmod) as type, not a.attnotnull as nullable,
|
|
101
106
|
pg_get_expr(d.adbin, d.adrelid) as "default"
|
|
102
107
|
from pg_attribute a left join pg_attrdef d on d.adrelid = a.attrelid and d.adnum = a.attnum
|
|
103
108
|
where a.attrelid = $1::regclass and a.attnum > 0 and not a.attisdropped order by a.attnum`,
|
|
104
109
|
[selected.qname],
|
|
105
110
|
),
|
|
106
|
-
|
|
107
|
-
|
|
111
|
+
db.query('select policyname as policy, cmd, roles::text, qual as using, with_check from pg_policies where schemaname = $1 and tablename = $2', [selected.schema, selected.name]),
|
|
112
|
+
db.query(
|
|
108
113
|
`select grantee, string_agg(privilege_type, ', ' order by privilege_type) as privileges
|
|
109
114
|
from information_schema.role_table_grants where table_schema = $1 and table_name = $2 group by grantee order by grantee`,
|
|
110
115
|
[selected.schema, selected.name],
|
|
111
116
|
),
|
|
112
|
-
|
|
117
|
+
db.pool.query({ text: `select * from ${selected.qname} limit 25`, rowMode: 'array' }).catch((e) => e as Error),
|
|
113
118
|
]);
|
|
114
119
|
const asArray = (r: pg.QueryResult) => ({ ...r, rows: r.rows.map((x) => Object.values(x)) }) as unknown as pg.QueryResult<any[]>;
|
|
115
120
|
detail = html`${region(`${selected.kind === 'view' ? 'View' : 'Table'} ${selected.qname}`, resultTable(asArray(cols)),
|
package/src/builder/unload.ts
CHANGED
|
@@ -21,6 +21,7 @@ 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
25
|
|
|
25
26
|
// SQL Workshop → Unload Data (APEX: Data Workshop → Unload Data): a table or
|
|
26
27
|
// view (chosen columns, an optional WHERE and ORDER BY) or a query, to CSV,
|
|
@@ -125,7 +126,7 @@ export async function unloadRoutes(app: FastifyInstance) {
|
|
|
125
126
|
<a class="ide-tab" href="${BASE}/sql/unload?source=query"${active === 'query' ? raw(' aria-current="page"') : ''}>${icon('code')}<span>Query</span></a></nav>`;
|
|
126
127
|
|
|
127
128
|
const intro = html`<p class="muted">Download the rows of a table, a view or a query as CSV, JSON, Excel or XML (up to ${DOWNLOAD_MAX_ROWS.toLocaleString('en')} rows).
|
|
128
|
-
Runs as the builder's owner connection in a read-only transaction. To load a file into a table, use <a href="${BASE}/sql/load">Load Data</a>.</p>`;
|
|
129
|
+
Runs as the builder's owner connection (an isolated workspace's developer login for its developers) in a read-only transaction. To load a file into a table, use <a href="${BASE}/sql/load">Load Data</a>.</p>`;
|
|
129
130
|
|
|
130
131
|
const form = async (s: Session, b: Body, error?: string) => {
|
|
131
132
|
const alert = error ? html`<div class="alert alert-error" role="alert">${error}</div>` : '';
|
|
@@ -212,7 +213,7 @@ export async function unloadRoutes(app: FastifyInstance) {
|
|
|
212
213
|
if (typeof opts === 'string') return refuse(opts);
|
|
213
214
|
|
|
214
215
|
const started = performance.now();
|
|
215
|
-
const c = await
|
|
216
|
+
const c = await (await workshopDb(s)).pool.connect();
|
|
216
217
|
let opened;
|
|
217
218
|
try {
|
|
218
219
|
await c.query('begin transaction read only');
|