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.
@@ -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-12 (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).
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 | 8 | 1 | 0 | 0 | Application availability and Run only, automatic backups, user feedback; a developer toolbar with a session state viewer and debug per session; Workspaces that group applications and developers (not a tenant boundary); Top SQL per app; debug messages per request; an install/upgrade log |
26
- | **Total** | **136** | **1** | **0** | **3** | 140 APEX features compared: 97% available, 1% partial |
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:** Quick Edit (pick a component on the page), Theme Roller from the page |
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) | 🟡 | 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)). **Missing:** isolation between tenants (the SQL Workshop and application code run with installation-wide rights; use separate installations), per-workspace user accounts, schemas and workspace administrators |
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/),
@@ -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
- **Workspaces are not a security boundary between developers who write SQL.** The SQL Workshop runs as pgkiln's owner,
40
- and application code (processes, regions, validations) runs on the runtime connection, from which a developer's SQL
41
- can reach other applications' data and metadata. User accounts, identity providers, LDAP directories and AI services
42
- also stay installation-wide. Use workspaces to keep teams' applications apart in the builder; give tenants who must
43
- not see each other's applications or data **separate installations** (databases).
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 two pools, appTx() (SET LOCAL ROLE + pgkiln.* settings, NOTICEs to the debug log), savepoints
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pgkiln",
3
- "version": "0.36.0",
3
+ "version": "0.37.0",
4
4
  "description": "Open source low-code, SQL-driven application builder for PostgreSQL, inspired by Oracle APEX",
5
5
  "keywords": [
6
6
  "postgresql",
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}`);
@@ -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
@@ -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
- const c = (tx ?? (await owner.pool.connect())) as pg.PoolClient;
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(tx ? 'savepoint advisor_all' : 'begin');
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 (tx) {
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
- return (await owner.query<{ n: string }>(
49
- `select nspname as n from pg_namespace where nspname !~ '^pg_' and nspname not in ('information_schema', 'meta') order by 1`)).rows.map((r) => r.n);
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`)).rows;
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 owner.pool.connect();
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'`);
@@ -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
- async function definitions() {
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
- '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 order by a.name, d.name',
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 }>('select * from meta.data_load_def where id = $1', [id]);
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 owner.query(
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') order by 1`,
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 owner.tx((c) => tableColumns(c, chosen));
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 owner.tx(async (c) => {
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') {