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.
@@ -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 schemas = await userSchemas(owner);
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(owner.pool, schema), types, opts);
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 schemas = await userSchemas(owner);
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(owner.pool, source), readSchema(owner.pool, target)]);
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` });
@@ -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 owner.pool.connect();
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'`);
@@ -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)) ? card(`${BASE}/workspaces`, icon('layers'), 'Workspaces', 'Groups of applications and their developers (administrators).') : ''}
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).') : ''}
@@ -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
- await c.query(`create schema if not exists ${S}`);
68
- if (!(await c.query('select 1 from pg_roles where rolname = $1', [a.role])).rowCount) await c.query(`create role ${R} nologin`);
69
- await c.query(`grant ${R} to pgkiln_runtime`);
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}`);
@@ -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
- const c = await owner.pool.connect();
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`);
@@ -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 all = await schemas(owner.pool);
116
- 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 order by lower(name)')).rows;
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(owner.pool, chosen);
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(owner.pool, def.schema, t.table);
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(owner.pool, info));
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 p = await plan(owner.pool, def);
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(owner.pool, p.tables, seed);
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 owner.pool.connect();
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.`);
@@ -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 owner.pool.connect();
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' : ''}`;
@@ -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 owner.pool.connect();
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 the builder's owner connection (not as an application role). Multiple statements are allowed; results of the last one are shown.</p>
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 owner.query(
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 owner.one('select pg_get_functiondef($1::regprocedure) as def', [selected.qname]);
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
- owner.query(
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
- owner.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]),
107
- owner.query(
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
- owner.pool.query({ text: `select * from ${selected.qname} limit 25`, rowMode: 'array' }).catch((e) => e as Error),
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)),
@@ -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 owner.pool.connect();
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');