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
|
@@ -7,7 +7,7 @@ open item and open an issue or pull request; see [CONTRIBUTING.md](../CONTRIBUTI
|
|
|
7
7
|
Legend: ✅ available · 🟡 partial (see notes) · ❌ not yet · ➖ not planned (a deliberate choice,
|
|
8
8
|
or better served by the PostgreSQL ecosystem; see the notes and [extensions](guide/15-extensions.md))
|
|
9
9
|
|
|
10
|
-
Last reviewed: 2026-10-
|
|
10
|
+
Last reviewed: 2026-10-13 (sprint 60: isolated workspaces with their own database logins, row-level security on pgkiln's tables and workspace database roles, workspace administrators; sprint 59: the Page Designer's help pane, Quick edit from the developer toolbar; sprint 58: page groups; sprint 57: region templates hero, title bar, buttons container, inline dialog and drawer, wizard with wizard progress, the carousel; sprint 56: Generate DDL and Schema Comparison in the SQL Workshop, `pgkiln ddl` and `pgkiln compare`; sprint 55: an editable login page with a Login region and the Minimal page template, Forgot password through the application's mail service; sprint 54: interactive grid views, the single row view, group by and chart; sprint 53: application settings, user preferences and items kept per user, dynamic translations, `meta.data_export` and data export downloads, display image and geocoded address items; sprint 52: every dynamic action event and action, Ajax callbacks, an error handling function, the session timeout warning; sprint 51: checked imports, application backups, availability and Run only, feedback, type-specific attribute help; sprint 50: lost update detection, warn on unsaved changes, the URL and search regions with search configurations, the developer toolbar with a session state viewer, and e-mail through SMTP with OAuth2, Microsoft Graph, Amazon SES and HTTP APIs; import checks, application availability, property help and e-mail compared again; 2026-10-06: pgkiln 0.31.0, in progress: static application files and the Execute JavaScript dynamic action; region, item, dynamic action and process plug-ins; conditional and dynamic theme styles; XML and Excel in `meta.parse_data`, zips in SQL, `meta.v_boolean`; file items in S3-compatible object storage; map layers by the visible area and as vector tiles; a graphical query builder; tenants for workflows and tasks; application files as YAML and region static IDs; Lucide icons and icon modifiers; PWA push notifications; 0.30.0: 136 icons and an icon picker; an axe-core accessibility audit; cropping pictures; session sharing between applications; twenty-two built-in languages; 0.29.0: workspaces; the Iris base style; drawers; built-in template components including the metric card; Theme Roller dark-mode colours, live preview and template options on items and report columns; report selection across pages; instance settings; fourteen built-in languages; 0.28.0: AI with Claude and OpenAI; 0.27.0: Data Reporter; sample data generator; create application from several sheets, pasted data or existing tables; 0.26.0: working copies with a three-way merge; theme, library and boilerplate applications with subscriptions; 0.25.0: Gantt, pyramid and polar charts; map layers, clustering and server-side spatial filters; REST data sources that write back from forms and grids and synchronise into tables, OAuth2 password and refresh-token grants; debug messages per request and an install/upgrade log; `meta.web_request()` and `meta.parse_data()`; Theme Roller style variants and template options; 0.24.0: automations with several actions, error handling per row and runs from SQL; invoke-API workflow steps; Unload Data in the Data Workshop; create page wizards for cards, calendar, chart, map, faceted search, form and master-detail; create an application from a file; 0.23.0: interactive grid aggregates, frozen/moved/resized columns, layouts and saved grid reports, master-detail, row actions and copy/paste; download processes, execution chains in the background, workflow processes, branches to a function's URL or another app, the Dialog Closed event; number format masks, automatic time zone, runtime messages in German, French and Spanish; SQL scripts, Quick SQL, a query builder, XML loading and data load definitions; custom authentication, lists, page and application locks with developer comments, supporting objects; 0.22.0: keyset paging, report PDFs and REST collections streamed from a cursor, database-account authentication; 0.21.0: HTTP-header authentication behind a reverse proxy; 0.20.0: a popup list of values with server-side search; 0.19.0: row-range pagination, maximum row counts and row limits, lazy loading and caching of regions, streamed CSV/Excel downloads; 0.18.0: calendar week/day/list views with drag and drop, bubble/gauge/funnel/radar charts and drill-down, smart filters, a region display selector and more facet types, computations, conditional branches, build options, menu buttons, REST data sources and web credentials, rich text/Markdown, rating, combobox, date range and QR code items; 0.17.0: an App Builder home, workspace dashboard and utilities like APEX's; 0.16.0: a Page Designer with drag-and-drop layout, a code editor with autocomplete, template components and plug-ins, parallel branches and versions of workflows, the `pgkiln` command line with one file per component; 0.15.0: drop and paste files, heat maps, filtering a report by the map area; 0.14.0: stacked, combo, scatter and pie charts, several files per upload item; 0.13.0: map and tree regions; 0.12.0: REST modules in the builder; 0.11.0: workflows and Progressive Web Apps; 0.10.0: LDAP, SAML, "Keep me signed in", document templates, JSON loading, approvals and the task list; pgkiln installs no application, HR is an example).
|
|
11
11
|
|
|
12
12
|
## At a glance
|
|
13
13
|
|
|
@@ -22,8 +22,8 @@ Last reviewed: 2026-10-12 (sprint 59: the Page Designer's help pane, Quick edit
|
|
|
22
22
|
| Globalization | 6 | 0 | 0 | 0 | One translated app like 26.1 with dynamic translations of table values; number format masks and automatic time zone; twenty-two built-in languages |
|
|
23
23
|
| Data and integration | 12 | 0 | 0 | 2 | Generate DDL and schema comparison; `meta.data_export` and data export downloads (CSV, JSON, XML, HTML, Excel, PDF); e-mail through SMTP (password or OAuth2), Microsoft Graph, Amazon SES and HTTP APIs; REST APIs via PostgREST, REST data sources that write back and synchronise, web credentials with OAuth2 grants, CSV/XLSX/JSON/XML loading with saved definitions and unloading, SQL scripts and Quick SQL, report PDFs and document templates |
|
|
24
24
|
| Workflow, automation and AI | 6 | 0 | 0 | 0 | Scheduled automations with several actions and runs from SQL, approvals, a task list and workflows with parallel branches, versions and invoke-API steps; AI with Claude or OpenAI: *Generate text with AI*, an assistant region with tools, natural-language report filters and blueprints |
|
|
25
|
-
| Administration |
|
|
26
|
-
| **Total** | **
|
|
25
|
+
| Administration | 9 | 0 | 0 | 0 | Isolated workspaces with their own database logins and roles, and workspace administrators; application availability and Run only, automatic backups, user feedback; a developer toolbar with a session state viewer and debug per session; Top SQL per app; debug messages per request; an install/upgrade log |
|
|
26
|
+
| **Total** | **137** | **0** | **0** | **3** | 140 APEX features compared: 98% available, none partial or missing |
|
|
27
27
|
|
|
28
28
|
(Counts are of the rows in the tables below. A review on 2026-10-06 added 18 rows for APEX features that were
|
|
29
29
|
not compared before, and one on 2026-10-12 added two more (property help, application availability), so the share
|
|
@@ -212,12 +212,12 @@ available dropped without anything being removed.)
|
|
|
212
212
|
| Developer accounts | ✅ | |
|
|
213
213
|
| Instance administration, install/upgrade logs (26.1) | ✅ | Versioned migrations (`npm run db:migrate`), every run logged; Workspace utilities → **Installation** (administrators): server version, install and upgrade runs with errors, applied migrations, and warnings for migrations the database misses or the server doesn't know ([chapter 3](guide/03-builder.md)). Workspace utilities → **Instance settings**: session length and sign-in throttling (stored in the database, over the environment variables) and an overview of the server's configuration without secrets; a server whose database lacks migrations says so (503) or applies them (`MIGRATE_ON_START`). Other settings stay environment variables |
|
|
214
214
|
| Debug messages | ✅ | Debug levels 1–9 per app; `meta.debug(level, text)` and `meta.debug_enabled(level)` from application SQL (APEX_DEBUG); timed entries per request (page steps, regions, processes, branches, errors, SQL notices); Activity → **Debug messages** viewer per page view with filters and the slowest step highlighted; retention 1–90 days; password item values are never recorded ([chapter 6](guide/06-processing.md)). (sprint 50) Debug for **one session** from the developer toolbar, the application staying out of debug. **Missing:** debug entries for background jobs and REST requests |
|
|
215
|
-
| Developer toolbar, session state viewer | ✅ | (sprint 50) Developers signed in to the builder see a toolbar on the pages of their applications in the same browser: App Builder, the application, **Edit page** (Page Designer), **Session** (the session state viewer: user, roles, session, language, time zone, debug level, this page's items, application items and other pages' items; passwords never shown; clear the page's items) **Quick edit** (sprint 59: click a region, item or button to open it in the Page Designer) and **Debug** for this session only. A separate random token (cookie path `/a`, HttpOnly) of a live builder session of a developer of the application's workspace ([chapter 3](guide/03-builder.md#developer-toolbar)). **Missing:**
|
|
215
|
+
| Developer toolbar, session state viewer | ✅ | (sprint 50) Developers signed in to the builder see a toolbar on the pages of their applications in the same browser: App Builder, the application, **Edit page** (Page Designer), **Session** (the session state viewer: user, roles, session, language, time zone, debug level, this page's items, application items and other pages' items; passwords never shown; clear the page's items) **Quick edit** (sprint 59: click a region, item or button to open it in the Page Designer) and **Debug** for this session only. A separate random token (cookie path `/a`, HttpOnly) of a live builder session of a developer of the application's workspace ([chapter 3](guide/03-builder.md#developer-toolbar)). **Missing:** Theme Roller from the page |
|
|
216
216
|
| Feedback (users send feedback from a page, developers answer) | ✅ | (sprint 51) With *Users can send feedback* on, the navigation bar's **Feedback** link opens a page for a comment, idea or problem about the page (optional rating), with the user's earlier feedback and the developers' answers; developers read, filter, set a status and answer under Application → Feedback ([chapter 3](guide/03-builder.md#feedback)). No screenshots, no e-mail to developers |
|
|
217
217
|
| Automatic application backups, page groups | ✅ | (sprint 51) **Backups**: before every import that replaces an application (CLI, MCP, working copy merge, restore), daily when it changed, and by hand; the newest 25 (configurable) are kept; download and restore from the builder ([chapter 3](guide/03-builder.md#backups)). (sprint 58) **Page groups**: Shared Components → Page groups, a page's *Page group*, the page list by group with a filter ([chapter 3](guide/03-builder.md#page-groups)) |
|
|
218
218
|
| Application availability (Available, Developers only, Restricted access, Unavailable; build status *Run only*) | ✅ | (sprint 51) Available, available to developers only (a builder session in the same browser), restricted to named users, or unavailable with a message, for pages, file downloads, REST modules and PostgREST; build status **Run only** refuses builder changes as if the application were locked, while `pgkiln import --replace` still deploys. Installation settings: not exported, kept by an import ([chapter 3](guide/03-builder.md#availability-and-build-status)) |
|
|
219
219
|
| Monitoring (top SQL) | ✅ | Activity per app; Top SQL per app's database role from `pg_stat_statements` (sortable, resettable) |
|
|
220
|
-
| Workspaces (multi-tenant) |
|
|
220
|
+
| Workspaces (multi-tenant) | ✅ | Workspaces group applications and developers: the builder shows and opens only the applications of a developer's workspaces (administrators: all), a current workspace with a switcher, new and imported applications go into it, administrators add workspaces, choose their developers and move applications ([chapter 3](guide/03-builder.md#workspaces)). (sprint 60) **Isolated workspaces** are a tenant boundary: the applications run on a PostgreSQL login of the workspace's own and its developers' SQL Workshop on another, with row-level security on pgkiln's tables, so SQL that resets its role can't reach other workspaces' applications, data, sessions or accounts; **workspace database roles** (APEX: workspace schemas) assigned by administrators; **workspace administrators** who manage their workspace's developers; SQL scripts, blueprints and sample data per workspace ([isolated workspaces](guide/03-builder.md#isolated-workspaces)). **Missing:** user directories per workspace (accounts are installation-wide, visible to the workspaces whose applications they use), developers created by a workspace administrator |
|
|
221
221
|
|
|
222
222
|
## Different on purpose
|
|
223
223
|
|
|
@@ -291,7 +291,7 @@ report filters, App Builder AI and blueprints.
|
|
|
291
291
|
translations, display image and geocoded address items~~ (done in sprint 53).
|
|
292
292
|
8. ~~Interactive grid views (single row view, chart view, group by)~~ (done in sprint 54).
|
|
293
293
|
9. ~~Schema comparison and DDL generation in SQL Workshop~~ (done in sprint 56): no ❌ rows left.
|
|
294
|
-
10. ~~Region templates~~ (done in sprint 57); ~~page groups~~ (sprint 58); ~~the help pane and Quick edit~~ (sprint 59); then the last 🟡 row: workspace isolation. (2026-10-12, owner: more built-in runtime languages are not planned; the row was removed. Other languages come from an application's own text messages.)
|
|
294
|
+
10. ~~Region templates~~ (done in sprint 57); ~~page groups~~ (sprint 58); ~~the help pane and Quick edit~~ (sprint 59); ~~then the last 🟡 row: workspace isolation~~ (done in sprint 60: no 🟡 rows left). (2026-10-12, owner: more built-in runtime languages are not planned; the row was removed. Other languages come from an application's own text messages.)
|
|
295
295
|
|
|
296
296
|
Sources: [APEX 26.1 new features](https://docs.oracle.com/en/database/oracle/apex/26.1/htmrn/new-features.html),
|
|
297
297
|
[What's new in APEX 24.2](https://apex.oracle.com/en/platform/features/whats-new-242/),
|
package/docs/guide/03-builder.md
CHANGED
|
@@ -35,12 +35,55 @@ department. Every installation has the **Default** workspace, which holds the ap
|
|
|
35
35
|
(`pgkiln import`) go into Default. Exports don't carry the workspace.
|
|
36
36
|
- Workspace utilities → **Workspaces** (administrators): add a workspace (you become its first developer), rename it,
|
|
37
37
|
choose its developers, move applications to another workspace and delete an empty workspace.
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
not
|
|
38
|
+
- **Workspace administrators**: on a workspace's page, administrators tick which of its developers administer it. A
|
|
39
|
+
workspace administrator opens the page from Workspace utilities (the card names the current workspace) and changes
|
|
40
|
+
its name, description and developers; isolation, database roles, moving applications and deleting stay with the
|
|
41
|
+
installation's administrators.
|
|
42
|
+
|
|
43
|
+
A **shared** workspace (the default) is not a security boundary between developers who write SQL: its SQL Workshop
|
|
44
|
+
runs as pgkiln's owner, and its application code (processes, regions, validations) runs on the shared runtime
|
|
45
|
+
connection, from which SQL can reach other shared applications' data and metadata. Make a workspace **isolated** when
|
|
46
|
+
its developers must not reach other teams' applications or data.
|
|
47
|
+
|
|
48
|
+
### Isolated workspaces
|
|
49
|
+
|
|
50
|
+
On a workspace's page an administrator chooses **Isolate this workspace** (Workspace utilities → Workspaces → the
|
|
51
|
+
workspace → *Isolation*). It needs `RUNTIME_DATABASE_URL` (applications don't run on the owner connection) and
|
|
52
|
+
`PGKILN_SECRET_KEY` (the passwords of the workspace's logins are derived from it; nothing is stored). Then:
|
|
53
|
+
|
|
54
|
+
- **Two PostgreSQL logins of its own**: `pgkiln_ws<id>_run_<suffix>` runs its applications (pages, processes, REST
|
|
55
|
+
handlers, automations, workflows, background processes, REST synchronisation); `pgkiln_ws<id>_dev_<suffix>` runs its
|
|
56
|
+
developers' SQL: SQL Commands, SQL Scripts, Quick SQL, Load and Unload Data, Sample Data, DDL and Compare, the
|
|
57
|
+
Advisor, code checks and document previews. SQL that runs `RESET ROLE` (or `set_config('role', …)`) only gets back to
|
|
58
|
+
that login, which can use the workspace's database roles and nothing else.
|
|
59
|
+
- **The workspace's database roles** (APEX: workspace schemas): isolating gives the workspace the roles its
|
|
60
|
+
applications run as; administrators add or remove roles on its page. Its applications can run only as these roles,
|
|
61
|
+
so an import or a changed *Database role* setting that names another role is refused. A role belongs to one
|
|
62
|
+
workspace; a superuser, a role that creates roles or bypasses row-level security, and pgkiln's own roles can't
|
|
63
|
+
belong to one. A **new application** in an isolated workspace gets a new role and a new schema (owned by that role),
|
|
64
|
+
or a schema of one of the workspace's roles. The developer login starts as the first role (alphabetically), so the
|
|
65
|
+
tables a developer creates in the SQL Workshop belong to an application role; `set role` picks another of them.
|
|
66
|
+
- **pgkiln's own tables** (`meta`) show each login its own workspace's applications, pages, sessions and accounts
|
|
67
|
+
(row-level security), and the shared runtime connection no longer sees the isolated workspace's.
|
|
68
|
+
`meta.app_id()` refuses another workspace's application, so `meta.web_request`, `meta.ai_generate`, mail, push
|
|
69
|
+
and `meta.page_url` can't act for it. The developer login has no rights in `meta`.
|
|
70
|
+
- **The SQL Workshop's saved work** (scripts and their runs, blueprints, sample data generators) belongs to the
|
|
71
|
+
workspace it was made in, also in shared workspaces; data load definitions are listed for the current workspace's
|
|
72
|
+
applications.
|
|
73
|
+
- **Administrators keep the owner** in the SQL Workshop, and a developer who also belongs to a shared workspace has the
|
|
74
|
+
owner's rights there: make the developers of an isolated workspace members of that workspace only.
|
|
75
|
+
- A workflow step's SQL runs on the workspace's login and commits just before the workflow's own state, so after a
|
|
76
|
+
crash between the two a step can run again.
|
|
77
|
+
|
|
78
|
+
Still installation-wide: user accounts (an account with access to an isolated workspace's application is visible only
|
|
79
|
+
to that workspace, other accounts to every workspace), identity providers, LDAP directories, mail and AI services, the
|
|
80
|
+
REST API through PostgREST (use REST modules for isolated applications), and the database catalog (any login can read
|
|
81
|
+
table and function definitions in `pg_catalog`, not their rows). Every login of every workspace counts towards the
|
|
82
|
+
server's `max_connections`: each isolated workspace opens at most `PGKILN_WORKSPACE_POOL_SIZE` (5) connections per
|
|
83
|
+
login and server process.
|
|
84
|
+
|
|
85
|
+
**Make shared** undoes it: the roles go back to the shared runtime connection and the workspace's logins can no
|
|
86
|
+
longer sign in. Delete a workspace only when it is shared and empty; its logins are dropped with it.
|
|
44
87
|
|
|
45
88
|
## The builder window
|
|
46
89
|
|
|
@@ -32,7 +32,9 @@ src/
|
|
|
32
32
|
read, import), files.ts (directories, zip), diff.ts, replace.ts (import --replace in place),
|
|
33
33
|
mcp.ts (`pgkiln mcp`: the MCP server for AI coding agents, chapter 20)
|
|
34
34
|
app.ts / server.ts Fastify setup / entry point
|
|
35
|
-
db.ts the
|
|
35
|
+
db.ts the owner and shared runtime pools, `runtime` (the current scope's: runtimeScope), appTx() (SET LOCAL ROLE + pgkiln.* settings, NOTICEs to the debug log), savepoints
|
|
36
|
+
workspace-db.ts isolated workspaces (090): the isolated applications (cached 5 s), a pool per workspace login (passwords from
|
|
37
|
+
PGKILN_SECRET_KEY), runtimeForApp/runtimeForAlias, withAppDb, devDbForApp; app.ts scopes /a/:alias requests
|
|
36
38
|
security.ts URL checksums, password policy, security headers (CSP nonce), throttling limits
|
|
37
39
|
ddl.ts Generate DDL and Schema Comparison: readSchema (catalog → one DDL per object, unqualified), ddlScript (order), compare, changeScript
|
|
38
40
|
static-version.ts the version of app.css, app.js, icons.svg and sw.js (?v= in their URLs, the service worker version): content hash, looked at again when a file changes
|
|
@@ -232,7 +234,8 @@ src/
|
|
|
232
234
|
code-editor.ts code fields (data-code marks), /builder/code/completions (scoped to the app's role), /builder/code/check
|
|
233
235
|
instance.ts Workspace utilities → Instance settings (src/instance.ts: session and sign-in settings, configuration overview)
|
|
234
236
|
workspaces.ts workspaces (064): loadWorkspaces/appAllowed (checked in ui.ts developer() for every /apps/:id and /pages/:pid
|
|
235
|
-
request), the current workspace (session state __WS), placeApp, the switcher, Workspace utilities → Workspaces
|
|
237
|
+
request), the current workspace (session state __WS), placeApp, the switcher, Workspace utilities → Workspaces;
|
|
238
|
+
(090) isolation and the workspace's database roles, workshopDb (the SQL Workshop's connection)
|
|
236
239
|
locks.ts page and application locks (blockingLock, checked in ui.ts developer() for every builder POST; appOfPath), developer comments, administrators
|
|
237
240
|
blueprints.ts Create → From a blueprint: list, JSON editor, AI draft, review (signed with the session), create in one transaction
|
|
238
241
|
ai-builder.ts App Builder AI (migration 062): the builder's AI service (meta.builder_ai), SQL Workshop → AI (SQL from a
|
|
@@ -290,6 +293,7 @@ test/
|
|
|
290
293
|
page-wizards.test.ts create page wizards: catalog defaults, every page type generated and rendered, refusals, the builder steps
|
|
291
294
|
theme-styles.test.ts Theme Roller style variants (checks, CSS, user choice per app, builder page), template options, base style Iris
|
|
292
295
|
workspaces.test.ts workspaces (064): Default, administrators' pages, current workspace, refused apps, imports and copies
|
|
296
|
+
workspace-isolation.test.ts isolated workspaces (090): logins, RLS on meta, app_id, roles, SQL Workshop, scripts, imports
|
|
293
297
|
ai.test.ts AI services: Generate text with AI (text, structured outputs, errors, limits, keys), its dynamic action,
|
|
294
298
|
meta.ai_generate, the providers, HR page 37; against ai-mock.ts (no real API calls)
|
|
295
299
|
ai-assistant.test.ts AI assistant region (context, tools as the app role, writes, REST, histories, limits, sessions), OpenAI,
|
|
@@ -47,4 +47,4 @@ values ('HR demo staff', 'Departments, employees and leave requests for trying o
|
|
|
47
47
|
{"column": "decision_note", "generator": "skip", "options": "", "nulls": 0},
|
|
48
48
|
{"column": "created_at", "generator": "timestamp", "options": "2025-10-01..2026-10-01", "nulls": 0}]}
|
|
49
49
|
]$json$::jsonb, 'admin', 'admin')
|
|
50
|
-
on conflict (name) do nothing;
|
|
50
|
+
on conflict (workspace_id, name) do nothing;
|
package/package.json
CHANGED
package/src/app.ts
CHANGED
|
@@ -65,6 +65,8 @@ import { ownerUrl, runtime } from './db.ts';
|
|
|
65
65
|
import { refreshInstanceSettings } from './instance.ts';
|
|
66
66
|
import { loadSecrets, securityHeaders, trustProxySetting } from './security.ts';
|
|
67
67
|
import { lucideSymbol } from './icons.ts';
|
|
68
|
+
import { runtimeForAlias } from './workspace-db.ts';
|
|
69
|
+
import { runtimeScope } from './db.ts';
|
|
68
70
|
|
|
69
71
|
export async function buildApp(opts: { logger?: boolean } = {}) {
|
|
70
72
|
// a database older than the code: apply the migrations (MIGRATE_ON_START=true)
|
|
@@ -104,6 +106,22 @@ export async function buildApp(opts: { logger?: boolean } = {}) {
|
|
|
104
106
|
);
|
|
105
107
|
});
|
|
106
108
|
}
|
|
109
|
+
// an application of an isolated workspace runs on its workspace's own login (src/workspace-db.ts):
|
|
110
|
+
// the rest of the request runs in that scope, so runtime.* uses that pool
|
|
111
|
+
app.addHook('onRequest', (req, _reply, done) => {
|
|
112
|
+
const m = /^\/a\/([^/?#]{1,100})/.exec(req.url);
|
|
113
|
+
if (!m || pending.length) return done();
|
|
114
|
+
let alias: string;
|
|
115
|
+
try {
|
|
116
|
+
alias = decodeURIComponent(m[1]);
|
|
117
|
+
} catch {
|
|
118
|
+
return done();
|
|
119
|
+
}
|
|
120
|
+
runtimeForAlias(alias).then(
|
|
121
|
+
(db) => runtimeScope.run(db, done),
|
|
122
|
+
(err: Error) => done(err),
|
|
123
|
+
);
|
|
124
|
+
});
|
|
107
125
|
// debug messages: stored after the response, for requests that have a debug log (src/debug.ts)
|
|
108
126
|
app.addHook('onError', async (req, _reply, err) => {
|
|
109
127
|
debugOf(req)?.add(1, 'error', `unhandled: ${err.message}`);
|
package/src/automations.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import pg from 'pg';
|
|
2
2
|
import { owner, runtime } from './db.ts';
|
|
3
|
+
import { withAppDb } from './workspace-db.ts';
|
|
3
4
|
|
|
4
5
|
// Automations (Shared Components → Automations): SQL or PL/pgSQL on a cron
|
|
5
6
|
// schedule, run by the pgkiln server as the application's database role.
|
|
@@ -226,7 +227,7 @@ export async function runAutomation(id: number, trigger: 'schedule' | 'manual' =
|
|
|
226
227
|
|
|
227
228
|
/** The actions, as the application's role, through meta.automation_execute() (also used by meta.run_automation). */
|
|
228
229
|
async function execute(def: Definition): Promise<{ rows: number; failed: number; errors: RowError[] }> {
|
|
229
|
-
return runtime.tx(async (c) => {
|
|
230
|
+
return withAppDb(def.app_id, () => runtime.tx(async (c) => {
|
|
230
231
|
await c.query(
|
|
231
232
|
`select set_config('pgkiln.app_user', $1, true), set_config('pgkiln.app_id', $2, true),
|
|
232
233
|
set_config('pgkiln.automation_id', $3, true), set_config('pgkiln.session_id', '', true),
|
|
@@ -235,7 +236,7 @@ async function execute(def: Definition): Promise<{ rows: number; failed: number;
|
|
|
235
236
|
);
|
|
236
237
|
if (def.db_role) await c.query(`set local role ${pg.escapeIdentifier(def.db_role)}`);
|
|
237
238
|
return (await c.query('select meta.automation_execute($1::jsonb) as r', [JSON.stringify(def)])).rows[0].r;
|
|
238
|
-
});
|
|
239
|
+
}));
|
|
239
240
|
}
|
|
240
241
|
|
|
241
242
|
// ------------------------------------------------------------------ scheduler
|
package/src/builder/advisor.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { FastifyInstance } from 'fastify';
|
|
2
2
|
import pg from 'pg';
|
|
3
3
|
import { applyBinds, splitStatements } from '../binds.ts';
|
|
4
|
-
import { owner } from '../db.ts';
|
|
4
|
+
import { owner, type Db } from '../db.ts';
|
|
5
|
+
import { devDbForApp } from '../workspace-db.ts';
|
|
5
6
|
import { html, type Raw } from '../html.ts';
|
|
6
7
|
import { icon } from '../icons.ts';
|
|
7
8
|
import { templateProblem } from '../runtime/document.ts';
|
|
@@ -156,7 +157,7 @@ const BUILT_IN = new Set(['MUST_NOT_BE_PUBLIC_USER']);
|
|
|
156
157
|
* e.g. an import that hasn't committed yet) everything is read and checked
|
|
157
158
|
* on that client, inside a savepoint that is rolled back.
|
|
158
159
|
*/
|
|
159
|
-
export async function advise(appId: number, tx?: pg.ClientBase): Promise<{ findings: Finding[]; checked: number; plpgsqlCheck: boolean }> {
|
|
160
|
+
export async function advise(appId: number, tx?: pg.ClientBase, opts: { db?: Db } = {}): Promise<{ findings: Finding[]; checked: number; plpgsqlCheck: boolean }> {
|
|
160
161
|
const query = (sql: string, params: unknown[] = []) => (tx ? tx.query(sql, params) : owner.query(sql, params));
|
|
161
162
|
const one = async (sql: string, params: unknown[] = []) => (await query(sql, params)).rows[0];
|
|
162
163
|
const app = await one('select * from meta.app where id = $1', [appId]);
|
|
@@ -188,9 +189,14 @@ export async function advise(appId: number, tx?: pg.ClientBase): Promise<{ findi
|
|
|
188
189
|
and not exists (select 1 from meta.page_group g where g.app_id = p.app_id and g.name = p.page_group) order by 1`, [appId])).rows)
|
|
189
190
|
findings.push({ severity: 'warning', entry: null, field: `Page ${p.page_no} → Page group`, message: `Page group ${p.page_group} doesn't exist (Shared Components → Page groups).` });
|
|
190
191
|
// ---- SQL, as the application's role, rolled back (a savepoint in the caller's transaction)
|
|
191
|
-
|
|
192
|
+
// (090) an isolated workspace's application: planned on the workspace's developer login, in a
|
|
193
|
+
// transaction of its own (planning can run the application's functions; RESET ROLE there only
|
|
194
|
+
// reaches that login, never the owner); `opts.db` names the login for an import into the workspace
|
|
195
|
+
const own = opts.db && opts.db !== owner ? opts.db : await devDbForApp(appId);
|
|
196
|
+
const inTx = !!tx && own === owner;
|
|
197
|
+
const c = (inTx ? tx : await (own === owner ? owner : own).pool.connect()) as pg.PoolClient;
|
|
192
198
|
try {
|
|
193
|
-
await c.query(
|
|
199
|
+
await c.query(inTx ? 'savepoint advisor_all' : 'begin');
|
|
194
200
|
await c.query(`set local statement_timeout = '5s'`);
|
|
195
201
|
await c.query(`select set_config('pgkiln.app_id', $1, true)`, [String(appId)]);
|
|
196
202
|
if (app.db_role) await c.query(`set local role ${pg.escapeIdentifier(app.db_role)}`);
|
|
@@ -263,7 +269,7 @@ export async function advise(appId: number, tx?: pg.ClientBase): Promise<{ findi
|
|
|
263
269
|
}
|
|
264
270
|
}
|
|
265
271
|
} finally {
|
|
266
|
-
if (
|
|
272
|
+
if (inTx) {
|
|
267
273
|
await c.query('rollback to savepoint advisor_all');
|
|
268
274
|
await c.query('release savepoint advisor_all');
|
|
269
275
|
} else {
|
|
@@ -9,6 +9,7 @@ import type { Session } from '../session.ts';
|
|
|
9
9
|
import { appOr404 } from './forms.ts';
|
|
10
10
|
import { isAdmin } from './locks.ts';
|
|
11
11
|
import { appHeader, back, BASE, csrf, developer, flash, region, select, send, shell, workshopTabs, type Req } from './ui.ts';
|
|
12
|
+
import { workshopDb } from './workspaces.ts';
|
|
12
13
|
import { WIZARD_KINDS, wizardTables } from './wizards.ts';
|
|
13
14
|
|
|
14
15
|
// App Builder AI (migration 062), with the AI service an administrator
|
|
@@ -43,10 +44,20 @@ async function ask(s: Session, req: Parameters<typeof generate>[1]) {
|
|
|
43
44
|
return generate(svc.name, req, { appId: null, user: s.username, source: 'builder' });
|
|
44
45
|
}
|
|
45
46
|
|
|
46
|
-
/** Schemas a developer may describe or ask about (not pgkiln's, not the system's). */
|
|
47
|
-
async function schemas() {
|
|
48
|
-
|
|
49
|
-
|
|
47
|
+
/** Schemas a developer may describe or ask about (not pgkiln's, not the system's; in an isolated workspace, its own). */
|
|
48
|
+
async function schemas(s: Session) {
|
|
49
|
+
const db = await workshopDb(s);
|
|
50
|
+
return (await db.query<{ n: string }>(
|
|
51
|
+
`select nspname as n from pg_namespace where nspname !~ '^pg_' and nspname not in ('information_schema', 'meta')
|
|
52
|
+
${db === owner ? '' : `and has_schema_privilege(oid, 'USAGE')`} order by 1`)).rows.map((r) => r.n);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A table's descriptions as database comments. */
|
|
56
|
+
async function comment(c: { query: (sql: string) => Promise<unknown> }, t: TableInfo, notes: [string, string][]) {
|
|
57
|
+
const rel = `${pg.escapeIdentifier(t.schema)}.${pg.escapeIdentifier(t.table)}`;
|
|
58
|
+
const what = t.kind === 'view' ? 'view' : t.kind === 'materialized view' ? 'materialized view' : 'table';
|
|
59
|
+
for (const [col, note] of notes)
|
|
60
|
+
await c.query(col ? `comment on column ${rel}.${pg.escapeIdentifier(col)} is ${note ? pg.escapeLiteral(note) : 'null'}` : `comment on ${what} ${rel} is ${note ? pg.escapeLiteral(note) : 'null'}`);
|
|
50
61
|
}
|
|
51
62
|
|
|
52
63
|
interface TableInfo {
|
|
@@ -130,7 +141,7 @@ const SQL_SCHEMA = {
|
|
|
130
141
|
};
|
|
131
142
|
|
|
132
143
|
async function workshopPage(s: Session, reply: FastifyReply, out: { question?: string; schema?: string; sql?: string; error?: string; result?: Raw }) {
|
|
133
|
-
const list = await schemas();
|
|
144
|
+
const list = await schemas(s);
|
|
134
145
|
const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('ai')}
|
|
135
146
|
${await serviceBox(s)}
|
|
136
147
|
<p><a href="${BASE}/sql/ai/describe">Describe tables for AI ▸</a> <span class="muted">(better answers: say what tables and columns mean)</span></p>
|
|
@@ -162,7 +173,7 @@ async function workshopPage(s: Session, reply: FastifyReply, out: { question?: s
|
|
|
162
173
|
const isName = (v: unknown): v is string => typeof v === 'string' && v.length > 0 && v.length <= 63;
|
|
163
174
|
|
|
164
175
|
async function describePage(s: Session, reply: FastifyReply, schema: string, table: string, draft: { table?: string; columns?: Record<string, string> } | null, message?: Raw) {
|
|
165
|
-
const list = await schemas();
|
|
176
|
+
const list = await schemas(s);
|
|
166
177
|
const tables = schema && list.includes(schema) ? await tableInfo({ schema }) : [];
|
|
167
178
|
const t = table ? tables.find((x) => x.table === table) : undefined;
|
|
168
179
|
const noted = new Set((await owner.query<{ t: string }>(`select distinct table_name as t from meta.ai_table_note where schema_name = $1`, [schema])).rows.map((r) => r.t));
|
|
@@ -299,7 +310,7 @@ export async function aiBuilderRoutes(app: FastifyInstance) {
|
|
|
299
310
|
let result: Raw;
|
|
300
311
|
try {
|
|
301
312
|
if (!question) throw new AiError('config', 'Type a question.');
|
|
302
|
-
if (!(await schemas()).includes(schema)) throw new AiError('config', 'Choose a schema.');
|
|
313
|
+
if (!(await schemas(s)).includes(schema)) throw new AiError('config', 'Choose a schema.');
|
|
303
314
|
const res = await ask(s, {
|
|
304
315
|
system: `You write PostgreSQL ${(await owner.one<{ v: number }>(`select current_setting('server_version_num')::int / 10000 as v`))!.v} SQL for a developer, for these tables and views:\n\n${describeTables(await tableInfo({ schema }))}\n\n` +
|
|
305
316
|
'Use only those tables and columns, qualified with their schema. Write a SELECT unless the question clearly asks to change data. The developer reviews the SQL before anything runs.',
|
|
@@ -331,7 +342,7 @@ export async function aiBuilderRoutes(app: FastifyInstance) {
|
|
|
331
342
|
let result: Raw;
|
|
332
343
|
try {
|
|
333
344
|
if (!sql.trim() && !error.trim()) throw new AiError('config', 'Paste a query, an error message or both.');
|
|
334
|
-
const tables = schema && (await schemas()).includes(schema) ? describeTables(await tableInfo({ schema })) : '';
|
|
345
|
+
const tables = schema && (await schemas(s)).includes(schema) ? describeTables(await tableInfo({ schema })) : '';
|
|
335
346
|
const res = await ask(s, {
|
|
336
347
|
system: 'You explain PostgreSQL to a developer: what a query does, why an error happens and how to fix it. Be concise; show corrected SQL when it helps.' +
|
|
337
348
|
(tables ? `\n\nThe tables and views of the schema:\n\n${tables}` : ''),
|
|
@@ -352,10 +363,10 @@ export async function aiBuilderRoutes(app: FastifyInstance) {
|
|
|
352
363
|
});
|
|
353
364
|
|
|
354
365
|
/** The posted table, checked against the catalog (null: unknown or reserved). */
|
|
355
|
-
const postedTable = async (b: Record<string, any>) => {
|
|
366
|
+
const postedTable = async (s: Session, b: Record<string, any>) => {
|
|
356
367
|
const schema = String(b.schema ?? '');
|
|
357
368
|
const table = String(b.table ?? '');
|
|
358
|
-
if (!isName(schema) || !isName(table) || RESERVED.test(schema)) return null;
|
|
369
|
+
if (!isName(schema) || !isName(table) || RESERVED.test(schema) || !(await schemas(s)).includes(schema)) return null;
|
|
359
370
|
return (await tableInfo({ schema, table }))[0] ?? null;
|
|
360
371
|
};
|
|
361
372
|
|
|
@@ -363,11 +374,14 @@ export async function aiBuilderRoutes(app: FastifyInstance) {
|
|
|
363
374
|
const s = await developer(req, reply);
|
|
364
375
|
if (!s) return;
|
|
365
376
|
const b = (req.body ?? {}) as Record<string, any>;
|
|
366
|
-
const t = await postedTable(b);
|
|
377
|
+
const t = await postedTable(s, b);
|
|
367
378
|
if (!t) return reply.code(404).send('Not found');
|
|
368
379
|
const notes: [string, string][] = [['', String(b['note:'] ?? '')], ...t.columns.map((c): [string, string] => [c.name, String(b[`note:${c.name}`] ?? '')])]
|
|
369
380
|
.map(([k, v]) => [k, v.replace(/\s+/g, ' ').trim().slice(0, 2000)]);
|
|
370
381
|
try {
|
|
382
|
+
// database comments as the developer's SQL connection (an isolated workspace: its own login), then the notes
|
|
383
|
+
const db = await workshopDb(s);
|
|
384
|
+
if (b.comments === 'true' && db !== owner) await db.tx((c) => comment(c, t, notes));
|
|
371
385
|
await owner.tx(async (c) => {
|
|
372
386
|
for (const [col, note] of notes) {
|
|
373
387
|
if (note) await c.query(
|
|
@@ -376,12 +390,7 @@ export async function aiBuilderRoutes(app: FastifyInstance) {
|
|
|
376
390
|
[t.schema, t.table, col, note, s.username]);
|
|
377
391
|
else await c.query('delete from meta.ai_table_note where schema_name = $1 and table_name = $2 and column_name = $3', [t.schema, t.table, col]);
|
|
378
392
|
}
|
|
379
|
-
if (b.comments === 'true')
|
|
380
|
-
const rel = `${pg.escapeIdentifier(t.schema)}.${pg.escapeIdentifier(t.table)}`;
|
|
381
|
-
const what = t.kind === 'view' ? 'view' : t.kind === 'materialized view' ? 'materialized view' : 'table';
|
|
382
|
-
for (const [col, note] of notes)
|
|
383
|
-
await c.query(col ? `comment on column ${rel}.${pg.escapeIdentifier(col)} is ${note ? pg.escapeLiteral(note) : 'null'}` : `comment on ${what} ${rel} is ${note ? pg.escapeLiteral(note) : 'null'}`);
|
|
384
|
-
}
|
|
393
|
+
if (b.comments === 'true' && db === owner) await comment(c, t, notes);
|
|
385
394
|
});
|
|
386
395
|
flash(s, `Descriptions of ${t.schema}.${t.table} saved${b.comments === 'true' ? ', also as database comments' : ''}.`);
|
|
387
396
|
} catch (e) {
|
|
@@ -394,7 +403,7 @@ export async function aiBuilderRoutes(app: FastifyInstance) {
|
|
|
394
403
|
const s = await developer(req, reply);
|
|
395
404
|
if (!s) return;
|
|
396
405
|
const b = (req.body ?? {}) as Record<string, any>;
|
|
397
|
-
const t = await postedTable(b);
|
|
406
|
+
const t = await postedTable(s, b);
|
|
398
407
|
if (!t) return reply.code(404).send('Not found');
|
|
399
408
|
const names = t.columns.map((c) => c.name);
|
|
400
409
|
try {
|
|
@@ -133,7 +133,7 @@ export async function blueprintRoutes(app: FastifyInstance) {
|
|
|
133
133
|
app.get(`${BASE}/blueprints`, async (req: Req, reply) => {
|
|
134
134
|
const s = await developer(req, reply);
|
|
135
135
|
if (!s) return;
|
|
136
|
-
const list = (await owner.query(`select b.id, b.name, b.created_by, b.updated_at::text as updated, a.id as app_id, a.alias from meta.blueprint b left join meta.app a on a.id = b.app_id order by lower(b.name), b.id
|
|
136
|
+
const list = (await owner.query(`select b.id, b.name, b.created_by, b.updated_at::text as updated, a.id as app_id, a.alias from meta.blueprint b left join meta.app a on a.id = b.app_id where b.workspace_id = $1 order by lower(b.name), b.id`, [currentWorkspace(s)])).rows;
|
|
137
137
|
return page(s, reply, '', html`<h1>Blueprints</h1>
|
|
138
138
|
<p class="muted">Describe an application as a blueprint (tables, pages, menu, sample rows), review it and create it. <a class="btn btn-hot" href="${BASE}/blueprints/new">${icon('plus')} New blueprint</a></p>
|
|
139
139
|
${list.length
|
|
@@ -154,7 +154,7 @@ export async function blueprintRoutes(app: FastifyInstance) {
|
|
|
154
154
|
app.get(`${BASE}/blueprints/:id(^\\d+$)`, async (req: Req, reply) => {
|
|
155
155
|
const s = await developer(req, reply);
|
|
156
156
|
if (!s) return;
|
|
157
|
-
const b = await owner.one('select id, name, spec from meta.blueprint where id = $1', [req.params.id]);
|
|
157
|
+
const b = await owner.one('select id, name, spec from meta.blueprint where id = $1 and workspace_id = $2', [req.params.id, currentWorkspace(s)]);
|
|
158
158
|
if (!b) return reply.code(404).send('Not found');
|
|
159
159
|
return editor(s, reply, { id: b.id, name: b.name, spec: JSON.stringify(b.spec, null, 2) });
|
|
160
160
|
});
|
|
@@ -178,8 +178,8 @@ export async function blueprintRoutes(app: FastifyInstance) {
|
|
|
178
178
|
if (error || !json || typeof json !== 'object' || Array.isArray(json))
|
|
179
179
|
return editor(s, reply, { ...o, message: html`<div class="alert alert-error" role="alert">${error ?? 'A blueprint is a JSON object.'}</div>` });
|
|
180
180
|
const id = o.id
|
|
181
|
-
? (await owner.one('update meta.blueprint set name = $2, spec = $3, updated_at = now() where id = $1 returning id', [o.id, o.name, JSON.stringify(json)]))?.id
|
|
182
|
-
: (await owner.one('insert into meta.blueprint (name, spec, created_by) values ($1, $2, $3) returning id', [o.name, JSON.stringify(json), s.username])).id;
|
|
181
|
+
? (await owner.one('update meta.blueprint set name = $2, spec = $3, updated_at = now() where id = $1 and workspace_id = $4 returning id', [o.id, o.name, JSON.stringify(json), currentWorkspace(s)]))?.id
|
|
182
|
+
: (await owner.one('insert into meta.blueprint (name, spec, created_by, workspace_id) values ($1, $2, $3, $4) returning id', [o.name, JSON.stringify(json), s.username, currentWorkspace(s)])).id;
|
|
183
183
|
if (!id) return reply.code(404).send('Not found');
|
|
184
184
|
flash(s, `Blueprint ${o.name} saved.`);
|
|
185
185
|
return back(reply, s, `${BASE}/blueprints/${id}`);
|
|
@@ -240,8 +240,8 @@ export async function blueprintRoutes(app: FastifyInstance) {
|
|
|
240
240
|
throw new Error(createAppError(e, checked.alias));
|
|
241
241
|
});
|
|
242
242
|
const r = await buildBlueprint(c, blueprint, checked.role, { dashboard: (tables, no) => addDashboard(c, checked.alias, tables, no) });
|
|
243
|
-
if (o.id) await c.query('update meta.blueprint set app_id = $2, spec = $3, updated_at = now() where id = $1', [o.id, a.id, JSON.stringify(json)]);
|
|
244
|
-
else await c.query('insert into meta.blueprint (name, spec, created_by, app_id) values ($1, $2, $3, $4)', [o.name, JSON.stringify(json), s.username, a.id]);
|
|
243
|
+
if (o.id) await c.query('update meta.blueprint set app_id = $2, spec = $3, updated_at = now() where id = $1 and workspace_id = $4', [o.id, a.id, JSON.stringify(json), currentWorkspace(s)]);
|
|
244
|
+
else await c.query('insert into meta.blueprint (name, spec, created_by, app_id, workspace_id) values ($1, $2, $3, $4, $5)', [o.name, JSON.stringify(json), s.username, a.id, currentWorkspace(s)]);
|
|
245
245
|
return { ...r, existing: a.existingAccount };
|
|
246
246
|
});
|
|
247
247
|
await logActivity({ username: s.username, event: 'blueprint', ip: clientIp(req), detail: `created ${blueprint.alias} from blueprint ${o.name}` });
|
|
@@ -255,7 +255,7 @@ export async function blueprintRoutes(app: FastifyInstance) {
|
|
|
255
255
|
app.post(`${BASE}/blueprints/:id(^\\d+$)/delete`, async (req: Req, reply) => {
|
|
256
256
|
const s = await developer(req, reply);
|
|
257
257
|
if (!s) return;
|
|
258
|
-
const r = await owner.query('delete from meta.blueprint where id = $1', [req.params.id]);
|
|
258
|
+
const r = await owner.query('delete from meta.blueprint where id = $1 and workspace_id = $2', [req.params.id, currentWorkspace(s)]);
|
|
259
259
|
flash(s, r.rowCount ? 'Blueprint deleted (applications created from it stay).' : 'There is no such blueprint.');
|
|
260
260
|
return back(reply, s, `${BASE}/blueprints`);
|
|
261
261
|
});
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import type { FastifyInstance } from 'fastify';
|
|
2
2
|
import pg from 'pg';
|
|
3
3
|
import { owner, runtime } from '../db.ts';
|
|
4
|
+
import { devDbForApp } from '../workspace-db.ts';
|
|
4
5
|
import { html, raw, type Raw } from '../html.ts';
|
|
5
6
|
import { checkSql, type SqlShape } from './advisor.ts';
|
|
6
7
|
import { BASE, developer, type Req } from './ui.ts';
|
|
7
|
-
import { appAllowed } from './workspaces.ts';
|
|
8
|
+
import { appAllowed, workshopDb } from './workspaces.ts';
|
|
8
9
|
|
|
9
10
|
// The builder's code editor (public/code-editor.js) and its SQL completions.
|
|
10
11
|
//
|
|
@@ -215,7 +216,7 @@ export async function codeEditorRoutes(app: FastifyInstance) {
|
|
|
215
216
|
if (!sql.trim()) return reply.send({ ok: true, message: 'Nothing to check.' });
|
|
216
217
|
if (/^\s*(STATIC|LOV):/i.test(sql)) return reply.send({ ok: true, message: 'Not SQL: a static or shared list of values.' });
|
|
217
218
|
const role = target ? (await owner.one('select db_role from meta.app where id = $1', [target.id]))?.db_role : null;
|
|
218
|
-
const c = await
|
|
219
|
+
const c = await (target ? await devDbForApp(target.id) : await workshopDb(s)).pool.connect();
|
|
219
220
|
try {
|
|
220
221
|
await c.query('begin');
|
|
221
222
|
await c.query(`set local statement_timeout = '5s'`);
|
package/src/builder/dataload.ts
CHANGED
|
@@ -26,6 +26,7 @@ import { readMultipart } from '../runtime/files.ts';
|
|
|
26
26
|
import type { Session } from '../session.ts';
|
|
27
27
|
import type { ComponentSpec } from './components.ts';
|
|
28
28
|
import { back, BASE, csrf, developer, flash, region, send, shell, workshopTabs, type Body, type Req } from './ui.ts';
|
|
29
|
+
import { appWorkspace, currentWorkspace, workshopDb } from './workspaces.ts';
|
|
29
30
|
|
|
30
31
|
// SQL Workshop → Load Data: upload a CSV/TSV/XLSX/JSON/XML file, preview it,
|
|
31
32
|
// and load it into a new table (column types inferred) or an existing one
|
|
@@ -73,17 +74,23 @@ export const DATA_LOAD_DEF_SPEC: ComponentSpec = {
|
|
|
73
74
|
export const dataLoadDefExtras = (row: { id: number }) =>
|
|
74
75
|
html`<p class="u-mt1"><a class="btn" href="${BASE}/sql/load?definition=${row.id}">${icon('upload')} Load a file with this definition</a></p>`;
|
|
75
76
|
|
|
76
|
-
|
|
77
|
+
/** The data load definitions of the current workspace's applications. */
|
|
78
|
+
async function definitions(s: Session) {
|
|
77
79
|
return (
|
|
78
80
|
await owner.query<{ id: number; app_id: number; app_name: string; name: string; table_name: string }>(
|
|
79
|
-
|
|
81
|
+
`select d.id, d.app_id, a.name as app_name, d.name, d.table_name from meta.data_load_def d join meta.app a on a.id = d.app_id
|
|
82
|
+
where meta.app_workspace(a.id) = $1 order by a.name, d.name`,
|
|
83
|
+
[currentWorkspace(s)],
|
|
80
84
|
)
|
|
81
85
|
).rows;
|
|
82
86
|
}
|
|
83
87
|
|
|
84
|
-
async function definition(id: unknown) {
|
|
88
|
+
async function definition(s: Session, id: unknown) {
|
|
85
89
|
if (!/^\d+$/.test(String(id ?? ''))) return undefined;
|
|
86
|
-
return owner.one<DataLoadDefinition & { id: number; app_id: number }>(
|
|
90
|
+
return owner.one<DataLoadDefinition & { id: number; app_id: number }>(
|
|
91
|
+
'select * from meta.data_load_def where id = $1 and meta.app_workspace(app_id) = $2',
|
|
92
|
+
[id, currentWorkspace(s)],
|
|
93
|
+
);
|
|
87
94
|
}
|
|
88
95
|
|
|
89
96
|
export const MAX_MB = Number(process.env.DATA_LOAD_MAX_MB ?? 50);
|
|
@@ -142,7 +149,7 @@ export async function dataLoadRoutes(app: FastifyInstance) {
|
|
|
142
149
|
});
|
|
143
150
|
|
|
144
151
|
const uploadForm = async (s: Session, error?: string, chosen?: string) => {
|
|
145
|
-
const defs = await definitions();
|
|
152
|
+
const defs = await definitions(s);
|
|
146
153
|
return region(
|
|
147
154
|
'Load data from a file',
|
|
148
155
|
html`${error ? html`<div class="alert alert-error" role="alert">${error}</div>` : ''}
|
|
@@ -180,7 +187,7 @@ export async function dataLoadRoutes(app: FastifyInstance) {
|
|
|
180
187
|
if (!s) return;
|
|
181
188
|
if (!file) return page(s, reply, await uploadForm(s, 'Choose a file.'));
|
|
182
189
|
if (file.truncated) return page(s, reply, await uploadForm(s, `The file is larger than ${MAX_MB} MB.`));
|
|
183
|
-
const def = await definition(req.body?.definition);
|
|
190
|
+
const def = await definition(s, req.body?.definition);
|
|
184
191
|
const rowTag = (req.body?.row_tag ?? '').trim();
|
|
185
192
|
try {
|
|
186
193
|
if (def) await parseFile(file.filename, file.data, { headers: def.headers, format: def.format, rowTag: def.row_tag });
|
|
@@ -203,7 +210,7 @@ export async function dataLoadRoutes(app: FastifyInstance) {
|
|
|
203
210
|
if (!f) return reply.redirect(`${BASE}/sql/load`);
|
|
204
211
|
const headers = (req.query.h ?? req.body?.h) !== '0';
|
|
205
212
|
const rowTag = (req.query.rt ?? req.body?.rt ?? '').trim();
|
|
206
|
-
const def = await definition(req.query.d ?? req.body?.d);
|
|
213
|
+
const def = await definition(s, req.query.d ?? req.body?.d);
|
|
207
214
|
let sheet: Sheet;
|
|
208
215
|
try {
|
|
209
216
|
sheet = def
|
|
@@ -256,17 +263,19 @@ export async function dataLoadRoutes(app: FastifyInstance) {
|
|
|
256
263
|
${skip}
|
|
257
264
|
<div class="buttons"><button class="btn btn-hot">Create table and load</button></div></form>`;
|
|
258
265
|
|
|
266
|
+
const db = await workshopDb(s);
|
|
259
267
|
const tables = (
|
|
260
|
-
await
|
|
268
|
+
await db.query(
|
|
261
269
|
`select c.oid::regclass::text as name from pg_class c join pg_namespace n on n.oid = c.relnamespace
|
|
262
|
-
where c.relkind in ('r', 'p') and n.nspname !~ '^pg_' and n.nspname not in ('information_schema', 'meta')
|
|
270
|
+
where c.relkind in ('r', 'p') and n.nspname !~ '^pg_' and n.nspname not in ('information_schema', 'meta')
|
|
271
|
+
and has_table_privilege(c.oid, 'INSERT') order by 1`,
|
|
263
272
|
)
|
|
264
273
|
).rows.map((r) => r.name as string);
|
|
265
274
|
const chosen = tables.find((t) => t === (req.query.table ?? req.body?.table));
|
|
266
|
-
const apps = (await owner.query<{ id: number; name: string }>('select id, name from meta.app order by name')).rows;
|
|
275
|
+
const apps = (await owner.query<{ id: number; name: string }>('select id, name from meta.app where meta.app_workspace(id) = $1 order by name', [currentWorkspace(s)])).rows;
|
|
267
276
|
let mapping: Raw | '' = '';
|
|
268
277
|
if (chosen) {
|
|
269
|
-
const cols = await
|
|
278
|
+
const cols = await db.tx((c) => tableColumns(c, chosen));
|
|
270
279
|
const auto = autoMap(sheet.headers, cols);
|
|
271
280
|
const hasPk = cols.some((c) => c.pk);
|
|
272
281
|
mapping = html`<form method="post" action="${action}">${hidden}<input type="hidden" name="target" value="existing"><input type="hidden" name="table" value="${chosen}">
|
|
@@ -335,6 +344,7 @@ export async function dataLoadRoutes(app: FastifyInstance) {
|
|
|
335
344
|
const mode = ['append', 'replace', 'merge'].includes(b.mode ?? '') ? b.mode : 'append';
|
|
336
345
|
const format = sheet instanceof Error ? 'auto' : sheet.format;
|
|
337
346
|
try {
|
|
347
|
+
if (!/^\d{1,9}$/.test(String(b.def_app ?? '')) || (await appWorkspace(String(b.def_app))) !== currentWorkspace(s)) throw new LoadError('Choose an application of this workspace.');
|
|
338
348
|
const r = await owner.one(
|
|
339
349
|
`insert into meta.data_load_def (app_id, name, table_name, format, headers, row_tag, mode, skip_errors, columns)
|
|
340
350
|
values ($1, $2, $3, $4, $5, $6, $7, $8, $9) returning id, app_id`,
|
|
@@ -344,7 +354,7 @@ export async function dataLoadRoutes(app: FastifyInstance) {
|
|
|
344
354
|
return back(reply, s, `${BASE}/apps/${r!.app_id}/shared?c=data_load_def-${r!.id}`);
|
|
345
355
|
} catch (e) {
|
|
346
356
|
const code = (e as { code?: string }).code;
|
|
347
|
-
if (!code?.startsWith('23') && code !== '22P02') throw e;
|
|
357
|
+
if (!(e instanceof LoadError) && !code?.startsWith('23') && code !== '22P02') throw e;
|
|
348
358
|
reply.code(422);
|
|
349
359
|
return targetPage(req, reply, s, html`<div class="alert alert-error" role="alert">The definition could not be saved: ${
|
|
350
360
|
code === '23505' ? `the application already has a definition named ${name}.` : (e as Error).message}</div>`);
|
|
@@ -354,10 +364,10 @@ export async function dataLoadRoutes(app: FastifyInstance) {
|
|
|
354
364
|
let error: string | undefined;
|
|
355
365
|
let failed = false;
|
|
356
366
|
try {
|
|
357
|
-
const def = b.target === 'definition' ? await definition(b.d) : undefined;
|
|
367
|
+
const def = b.target === 'definition' ? await definition(s, b.d) : undefined;
|
|
358
368
|
const sheet = def ? null : await parseFile(f.filename, f.content, { headers, rowTag });
|
|
359
369
|
const opts = { skipErrors: b.skip_errors === 'true', firstRow: headers && sheet && (sheet.format === 'csv' || sheet.format === 'xlsx') ? 2 : 1 };
|
|
360
|
-
result = await
|
|
370
|
+
result = await (await workshopDb(s)).tx(async (c) => {
|
|
361
371
|
if (def) return loadWithDefinition(c, def, f);
|
|
362
372
|
if (!sheet) throw new LoadError('The data load definition no longer exists.');
|
|
363
373
|
if (b.target === 'new') {
|