pgkiln 0.35.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/087_login_page.sql +175 -0
- package/db/migrations/088_region_templates.sql +18 -0
- package/db/migrations/089_page_groups.sql +330 -0
- package/db/migrations/090_workspace_isolation.sql +512 -0
- package/docs/apex-feature-parity.md +23 -21
- package/docs/guide/03-builder.md +85 -8
- package/docs/guide/04-pages-and-regions.md +42 -3
- package/docs/guide/08-security.md +46 -5
- package/docs/guide/11-from-apex.md +4 -1
- package/docs/guide/12-development.md +13 -3
- package/docs/guide/17-mobile.md +3 -1
- package/docs/guide/18-cli.md +2 -0
- package/docs/guide/20-ai-agents.md +2 -0
- package/examples/hr/hr_40_sample_data.sql +1 -1
- package/examples/hr/hr_54_region_templates.sql +67 -0
- package/examples/hr/hr_55_page_groups.sql +24 -0
- package/package.json +1 -1
- package/public/app.css +60 -3
- package/public/app.js +89 -3
- package/public/builder.css +11 -0
- package/public/builder.js +50 -0
- package/public/code-editor.css +3 -3
- package/public/sw.js +5 -3
- package/src/app.ts +20 -0
- package/src/appfiles.ts +1 -0
- package/src/automations.ts +3 -2
- package/src/builder/advisor.ts +25 -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/components.ts +15 -2
- package/src/builder/dataload.ts +24 -14
- package/src/builder/ddl-tools.ts +117 -0
- package/src/builder/designer.ts +29 -3
- package/src/builder/documents.ts +2 -1
- package/src/builder/forms.ts +6 -4
- package/src/builder/home.ts +6 -2
- package/src/builder/login-page.ts +82 -0
- package/src/builder/newapp.ts +21 -3
- package/src/builder/quicksql.ts +3 -2
- package/src/builder/region-help.ts +4 -2
- package/src/builder/region-settings.ts +19 -3
- package/src/builder/report-settings.ts +4 -2
- package/src/builder/routes.ts +18 -6
- package/src/builder/sampledata.ts +22 -18
- package/src/builder/scripts.ts +27 -16
- package/src/builder/shared.ts +1 -1
- package/src/builder/sql.ts +18 -10
- package/src/builder/ui.ts +2 -2
- package/src/builder/unload.ts +3 -2
- package/src/builder/workspaces.ts +175 -15
- package/src/cli/main.ts +45 -0
- package/src/cli/mcp.ts +42 -0
- package/src/cli/replace.ts +4 -0
- package/src/db.ts +36 -6
- package/src/ddl.ts +510 -0
- package/src/debug.ts +3 -2
- package/src/i18n/ar.ts +17 -0
- package/src/i18n/cs.ts +17 -0
- package/src/i18n/da.ts +17 -0
- package/src/i18n/de.ts +17 -0
- package/src/i18n/el.ts +17 -0
- package/src/i18n/es.ts +17 -0
- package/src/i18n/fi.ts +17 -0
- package/src/i18n/fr.ts +17 -0
- package/src/i18n/he.ts +17 -0
- package/src/i18n/it.ts +17 -0
- package/src/i18n/ja.ts +17 -0
- package/src/i18n/ko.ts +17 -0
- package/src/i18n/nb.ts +17 -0
- package/src/i18n/pl.ts +17 -0
- package/src/i18n/pt.ts +17 -0
- package/src/i18n/ru.ts +17 -0
- package/src/i18n/sv.ts +17 -0
- package/src/i18n/tr.ts +17 -0
- package/src/i18n/uk.ts +17 -0
- package/src/i18n/zh.ts +17 -0
- package/src/i18n.ts +34 -0
- package/src/icons.ts +2 -1
- package/src/layout.ts +3 -2
- package/src/metadata.ts +10 -4
- package/src/migrate.ts +3 -0
- package/src/process-jobs.ts +7 -2
- package/src/restsync.ts +3 -2
- package/src/runtime/devbar.ts +3 -1
- package/src/runtime/display-selector.ts +12 -4
- package/src/runtime/grid.ts +170 -26
- package/src/runtime/lists.ts +11 -1
- package/src/runtime/password-reset.ts +171 -0
- package/src/runtime/pwa.ts +7 -10
- package/src/runtime/regions.ts +33 -0
- package/src/runtime/render.ts +15 -0
- package/src/runtime/report-views.ts +4 -2
- package/src/runtime/report.ts +36 -22
- package/src/runtime/routes.ts +39 -33
- package/src/runtime/signin.ts +80 -0
- package/src/sampledata.ts +3 -3
- package/src/session.ts +5 -2
- package/src/static-version.ts +34 -0
- package/src/websources.ts +3 -1
- package/src/workflow.ts +65 -9
- package/src/workspace-db.ts +166 -0
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).') : ''}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { FastifyInstance } from 'fastify';
|
|
2
|
+
import { owner } from '../db.ts';
|
|
3
|
+
import { html, raw } from '../html.ts';
|
|
4
|
+
import { back, BASE, developer, flash, type Req } from './ui.ts';
|
|
5
|
+
|
|
6
|
+
// Application settings → Sign-in methods (migration 087): the application's
|
|
7
|
+
// own login page (APEX: the login page, usually 9999) and "Forgot password?".
|
|
8
|
+
// "Create login page" adds a minimal public page with a login region and
|
|
9
|
+
// makes it the login page.
|
|
10
|
+
|
|
11
|
+
interface AppRow {
|
|
12
|
+
id: number;
|
|
13
|
+
name: string;
|
|
14
|
+
authentication: string;
|
|
15
|
+
local_login: boolean;
|
|
16
|
+
login_page: number | null;
|
|
17
|
+
password_reset: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The fields in the settings form. */
|
|
21
|
+
export async function loginSettingsFields(a: AppRow) {
|
|
22
|
+
const pages = (await owner.query<{ page_no: number; name: string; requires_auth: boolean }>(
|
|
23
|
+
'select page_no, name, requires_auth from meta.page where app_id = $1 order by page_no', [a.id],
|
|
24
|
+
)).rows;
|
|
25
|
+
const mail = await owner.one<{ service: string | null; enabled: boolean | null; from_address: string | null }>(
|
|
26
|
+
`select s.name as service, s.enabled, ams.from_address
|
|
27
|
+
from meta.app_mail_service ams join meta.mail_service s on s.id = ams.service_id where ams.app_id = $1`, [a.id],
|
|
28
|
+
);
|
|
29
|
+
const mailOk = !!(mail?.enabled && mail.from_address?.trim());
|
|
30
|
+
const opt = (v: string, label: string, cur: string) => html`<option value="${v}"${v === cur ? raw(' selected') : ''}>${label}</option>`;
|
|
31
|
+
const current = a.login_page ? String(a.login_page) : '';
|
|
32
|
+
return html`<div class="form-grid">
|
|
33
|
+
<div class="field"><label class="label" for="f_login_page">Login page</label>
|
|
34
|
+
<select id="f_login_page" name="login_page">${opt('', '- the built-in sign-in page -', current)}${pages.map((p) =>
|
|
35
|
+
opt(String(p.page_no), `${p.page_no} ${p.name}${p.requires_auth ? ' (requires sign-in: not usable)' : ''}`, current))}</select>
|
|
36
|
+
<small class="help">A page of the application with a <em>Login</em> region replaces the built-in sign-in page (APEX: the login page). It must not require authentication; with page template <em>Minimal</em> it has no header and navigation.
|
|
37
|
+
<button class="link-button" formaction="${BASE}/apps/${a.id}/login-page">Create a login page</button></small></div>
|
|
38
|
+
<div class="field"><span class="label" aria-hidden="true"></span><label class="check"><input type="checkbox" name="password_reset" value="true"${a.password_reset ? raw(' checked') : ''}> Offer "Forgot password?"</label>
|
|
39
|
+
<small class="help">Users with a password and an e-mail address in the user directory get a link to choose a new password (single-use, valid for 30 minutes).
|
|
40
|
+
${mailOk
|
|
41
|
+
? html`Sent through mail service ${mail!.service} as ${mail!.from_address}.`
|
|
42
|
+
: html`<strong>Not offered yet:</strong> the application needs an enabled mail service with a From address (Workspace utilities → Mail services).`}
|
|
43
|
+
Only for the user directory's own passwords: not for LDAP, single sign-on, database accounts or custom authentication.</small></div>
|
|
44
|
+
</div>`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Save them (with the rest of the settings form). */
|
|
48
|
+
export async function saveLoginSettings(appId: string, b: Record<string, string | undefined>) {
|
|
49
|
+
const page = b.login_page?.trim() ? Number(b.login_page) : null;
|
|
50
|
+
if (page !== null && !(await owner.one('select 1 from meta.page where app_id = $1 and page_no = $2', [appId, page]))) throw new Error(`Login page: there is no page ${b.login_page}.`);
|
|
51
|
+
await owner.query('update meta.app set login_page = $2, password_reset = $3 where id = $1', [appId, page, b.password_reset === 'true']);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function loginPageRoutes(app: FastifyInstance) {
|
|
55
|
+
// Create a login page: page 9999 (or the next free number), public, minimal, with a login region
|
|
56
|
+
app.post(`${BASE}/apps/:id/login-page`, async (req: Req, reply) => {
|
|
57
|
+
const s = await developer(req, reply);
|
|
58
|
+
if (!s) return;
|
|
59
|
+
const a = await owner.one<{ id: number; name: string }>('select id, name from meta.app where id = $1', [req.params.id]);
|
|
60
|
+
if (!a) return reply.code(404).send('Not found');
|
|
61
|
+
try {
|
|
62
|
+
const { page, no } = await owner.tx(async (c) => {
|
|
63
|
+
const n = (await c.query<{ n: number }>(
|
|
64
|
+
`select case when not exists (select 1 from meta.page where app_id = $1 and page_no = 9999) then 9999
|
|
65
|
+
else (select max(page_no) + 1 from meta.page where app_id = $1) end as n`, [a.id],
|
|
66
|
+
)).rows[0].n;
|
|
67
|
+
const id = (await c.query<{ id: number }>(
|
|
68
|
+
`insert into meta.page (app_id, page_no, name, title, requires_auth, page_template) values ($1, $2, 'Login', $3, false, 'minimal') returning id`,
|
|
69
|
+
[a.id, n, a.name],
|
|
70
|
+
)).rows[0].id;
|
|
71
|
+
await c.query(`insert into meta.region (page_id, seq, title, type, template) values ($1, 10, $2, 'login', 'standard')`, [id, a.name]);
|
|
72
|
+
await c.query('update meta.app set login_page = $2, updated_at = now() where id = $1', [a.id, n]);
|
|
73
|
+
return { page: id, no: n };
|
|
74
|
+
});
|
|
75
|
+
flash(s, `Page ${no} "Login" created and made the login page. Add regions around the sign-in form in the Page Designer.`);
|
|
76
|
+
return back(reply, s, `${BASE}/pages/${page}`);
|
|
77
|
+
} catch (e) {
|
|
78
|
+
flash(s, (e as Error).message, 'error');
|
|
79
|
+
return back(reply, s, `${BASE}/apps/${req.params.id}/settings`);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
}
|
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
|
});
|
|
@@ -12,7 +12,7 @@ export const REGION_CONFIG_HELP: [string[], string][] = [
|
|
|
12
12
|
[['chart'], '{"kind":"bar"|"column"|"line"|"area"|"donut","max_rows":1000,"format_mask":"FML999G990"}'],
|
|
13
13
|
[['cards'], '{"style":"metric","link":{...},"max_rows":500}'],
|
|
14
14
|
[['dynamic'], '{"max_rows":1000}'],
|
|
15
|
-
[['grid'], '{"page_size":25,"allow":{"insert":true,"update":true,"delete":true},"readonly":["col"],"columns":{"deptno":{"lov":"LOV:DEPARTMENTS","required":true}},"aggregates":{"sal":["sum","avg"]},"layout":{"order":["col"],"hidden":["col"],"widths":{"col":160},"frozen":1},"row_actions":{"edit":{"page":3,"items":{"P3_ID":"#id#"}},"duplicate":true,"delete":true,"links":[{"label":"…","page":4,"items":{}}]},"select_row":{"column":"id","item":"P1_ID"} (a master),"master":{"item":"P1_ID","column":"parent_id"} (a detail; any region type),"actions":true,"saved_reports":true,"public_reports":"SCHEME"}'],
|
|
15
|
+
[['grid'], '{"page_size":25,"allow":{"insert":true,"update":true,"delete":true},"readonly":["col"],"columns":{"deptno":{"lov":"LOV:DEPARTMENTS","required":true}},"aggregates":{"sal":["sum","avg"]},"layout":{"order":["col"],"hidden":["col"],"widths":{"col":160},"frozen":1},"row_actions":{"edit":{"page":3,"items":{"P3_ID":"#id#"}},"duplicate":true,"delete":true,"links":[{"label":"…","page":4,"items":{}}]},"select_row":{"column":"id","item":"P1_ID"} (a master),"master":{"item":"P1_ID","column":"parent_id"} (a detail; any region type),"actions":true,"saved_reports":true,"public_reports":"SCHEME","views":["row","group","chart"] (views besides the grid; left out: all three)}'],
|
|
16
16
|
[['calendar'], '{"link":{...}}'],
|
|
17
17
|
[['facets'], '{"report":<region id>,"search":true,"facets":[{"column":"job","label":"Job","exclude":true},{"column":"sal","type":"range","ranges":[{"to":1000},{"from":1000}],"custom":true},{"column":"rating","type":"star","max":5}]}'],
|
|
18
18
|
[['smart_filters'], '{"report":<region id>,"suggestions":3,"placeholder":"…","facets":[…as facets]}'],
|
|
@@ -25,6 +25,7 @@ export const REGION_CONFIG_HELP: [string[], string][] = [
|
|
|
25
25
|
[['report'], '{"ai_filter":{"service":"NAME"}} adds an "Ask in your own words" box (natural-language filters)'],
|
|
26
26
|
[['plugin'], '{"plugin":"name","attributes":{"NAME":"value"}} (a region plug-in; a Source SELECT feeds its template)'],
|
|
27
27
|
[['form'], '{"lost_update":false} saves without checking that the row is unchanged since it was shown'],
|
|
28
|
+
[['login'], '(no attributes) the sign-in form of the application\'s login page (Application settings → Login page): providers, user name and password, "Keep me signed in", "Forgot password?"'],
|
|
28
29
|
[['url'], '{"height":480,"sandbox":["allow-scripts","allow-forms","allow-popups","allow-same-origin"] (the default; false: no sandbox),"fullscreen":true}'],
|
|
29
30
|
[['search'], '{"configs":["CUSTOMERS","ORDERS"] (Shared Components → Search configurations; empty: all),"item":"P1_SEARCH" (search with this item\'s value instead of a field of its own),"placeholder":"…","min_length":2,"empty":"No results"}'],
|
|
30
31
|
];
|
|
@@ -53,6 +54,7 @@ const ANCHORS: Record<string, string> = {
|
|
|
53
54
|
plugin: 'plug-ins-with-their-own-code',
|
|
54
55
|
tasks: 'approvals-and-the-task-list',
|
|
55
56
|
workflows: 'workflows',
|
|
57
|
+
login: 'the-login-page',
|
|
56
58
|
};
|
|
57
59
|
|
|
58
60
|
/** The help of every type (a region whose type isn't known yet). */
|
|
@@ -63,7 +65,7 @@ export function regionConfigHelp(type: string | null | undefined): { text: strin
|
|
|
63
65
|
if (!type) return { text: ALL_REGION_CONFIG_HELP, url: null };
|
|
64
66
|
const own = REGION_CONFIG_HELP.filter(([types]) => types.includes(type)).map(([, text]) => text);
|
|
65
67
|
const any = REGION_CONFIG_HELP.filter(([types]) => types.includes('*')).map(([, text]) => `any region: ${text}`);
|
|
66
|
-
const chapter = type === 'tasks' || type === 'workflows' ? 'processing' : 'pages-and-regions';
|
|
68
|
+
const chapter = type === 'tasks' || type === 'workflows' ? 'processing' : type === 'login' ? 'security' : 'pages-and-regions';
|
|
67
69
|
return {
|
|
68
70
|
text: [...own, ...any].join(' · ') || 'No attributes of its own.',
|
|
69
71
|
url: ANCHORS[type] ? `${GUIDE}/${chapter}#${ANCHORS[type]}` : null,
|
|
@@ -242,6 +242,10 @@ function mergeGridFeatures(out: Config, b: Body, a: Allowed, columns: string[])
|
|
|
242
242
|
set('saved_reports', b.saved_reports === 'true' ? undefined : false);
|
|
243
243
|
const pub = (b.public_reports ?? '').trim().toUpperCase();
|
|
244
244
|
set('public_reports', pub && a.authz?.has(pub) ? pub : undefined);
|
|
245
|
+
// views besides the grid: all three is the default (left out); none ticked means the grid only
|
|
246
|
+
// (only when the form has the Views fieldset)
|
|
247
|
+
const views = GRID_USER_VIEWS.filter((v) => b[`view_${v}`] === 'true');
|
|
248
|
+
if (b.grid_views === '1') set('views', views.length < GRID_USER_VIEWS.length ? views : undefined);
|
|
245
249
|
// row actions: an edit link, duplicate and delete; custom links stay as written in the JSON
|
|
246
250
|
const edit = mergeLink(b, a.pages, 'edit');
|
|
247
251
|
const ra: Config = { ...(out.row_actions && typeof out.row_actions === 'object' ? out.row_actions : {}) };
|
|
@@ -342,8 +346,10 @@ export function mergeSmartFiltersSettings(config: Config, b: Body, a: Allowed):
|
|
|
342
346
|
export function mergeDisplaySelectorSettings(config: Config, b: Body): Config {
|
|
343
347
|
const out = { ...config };
|
|
344
348
|
const set = setter(out);
|
|
345
|
-
set('style', b.style === 'select'
|
|
349
|
+
set('style', b.style === 'select' || b.style === 'carousel' ? b.style : undefined);
|
|
346
350
|
set('show_all', b.show_all === 'true' ? undefined : false);
|
|
351
|
+
const interval = Number(b.interval);
|
|
352
|
+
set('interval', b.style === 'carousel' && Number.isInteger(interval) && interval >= 1 && interval <= 120 ? interval : undefined);
|
|
347
353
|
set('remember', b.remember === 'true' ? undefined : false);
|
|
348
354
|
return out;
|
|
349
355
|
}
|
|
@@ -497,6 +503,11 @@ async function gridFields(appId: number, r: RegionRow, id: (n: string) => string
|
|
|
497
503
|
<div class="field"><label class="label" for="${id('public_reports')}">Public reports by</label>
|
|
498
504
|
<select id="${id('public_reports')}" name="public_reports">${opt('', '- nobody -', cfg.public_reports)}${authz.map((n) => opt(n, n, cfg.public_reports))}</select></div>
|
|
499
505
|
</div></fieldset>
|
|
506
|
+
<input type="hidden" name="grid_views" value="1">
|
|
507
|
+
<fieldset class="prop-group"><legend>Views</legend><div class="form-grid">
|
|
508
|
+
${GRID_USER_VIEWS.map((v) => check(`view_${v}`, GRID_VIEW_LABELS[v], !Array.isArray(cfg.views) || cfg.views.includes(v)))}
|
|
509
|
+
</div>
|
|
510
|
+
<small class="help">Views users may switch to besides the grid. The single row view edits one row at a time (also from the row actions menu); group by and chart are set up in Actions.</small></fieldset>
|
|
500
511
|
<fieldset class="prop-group"><legend>Row actions</legend><div class="form-grid">
|
|
501
512
|
${check('row_actions', 'Row actions menu', !!cfg.row_actions)}
|
|
502
513
|
${check('row_duplicate', 'Duplicate', cfg.row_actions?.duplicate !== false)}
|
|
@@ -634,6 +645,8 @@ async function facetsFields(r: RegionRow, pageId: number, appId: number, id: (n:
|
|
|
634
645
|
}
|
|
635
646
|
|
|
636
647
|
const VIEW_LABELS: Record<string, string> = { month: 'Month', week: 'Week', day: 'Day', list: 'List' };
|
|
648
|
+
const GRID_USER_VIEWS = ['row', 'group', 'chart'] as const;
|
|
649
|
+
const GRID_VIEW_LABELS: Record<string, string> = { row: 'Single row view', group: 'Group by', chart: 'Chart' };
|
|
637
650
|
|
|
638
651
|
async function calendarFields(appId: number, r: RegionRow, id: (n: string) => string, pages: { page_no: number; name: string }[]) {
|
|
639
652
|
const cfg = r.config ?? {};
|
|
@@ -676,7 +689,10 @@ async function displaySelectorFields(r: RegionRow, pageId: number, id: (n: strin
|
|
|
676
689
|
where page_id = $1 and id <> $2 and type <> 'display_selector' order by seq, id`, [pageId, r.id])).rows;
|
|
677
690
|
return html`<fieldset class="prop-group"><legend>Appearance</legend><div class="form-grid">
|
|
678
691
|
<div class="field"><label class="label" for="${id('style')}">Show as</label>
|
|
679
|
-
<select id="${id('style')}" name="style">${opt('', 'Tabs', cfg.style)}${opt('select', 'Select list', cfg.style)}</select></div>
|
|
692
|
+
<select id="${id('style')}" name="style">${opt('', 'Tabs', cfg.style)}${opt('select', 'Select list', cfg.style)}${opt('carousel', 'Carousel (one at a time, Previous and Next)', cfg.style)}</select></div>
|
|
693
|
+
<div class="field"><label class="label" for="${id('interval')}">Carousel: next region every (seconds)</label>
|
|
694
|
+
<input id="${id('interval')}" name="interval" type="number" min="1" max="120" value="${cfg.interval ?? ''}" placeholder="empty: only by hand">
|
|
695
|
+
<small class="help">Not while the pointer or the focus is in the carousel, nor for users who prefer reduced motion. A carousel has no "Show all".</small></div>
|
|
680
696
|
</div>
|
|
681
697
|
${check('show_all', '"Show all" choice (shows every region)', cfg.show_all !== false)}
|
|
682
698
|
${check('remember', 'Remember the choice during the session', cfg.remember !== false)}</fieldset>
|
|
@@ -765,7 +781,7 @@ export async function regionSettingsForm(pageId: number, appId: number, r: Regio
|
|
|
765
781
|
<div class="field"><label class="label" for="${id('list')}">List</label>
|
|
766
782
|
<select id="${id('list')}" name="list">${opt('', '- choose -', cfg.list)}${lists.map((l) => opt(l.name, `${l.name} (${l.type})`, cfg.list))}${cfg.list && !lists.some((l) => l.name === cfg.list) ? opt(cfg.list, `${cfg.list} (missing!)`, cfg.list) : ''}</select></div>
|
|
767
783
|
<div class="field"><label class="label" for="${id('template')}">Template</label>
|
|
768
|
-
<select id="${id('template')}" name="template">${opt('', 'Links (nested)', cfg.template)}${opt('badges', 'Badge list', cfg.template)}${opt('cards', 'Cards (menu)', cfg.template)}${opt('tabs', 'Tabs', cfg.template)}</select></div>
|
|
784
|
+
<select id="${id('template')}" name="template">${opt('', 'Links (nested)', cfg.template)}${opt('badges', 'Badge list', cfg.template)}${opt('cards', 'Cards (menu)', cfg.template)}${opt('tabs', 'Tabs', cfg.template)}${opt('wizard_progress', 'Wizard progress (numbered steps)', cfg.template)}</select></div>
|
|
769
785
|
</div></fieldset>`;
|
|
770
786
|
break;
|
|
771
787
|
}
|
|
@@ -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
|
@@ -12,11 +12,12 @@ import { pwaSection } from './pwa.ts';
|
|
|
12
12
|
import { documentShell } from '../layout.ts';
|
|
13
13
|
import { passwordProblem } from '../accounts.ts';
|
|
14
14
|
import { clientIp, createSession, destroySession, getSession, loginThrottled, logActivity, saveState, takeFlash } from '../session.ts';
|
|
15
|
+
import { loginSettingsFields, saveLoginSettings } from './login-page.ts';
|
|
15
16
|
import { APP_COLORS, appHeader, back, BASE, builderHead, csrf, developer, flash, input, region, select, send, shell, THEME_COOKIE, validTheme, type Req } from './ui.ts';
|
|
16
17
|
import { appOr404 } from './forms.ts';
|
|
17
18
|
import { docToFiles, filesToZip } from '../appfiles.ts';
|
|
18
19
|
import { homeRoutes, rememberApp } from './home.ts';
|
|
19
|
-
import { appAllowed, currentWorkspace, placeApp, workspaceRoutes } from './workspaces.ts';
|
|
20
|
+
import { appAllowed, currentWorkspace, placeApp, workshopDb, workspaceRoutes } from './workspaces.ts';
|
|
20
21
|
import { instanceRoutes } from './instance.ts';
|
|
21
22
|
import { saveTimeZoneSettings, timeZoneSettings } from './globalization.ts';
|
|
22
23
|
import { WIZARD_KINDS, wizardRoutes, wizardTables } from './wizards.ts';
|
|
@@ -169,7 +170,7 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
169
170
|
await placeApp(c, made.id, ws);
|
|
170
171
|
// (sprint 51) the Advisor on the imported application, before the commit: errors refuse it
|
|
171
172
|
if (req.body?.check === 'true') {
|
|
172
|
-
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');
|
|
173
174
|
if (errors.length)
|
|
174
175
|
throw new Error(`the check found ${errors.length} error(s), nothing was imported: ${errors.slice(0, 5).map((f) =>
|
|
175
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.`);
|
|
@@ -207,6 +208,11 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
207
208
|
wizardTables(a.db_role),
|
|
208
209
|
]);
|
|
209
210
|
const nextPage = Math.max(0, ...pages.rows.map((p) => p.page_no)) + 1;
|
|
211
|
+
// (089) page groups: a column and a filter (?group=name, or "-" for pages without one)
|
|
212
|
+
const groups = (await owner.query<{ name: string }>('select name from meta.page_group where app_id = $1 order by name', [a.id])).rows.map((g) => g.name);
|
|
213
|
+
const grouped = groups.length > 0 || pages.rows.some((p) => p.page_group);
|
|
214
|
+
const groupFilter = typeof req.query?.group === 'string' ? req.query.group : '';
|
|
215
|
+
const shownPages = pages.rows.filter((p) => !groupFilter || (groupFilter === '-' ? !p.page_group : p.page_group === groupFilter));
|
|
210
216
|
const locks = new Map((await appLocks(a.id)).map((l) => [l.page_no, l]));
|
|
211
217
|
const commentsPerPage = new Map((await owner.query('select page_no, count(*)::int as n from meta.dev_comment where app_id = $1 group by 1', [a.id])).rows.map((r) => [r.page_no, r.n]));
|
|
212
218
|
const appLock = locks.get(0);
|
|
@@ -214,10 +220,14 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
214
220
|
const main = html`
|
|
215
221
|
${appHeader(a, 'pages')}
|
|
216
222
|
${appLock && appLock.locked_by !== s.username ? html`<div class="alert alert-error" role="status">${icon('key')} ${lockText(appLock)} Changes are refused until it is unlocked.</div>` : ''}
|
|
217
|
-
${region('Pages', html
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
223
|
+
${region('Pages', html`${grouped
|
|
224
|
+
? html`<form method="get" class="search u-mwnone u-mb1"><div class="field"><label class="label" for="f_group">Page group</label>
|
|
225
|
+
<select id="f_group" name="group">${[['', 'All pages'], ...groups.map((g) => [g, g]), ['-', 'Without a group']].map(([v, l]) => html`<option value="${v}"${v === groupFilter ? raw(' selected') : ''}>${l}</option>`)}</select></div>
|
|
226
|
+
<button class="btn u-selfend">Show</button></form>`
|
|
227
|
+
: ''}<div class="table-wrap"><table class="report">
|
|
228
|
+
<thead><tr><th class="num">Page</th><th>Name</th>${grouped ? html`<th>Group</th>` : ''}<th>Mode</th><th class="num">Regions</th><th class="num">Items</th><th class="num">DAs</th><th class="num">Processes</th><th>Authorization</th><th>Protection</th><th>Lock</th><th></th></tr></thead>
|
|
229
|
+
<tbody>${shownPages.map((p) => html`<tr>
|
|
230
|
+
<td class="num">${p.page_no}</td><td><a href="${BASE}/pages/${p.id}">${p.name}</a>${commentsPerPage.get(p.page_no) ? html` <span class="tag" title="Developer comments">${commentsPerPage.get(p.page_no)} comment${commentsPerPage.get(p.page_no) === 1 ? '' : 's'}</span>` : ''}</td>${grouped ? html`<td>${p.page_group ?? '—'}</td>` : ''}<td>${p.mode}</td>
|
|
221
231
|
<td class="num">${p.regions}</td><td class="num">${p.items}</td><td class="num">${p.das}</td><td class="num">${p.processes}</td>
|
|
222
232
|
<td>${p.requires_auth ? (p.authz ?? '—') : 'public'}</td><td>${p.protection}</td>
|
|
223
233
|
<td>${((l) => (l ? html`<span class="lock-tag" title="${lockText(l)}">${icon('key')}<span>${l.locked_by === s.username ? 'you' : l.locked_by}</span></span>` : '—'))(locks.get(p.page_no))}</td>
|
|
@@ -354,6 +364,7 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
354
364
|
: html`<p class="muted">No LDAP directories configured. <a href="${BASE}/users/directories">Add one</a> to check passwords against LDAP or Active Directory.</p>`}
|
|
355
365
|
<small class="help">LDAP passwords are checked by the username and password form, after local accounts, so keep that method on.</small>
|
|
356
366
|
<div class="form-grid">${input('session_group', 'Session sharing group', a.session_group ?? '', { placeholder: 'empty: not shared', help: 'Applications with the same group (and the user directory as authentication) share a sign-in: after signing in to one, the others open without a new sign-in, each with its own access check and roles; signing out of one signs out of all (APEX: session sharing). Lower case letters, digits and _.' })}</div>
|
|
367
|
+
${await loginSettingsFields(a)}
|
|
357
368
|
<div class="form-grid">${input('remember_me_days', '"Keep me signed in" for (days)', a.remember_me_days ?? '', { type: 'number', placeholder: 'empty: not offered', help: 'Offers a checkbox on the sign-in form (APEX: persistent authentication). The browser stays signed in for this many days after the sign-in, also when the session ends; signing out, a new password, deactivation or removed access ends it. 1 to 365.' })}</div>
|
|
358
369
|
${providers.length
|
|
359
370
|
? providers.map((pr) => html`<div class="field"><label class="check"><input type="checkbox" name="sso_providers" value="${pr.name}"${a.sso_providers.includes(pr.name) ? raw(' checked') : ''}> Sign in with ${pr.display_name}${pr.enabled ? '' : ' (disabled)'}</label></div>`)
|
|
@@ -484,6 +495,7 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
484
495
|
/^[a-z][a-z0-9_]{0,29}$/.test(b.session_group?.trim().toLowerCase() ?? '') ? b.session_group!.trim().toLowerCase() : null],
|
|
485
496
|
);
|
|
486
497
|
await saveTimeZoneSettings(req.params.id, b);
|
|
498
|
+
await saveLoginSettings(req.params.id, b);
|
|
487
499
|
flash(s, 'Settings saved.');
|
|
488
500
|
} catch (e) {
|
|
489
501
|
flash(s, (e as Error).message, 'error');
|
|
@@ -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.`);
|