pgkiln 0.76.0 → 0.77.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,13 +7,13 @@ 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-11-04 against **APEX 26.2** (sprint 143: the interactive report's 26.2 dialogs: *is one of* / *is not one of* filters with the column's values and counts, *is between* two values (dates with the date picker in the application's format), relative periods on date columns, and the column order by drag and drop or arrow buttons, followed by downloads and the PDF (AP-9); sprint 142: JSON sources with a data profile and updatable views whose arrays are rows, document views of a table with its children (APEX's duality views), JSON path columns in reports (AP-8); sprint 141: static workspace files (`#WORKSPACE_FILES#`), remote servers for REST data sources, catalogs and AI services (an environment variable per environment), and workspace-level web credentials (AP-7); sprint 140: the items Shuttle (ordered), Select One and Select Many, the regions Classic Report (report templates, pagination types), Breadcrumb and Help Text with a page help text, and a [mapping of every APEX item and region type](#item-and-region-types-one-by-one) (AP-4); sprint 139: OpenTelemetry, traces with a span per SQL statement, web call, AI request and tool call, logs, metrics and `/metrics` for Prometheus, browser spans relayed by pgkiln, per workspace off or its own collector (AP-6); sprint 138: an application's Utilities tab with a dashboard, database object dependencies, change history per component, Upgrade application, UI defaults (APEX's Attribute Dictionary), application and page checksums and an icon browser (AP-5); sprint 137: Create page as a grid of page types with previews, the page types interactive report, smart filters, search page, data loading, tree, timeline, media list, content row, comments, dashboard, wizard, unified task list and workflow console, a Feature tab with nine features that install once, and Create page as copy with shared components mapped or copied (AP-3); sprint 136: vector providers (an AI service with an embedding model or a SQL function), `meta.embed`, *Add vector column* in Table Properties with an HNSW index and a queue that keeps embeddings current, search types vector and hybrid (reciprocal rank fusion with full-text) and a Retrieve (RAG) tool for AI agents, all as the app's role through RLS, on pgvector (AP-1); sprint 135: full-text search configurations, APEX's Oracle Text search type on PostgreSQL full-text search with stemming, ranking, highlights, *Did you mean* and *Add full-text search* in Table Properties (AP-2); sprint 134: the builder also in Italiano, 中文 (简体), 中文 (繁體), 日本語 and 한국어, APEX's ten builder languages plus Nederlands (SEC-5); sprints 121, 123–125: the builder in Deutsch, English, Español, Français, Nederlands and Português, step by step: the frame, sign-in, Workspace home, App Builder, Page Designer menus, the SQL Workshop's home, SQL Worksheet, Object Browser and Table Properties, Shared Components, application settings, users, identity providers, developers and workspaces (WS-7); sprint 122: *Ask in your own words* sets faceted search and smart filter facets, smart filters read a typed phrase (WS-5), and Sample Maps, Sample Approvals, Sample Data Loading and Sample Files in the App Gallery (WS-8); sprint 120: the App Gallery's single-topic sample apps (charts, cards, calendar, interactive grids, dynamic actions, reporting), the UX Pattern Catalog and a Utility Apps tab with Lookup Lists, Release Notes and Announcements (WS-8); sprint 119: data security per table, a roles × columns matrix and masked values per application role in a masking view, the same identity for pages, AI tools, REST, AutoREST and MCP (WS-13); sprint 118: Administration → Mail queue with delivery per hour and per workspace, the slowest provider calls and failures with Retry and Purge, a workspace's own mail service, and workspace network access lists that narrow the installation's allow-list (WS-9); sprint 117: Pillar Light and Pillar Dark navigation appearances with a header strip, clearer navigation tree states, subtle colour utilities, RTL; sprint 116: the comparison moved from 26.1 to 26.2, with a section for the 26.2 workspace items WS-1 … WS-13 and OCI managed identities; the App Builder list with Updated by, working copies and the lock; `pgkiln import --only`, a file-level import; stale rows on facet menus and blueprints fixed; sprint 115: popup LOVs that load on scroll, Wait Popup dynamic actions and dynamic actions on menu entries; sprint 114: Workflow 26.2 with custom task outcomes, task events and notifications, Send E-Mail and draft steps, migrating running instances and the diagram as SVG / PNG; sprint 113: Team Development with issues, labels, milestones, templates, a board and a set-up wizard; sprint 112: AI interactive reports with chips, Undo, computed columns and a phrase parser that needs no AI service; sprint 111: AI Agents with tools as a shared component, a Navigate tool with checksummed URLs, Gemini and OpenAI-compatible services; sprint 110: Data Reporter as a workspace tool with datasets, reporting applications, editors and viewers, published reports with stable URLs and pins; sprint 109: the Workspace home above the App Builder and SQL Workshop, with a summary, top applications, users and developers, and an optional release check; sprint 108: the code editor's syntax colours in the builder's light and dark themes, read-only SQL highlighted, the Data Modeler in dark mode; sprint 107: a transaction mode in the SQL Worksheet with Commit and Rollback; DA-18: JavaScript (MLE) not planned, plv8 where installed; sprint 106: the change log of DDL change sets with Flyway, Liquibase and SQL downloads; sprint 104: RESTful Services modules, templates and handlers with PL/pgSQL and media handlers, privileges, OpenAPI and Try it; sprint 103: Schema Export / Import with pg_dump and pg_restore; sprint 102: the SQL Monitor with live sessions, Top SQL and a statement's plan; sprint 101: Scheduling of pg_cron jobs with a schedule builder, run now and run history; sprint 100: RESTful Services, tables and views published as REST endpoints with paging, filters, keys and OpenAPI; sprint 99: Administration → Database Users for PostgreSQL roles; sprint 98: the Data Modeler with relational diagrams, DDL and a data dictionary; sprint 97: charts of a query in the worksheet, saved charts and their dashboard; sprint 96: the SQL Workshop's Launchpad with pins and tool groups, the builder's search with Ctrl+K; sprint 95: Table Properties: create, edit or copy a table in a form with a DDL preview and Apply in one transaction; sprint 94: an object's menu with Open in a panel, Quick DDL, Add index / trigger / policy and Drop with dependents; sprint 93: worksheets saved as SQL Scripts with tabs and a Files list, Format, Convert case, find and replace, bind and substitution variables; sprint 92: the worksheet's Query Result with sort, value dialog, copy and downloads, plans as a tree with the slowest step and misestimates, SQL History with status, search and run again; sprint 91: the SQL Worksheet like Database Actions' SQL: navigator, Run Statement at the cursor and Run Script with a log per statement, Explain Plan, Autotrace, notices, history, Run as an application role; sprint 90: Comment Out in the page designer, gallery labels in title case; sprint 89: the Object Browser's schema, one row of tabs, blank NULLs, Modify column; sprint 88: home cards with colour bands and round icons, the hero on the page background, a page footer with the version and Back to top; sprint 87: the Create Application wizard reloads the page list for a schema, RLS badges, the alias from the name; sprint 86: the page designer by page number, HTML error pages in the builder; sprint 85: the Form Designer, beyond APEX; sprint 84: the date picker, facet menus; sprint 83: Component Settings and Component Groups; sprint 82: Report Queries; sprint 81: Application Computations and Shortcuts in Shared Components; sprint 80: the master grid's first row selected when the page opens and after a search; grid cells as text with their format mask until edited, with cell navigation by arrow keys, Enter, F2 and Escape; sprint 79: generated dashboards with sums, donuts and lines, leaving out charts that say nothing; applications open in their own light or dark mode until the user chooses, per application; a user menu with Appearance and Sign in when signed out; the developer toolbar's App and Page texts and debug state; the report search's column scope in the magnifier; sprint 78: generated faceted search reports with one column per foreign key, the faceted search's total row count, Show all and Yes/No values, one search field, friendly headings and number formats in generated grids and master-detail pages, friendly names in the Create Application wizard's page list; sprint 77: the Page Designer's layout and tree by display position, button positions, the tree's context menu with Duplicate and Copy to other page, Show Common / Show All, Go to group and the Template Options dialog; display positions, button positions and side-column page templates at runtime; sprint 76: the interactive report's menus and pagination, date items in the date format; sprint 75: the SQL Workshop's Object Browser and tabs; sprint 74: the builder's information architecture: Shared Components grid, Settings tabs, application home search and tasks; sprint 73: the Create Application wizard on one page with page types and features; sprint 72: the runtime chrome compared with APEX 26.2's: the developer toolbar, the Iris look, the navigation bar's Help menu and Install button; sprint 71: generated applications compared with APEX 26.2's: labels, foreign key names in reports, file columns, format masks, the Home page and the dashboard; sprint 70: treemap, sunburst and timeline charts; sprint 69: dragging tasks on a Gantt chart; sprint 68: the Data Reporter's welcome page, Find my workspace; sprint 67: REST source catalogs from OpenAPI documents, a workspace's export and import (two rows added); sprint 66: a blueprint from an existing application; sprint 65: facet charts; sprint 64: approval vacation rules and expiration policies; sprint 63: range, box plot and stock charts; sprint 62: workspace preferences (SQL Workshop and RESTful services per workspace), extension links, Manage backups across applications, SQL Workshop utilities with object reports, About Database and a database monitor; sprint 61: the builder's screens compared with APEX 26.1: the workspace sign-in page, the Administration menu with workspace message, environment banner and utilization, the SQL Workshop home, the App Gallery, application groups and cross application reports (four rows added); 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).
10
+ Last reviewed: 2026-11-04 against **APEX 26.2** (sprint 144: `pgkiln advise` for CI with text, JSON and SARIF output (APEXlang static analysis / SQLcl CodeScan), security checks in the Advisor, and working copies and locks from the command line and MCP (the Working Copy APIs) (AP-10); sprint 143: the interactive report's 26.2 dialogs: *is one of* / *is not one of* filters with the column's values and counts, *is between* two values (dates with the date picker in the application's format), relative periods on date columns, and the column order by drag and drop or arrow buttons, followed by downloads and the PDF (AP-9); sprint 142: JSON sources with a data profile and updatable views whose arrays are rows, document views of a table with its children (APEX's duality views), JSON path columns in reports (AP-8); sprint 141: static workspace files (`#WORKSPACE_FILES#`), remote servers for REST data sources, catalogs and AI services (an environment variable per environment), and workspace-level web credentials (AP-7); sprint 140: the items Shuttle (ordered), Select One and Select Many, the regions Classic Report (report templates, pagination types), Breadcrumb and Help Text with a page help text, and a [mapping of every APEX item and region type](#item-and-region-types-one-by-one) (AP-4); sprint 139: OpenTelemetry, traces with a span per SQL statement, web call, AI request and tool call, logs, metrics and `/metrics` for Prometheus, browser spans relayed by pgkiln, per workspace off or its own collector (AP-6); sprint 138: an application's Utilities tab with a dashboard, database object dependencies, change history per component, Upgrade application, UI defaults (APEX's Attribute Dictionary), application and page checksums and an icon browser (AP-5); sprint 137: Create page as a grid of page types with previews, the page types interactive report, smart filters, search page, data loading, tree, timeline, media list, content row, comments, dashboard, wizard, unified task list and workflow console, a Feature tab with nine features that install once, and Create page as copy with shared components mapped or copied (AP-3); sprint 136: vector providers (an AI service with an embedding model or a SQL function), `meta.embed`, *Add vector column* in Table Properties with an HNSW index and a queue that keeps embeddings current, search types vector and hybrid (reciprocal rank fusion with full-text) and a Retrieve (RAG) tool for AI agents, all as the app's role through RLS, on pgvector (AP-1); sprint 135: full-text search configurations, APEX's Oracle Text search type on PostgreSQL full-text search with stemming, ranking, highlights, *Did you mean* and *Add full-text search* in Table Properties (AP-2); sprint 134: the builder also in Italiano, 中文 (简体), 中文 (繁體), 日本語 and 한국어, APEX's ten builder languages plus Nederlands (SEC-5); sprints 121, 123–125: the builder in Deutsch, English, Español, Français, Nederlands and Português, step by step: the frame, sign-in, Workspace home, App Builder, Page Designer menus, the SQL Workshop's home, SQL Worksheet, Object Browser and Table Properties, Shared Components, application settings, users, identity providers, developers and workspaces (WS-7); sprint 122: *Ask in your own words* sets faceted search and smart filter facets, smart filters read a typed phrase (WS-5), and Sample Maps, Sample Approvals, Sample Data Loading and Sample Files in the App Gallery (WS-8); sprint 120: the App Gallery's single-topic sample apps (charts, cards, calendar, interactive grids, dynamic actions, reporting), the UX Pattern Catalog and a Utility Apps tab with Lookup Lists, Release Notes and Announcements (WS-8); sprint 119: data security per table, a roles × columns matrix and masked values per application role in a masking view, the same identity for pages, AI tools, REST, AutoREST and MCP (WS-13); sprint 118: Administration → Mail queue with delivery per hour and per workspace, the slowest provider calls and failures with Retry and Purge, a workspace's own mail service, and workspace network access lists that narrow the installation's allow-list (WS-9); sprint 117: Pillar Light and Pillar Dark navigation appearances with a header strip, clearer navigation tree states, subtle colour utilities, RTL; sprint 116: the comparison moved from 26.1 to 26.2, with a section for the 26.2 workspace items WS-1 … WS-13 and OCI managed identities; the App Builder list with Updated by, working copies and the lock; `pgkiln import --only`, a file-level import; stale rows on facet menus and blueprints fixed; sprint 115: popup LOVs that load on scroll, Wait Popup dynamic actions and dynamic actions on menu entries; sprint 114: Workflow 26.2 with custom task outcomes, task events and notifications, Send E-Mail and draft steps, migrating running instances and the diagram as SVG / PNG; sprint 113: Team Development with issues, labels, milestones, templates, a board and a set-up wizard; sprint 112: AI interactive reports with chips, Undo, computed columns and a phrase parser that needs no AI service; sprint 111: AI Agents with tools as a shared component, a Navigate tool with checksummed URLs, Gemini and OpenAI-compatible services; sprint 110: Data Reporter as a workspace tool with datasets, reporting applications, editors and viewers, published reports with stable URLs and pins; sprint 109: the Workspace home above the App Builder and SQL Workshop, with a summary, top applications, users and developers, and an optional release check; sprint 108: the code editor's syntax colours in the builder's light and dark themes, read-only SQL highlighted, the Data Modeler in dark mode; sprint 107: a transaction mode in the SQL Worksheet with Commit and Rollback; DA-18: JavaScript (MLE) not planned, plv8 where installed; sprint 106: the change log of DDL change sets with Flyway, Liquibase and SQL downloads; sprint 104: RESTful Services modules, templates and handlers with PL/pgSQL and media handlers, privileges, OpenAPI and Try it; sprint 103: Schema Export / Import with pg_dump and pg_restore; sprint 102: the SQL Monitor with live sessions, Top SQL and a statement's plan; sprint 101: Scheduling of pg_cron jobs with a schedule builder, run now and run history; sprint 100: RESTful Services, tables and views published as REST endpoints with paging, filters, keys and OpenAPI; sprint 99: Administration → Database Users for PostgreSQL roles; sprint 98: the Data Modeler with relational diagrams, DDL and a data dictionary; sprint 97: charts of a query in the worksheet, saved charts and their dashboard; sprint 96: the SQL Workshop's Launchpad with pins and tool groups, the builder's search with Ctrl+K; sprint 95: Table Properties: create, edit or copy a table in a form with a DDL preview and Apply in one transaction; sprint 94: an object's menu with Open in a panel, Quick DDL, Add index / trigger / policy and Drop with dependents; sprint 93: worksheets saved as SQL Scripts with tabs and a Files list, Format, Convert case, find and replace, bind and substitution variables; sprint 92: the worksheet's Query Result with sort, value dialog, copy and downloads, plans as a tree with the slowest step and misestimates, SQL History with status, search and run again; sprint 91: the SQL Worksheet like Database Actions' SQL: navigator, Run Statement at the cursor and Run Script with a log per statement, Explain Plan, Autotrace, notices, history, Run as an application role; sprint 90: Comment Out in the page designer, gallery labels in title case; sprint 89: the Object Browser's schema, one row of tabs, blank NULLs, Modify column; sprint 88: home cards with colour bands and round icons, the hero on the page background, a page footer with the version and Back to top; sprint 87: the Create Application wizard reloads the page list for a schema, RLS badges, the alias from the name; sprint 86: the page designer by page number, HTML error pages in the builder; sprint 85: the Form Designer, beyond APEX; sprint 84: the date picker, facet menus; sprint 83: Component Settings and Component Groups; sprint 82: Report Queries; sprint 81: Application Computations and Shortcuts in Shared Components; sprint 80: the master grid's first row selected when the page opens and after a search; grid cells as text with their format mask until edited, with cell navigation by arrow keys, Enter, F2 and Escape; sprint 79: generated dashboards with sums, donuts and lines, leaving out charts that say nothing; applications open in their own light or dark mode until the user chooses, per application; a user menu with Appearance and Sign in when signed out; the developer toolbar's App and Page texts and debug state; the report search's column scope in the magnifier; sprint 78: generated faceted search reports with one column per foreign key, the faceted search's total row count, Show all and Yes/No values, one search field, friendly headings and number formats in generated grids and master-detail pages, friendly names in the Create Application wizard's page list; sprint 77: the Page Designer's layout and tree by display position, button positions, the tree's context menu with Duplicate and Copy to other page, Show Common / Show All, Go to group and the Template Options dialog; display positions, button positions and side-column page templates at runtime; sprint 76: the interactive report's menus and pagination, date items in the date format; sprint 75: the SQL Workshop's Object Browser and tabs; sprint 74: the builder's information architecture: Shared Components grid, Settings tabs, application home search and tasks; sprint 73: the Create Application wizard on one page with page types and features; sprint 72: the runtime chrome compared with APEX 26.2's: the developer toolbar, the Iris look, the navigation bar's Help menu and Install button; sprint 71: generated applications compared with APEX 26.2's: labels, foreign key names in reports, file columns, format masks, the Home page and the dashboard; sprint 70: treemap, sunburst and timeline charts; sprint 69: dragging tasks on a Gantt chart; sprint 68: the Data Reporter's welcome page, Find my workspace; sprint 67: REST source catalogs from OpenAPI documents, a workspace's export and import (two rows added); sprint 66: a blueprint from an existing application; sprint 65: facet charts; sprint 64: approval vacation rules and expiration policies; sprint 63: range, box plot and stock charts; sprint 62: workspace preferences (SQL Workshop and RESTful services per workspace), extension links, Manage backups across applications, SQL Workshop utilities with object reports, About Database and a database monitor; sprint 61: the builder's screens compared with APEX 26.1: the workspace sign-in page, the Administration menu with workspace message, environment banner and utilization, the SQL Workshop home, the App Gallery, application groups and cross application reports (four rows added); 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
 
14
14
  | Area | ✅ | 🟡 | ❌ | ➖ | In short |
15
15
  |---|---:|---:|---:|---:|---|
16
- | App Builder and development | 22 | 0 | 0 | 0 | Page Designer with drag-and-drop and a code editor, wizards, search, where used, an Advisor, a CLI with one file per component, page locks and comments, supporting objects, working copies with a three-way merge, theme/library/boilerplate apps with subscriptions; App Builder AI (pages, SQL and table descriptions) |
16
+ | App Builder and development | 23 | 0 | 0 | 0 | Page Designer with drag-and-drop and a code editor, wizards, search, where used, an Advisor (also `pgkiln advise` for CI with SARIF), a CLI with one file per component, page locks and comments, supporting objects, working copies with a three-way merge, theme/library/boilerplate apps with subscriptions; App Builder AI (pages, SQL and table descriptions) |
17
17
  | Regions | 25 | 0 | 0 | 0 | All everyday regions incl. classic reports with report templates, breadcrumb and help text regions, a search region over search configurations and a URL region; twenty-two chart types with drill-down; calendars with week/day/list views and drag and drop; faceted search, smart filters and a region display selector; interactive reports with breaks, aggregates, highlights, compute, group by, pivot, chart view, saved reports and 26.2's multi-select, date-range and column-order dialogs; interactive grids with single row, group by and chart views; maps (with vector tiles) and trees; template components; row ranges, lazy loading and region caching for large tables |
18
18
  | Items | 15 | 0 | 0 | 0 | All common items incl. shuttle, Select One and Select Many, file upload (several files per item), rich text and Markdown editors, star rating, combobox, date range, QR code, password reveal, display image and geocoded address |
19
19
  | Logic and processing | 18 | 0 | 0 | 1 | User preferences and application settings; all dynamic action events and actions, Ajax callbacks and an error handling function; Core APEX model complete with computations, conditional branches, build options and menu buttons, download, chain and workflow processes; JavaScript from static application files in dynamic actions; region, item, dynamic action and process plug-ins |
@@ -24,7 +24,7 @@ Last reviewed: 2026-11-04 against **APEX 26.2** (sprint 143: the interactive rep
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
25
  | Administration | 15 | 0 | 0 | 0 | OpenTelemetry traces, logs and metrics to the team's own monitoring and `/metrics` for Prometheus (24.2); An APEX-like sign-in with a workspace and a Workspace home; a mail queue dashboard, a workspace's own mail service and network access list (26.1 / 26.2); a workspace's components exported and imported as one file; workspace message, environment banner and utilization; 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
26
  | APEX 26.2 workspace (WS-1 … WS-13) | 13 | 0 | 0 | 1 | Workspace home, Data Reporter as a workspace tool, Team Development, AI Agents, AI interactive reports, Workflow 26.2, the mail queue, workspace mail and network access, the 26.1 component details, the App Builder list's details, the Pillar navigation appearances and data security per table (rows, columns and masked values), the App Gallery's single-topic sample apps, UX Pattern Catalog and Utility Apps; the builder in eleven languages (APEX's ten and Nederlands) |
27
- | **Total** | **170** | **0** | **0** | **3** | 173 APEX features compared: 98% available, none missing, 3 deliberately not planned |
27
+ | **Total** | **171** | **0** | **0** | **3** | 174 APEX features compared: 98% available, none missing, 3 deliberately not planned |
28
28
 
29
29
  (Counts are of the rows in the tables below. A review on 2026-10-06 added 18 rows for APEX features that were
30
30
  not compared before, one on 2026-10-12 added two more (property help, application availability), and one on
@@ -48,6 +48,7 @@ database links row and added static workspace files with workspace web credentia
48
48
  | Shared components | ✅ | (sprint 74) A grid of ten groups with counts like APEX's, a searchable, sortable list per type with Create, and Settings-backed types linking to their Settings tab ([mapping below](#shared-components-apex-links-and-pgkiln)). Navigation menu, **lists** (static entries with nesting, badges, conditions, authorization and build options, or a query; for list regions, the navigation menu and the navigation bar), authorization schemes, build options, lists of values, application items and processes, access control, globalization, template components and plug-ins, web credentials, REST data sources, data load definitions, supporting objects |
49
49
  | Export / import | ✅ | `meta.export_app()` / `meta.import_app()`: portable JSON, also in the builder (new applications; `pgkiln import --replace` and the MCP `import_app` update one in place, live for every user at the commit). (sprint 51) **Checked imports**: `pgkiln import --check` (`--dry-run`, `--force`), the MCP import (by default) and the builder's import run the Advisor inside the import's transaction (SQL planned as the application's role: missing tables, columns, functions, types, grants; references) and refuse on errors, so nothing changes; APEX doesn't check. The version an import replaces is [backed up](guide/03-builder.md#backups) first ([chapter 18](guide/18-cli.md#checking-before-an-import)) |
50
50
  | APEXlang: human-readable, diffable app files; static IDs (26.1) | ✅ | `pgkiln export --format dir`: one JSON file per component with sorted keys, SQL and HTML in sibling files, references by static id instead of database ids; `pgkiln import --replace` updates an app in place, `pgkiln diff` compares ([chapter 18](guide/18-cli.md)). (0.31) **`--format text`**: the same files as YAML (a strict subset any YAML tool reads) with the SQL and templates inline, read back losslessly ([text files](guide/18-cli.md#text-files-apexlang)); regions have a stored **Static ID** (exported as their key, rendered as `data-static-id`), other components are named by their stored names. The syntax is YAML rather than APEXlang's own, so APEXlang files don't import. (sprint 116, 26.2 **file-level import**) `pgkiln import <dir> --replace --only pages/12` (or `--only shared/lovs/jobs.yaml`, repeatable) imports one page or shared component from a directory export and leaves the rest of the application as it is ([chapter 18](guide/18-cli.md#importing-one-page-or-component)) |
51
+ | Static analysis of exports in CI (APEXlang / SQLcl CodeScan), Working Copy APIs (26.2) | ✅ | (sprint 144, AP-10) **`pgkiln advise <export \| alias>`** runs the Advisor outside the builder: an export is imported under a temporary alias in a transaction that is always rolled back (code never runs) and checked there; every SQL fragment is planned as the application's role (stronger than CodeScan's file-only checks: unknown tables, columns, functions, types, missing grants), references are followed, and security defaults are checked (pages that write data without sign-in or an authorization scheme, unrestricted page protection, long sessions, administrators without a second factor). Output as text, JSON or **SARIF 2.1.0** with each finding at the file of its page or component, so GitHub code scanning annotates pull requests; `--fail-on` sets the exit code ([chapter 18](guide/18-cli.md#pgkiln-advise-in-ci)). **`pgkiln working-copy list\|create\|compare\|merge\|refresh\|delete`** and **`pgkiln lock` / `unlock`** for scripts and CI, and the MCP tools `advise_app`, `working_copy` and `lock_app` for coding agents ([working copies and locks](guide/18-cli.md#working-copies-and-locks)). It needs a database (pgkiln's migrations and the application's tables), not the files alone |
51
52
  | Working copies, merge, team development | ✅ | **Working copies** of an application ([chapter 3](guide/03-builder.md#working-copies)): a second app on the same schema and data, compared three ways per component (settings, each shared component, page, region, item, …) with the main app as it was when copied; changes on one side merge, conflicts are chosen per component with a line diff; merge into the main app or refresh the copy, keeping users, sessions, saved reports, secrets and automation switches; locks respected. Conflicts are resolved per component, not per property |
52
53
  | Team Development: issues, labels, milestones, templates, feedback | ✅ | (sprint 113) Builder → **Team Development** ([chapter 3](guide/03-builder.md#team-development)): issues per workspace with labels in groups, milestones, assignees, application and page links, Markdown comments with in-app @mentions and a history; a filtered list with saved views and a board by label group or milestone (no JavaScript needed); a set-up wizard installs sample labels, templates, canned responses and milestones. Users' feedback and developer comments become issues; open issues show in the Page Designer, on App Home and on the Workspace home; issues travel in the workspace export. Not included: e-mail notifications, GitHub sync |
53
54
  | Application lock (26.1), page locks, comments | ✅ | Page and application locks, enforced on the server for every builder change; the owner or an administrator unlocks, and an administrator breaking a lock is logged. Developer comments per page and per app ([chapter 3](guide/03-builder.md)) |
@@ -1609,11 +1609,19 @@ Database code (views, functions, RLS policies) isn't part of the application, so
1609
1609
  component out, and the items computations set or copy); grids without a key or a save process;
1610
1610
  workflow `invoke_api` steps that name a REST data source, parameter or web credential that
1611
1611
  doesn't exist, leave a required parameter empty or use a `&VAR.` no step sets.
1612
+ - **Security** (sprint 144; APEX 26.2: CodeScan's checks): a page that needs no sign-in with a process that writes
1613
+ data (a form or grid save, or SQL with INSERT, UPDATE, DELETE, MERGE or TRUNCATE) is a warning; a signed-in page
1614
+ that writes data with no authorization scheme on the page or the process, in an application that has schemes, is a
1615
+ note; a page with **unrestricted** protection whose items a link can set without a checksum is a note, a warning
1616
+ when it writes data (hidden items never take posted values, so the URL is the way to set them); the installation's
1617
+ session idle time above 120 minutes or session length above 24 hours is a warning; and, as before, administrators
1618
+ who sign in with only a password.
1612
1619
  - **PL/pgSQL functions:** when the `plpgsql_check` extension is installed, the functions in the
1613
1620
  schemas the application's role can use are checked with `plpgsql_check_function_tb` (see
1614
1621
  [extensions](15-extensions.md)).
1615
1622
 
1616
- Findings are errors, warnings or notes, each linking to the component.
1623
+ Findings are errors, warnings or notes, each linking to the component. The same checks run from the command line
1624
+ for CI, with text, JSON or SARIF output: [`pgkiln advise`](18-cli.md#pgkiln-advise-in-ci).
1617
1625
 
1618
1626
  ## Utilities
1619
1627
 
@@ -49,7 +49,10 @@ src/
49
49
  cli/ the command line: main.ts (commands, help, exit codes), apps.ts (connect, export,
50
50
  read, import), files.ts (directories, zip), diff.ts, replace.ts (import --replace in place),
51
51
  mcp.ts (`pgkiln mcp`: the MCP server for AI coding agents, chapter 20), only.ts (sprint 116:
52
- `import --replace --only pages/12`, the named files merged over the application's current export)
52
+ `import --replace --only pages/12`, the named files merged over the application's current export),
53
+ advise.ts (sprint 144: `pgkiln advise`, the Advisor on an export rolled back, text / JSON / SARIF
54
+ with each finding at its file), working-copy.ts (sprint 144: working copies and locks for the
55
+ command line and the MCP tools working_copy and lock_app)
53
56
  app.ts / server.ts Fastify setup / entry point
54
57
  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
55
58
  workspace-db.ts isolated workspaces (090): the isolated applications (cached 5 s), a pool per workspace login (passwords from
@@ -44,6 +44,9 @@ any (see [what is not exported](03-builder.md#export-format)).
44
44
  | `pgkiln export <alias> [--format json\|dir\|text] [--out <path>]` | `json` (default): the `pgkiln/2` document with sorted keys, to `--out` or standard output. `dir`: a directory (default `./<alias>`), see below. `text`: the same directory in YAML with the code inline ([text files](#text-files-apexlang)) |
45
45
  | `pgkiln import <path> [--alias <alias>] [--replace [--only <file>]…] [--check [--force] \| --dry-run]` | Imports a JSON export, an application directory or a `.zip` of one. `--alias` gives the copy another alias. `--replace` updates the application with that alias in place (after a [backup](03-builder.md#backups)); with `--only pages/12` (repeatable) only that page or component file ([below](#importing-one-page-or-component)). `--check` checks the application before it commits, `--dry-run` only checks ([below](#checking-before-an-import)) |
46
46
  | `pgkiln diff <alias> <path> [--name-only \| --quiet]` | What differs between the application in the database and a directory (or JSON file, or zip) |
47
+ | `pgkiln advise <path\|alias> [--format text\|json\|sarif] [--fail-on error\|warning\|info\|never] [-o <file>] [--base <dir>]` | (0.77) The [Advisor](03-builder.md#advisor) for CI: an export is imported into a transaction that is always rolled back and checked there; an alias checks the application in the database. SARIF points each finding at its file, so pull requests get annotations. Exit code 1 from `--fail-on` (default `error`) ([below](#pgkiln-advise-in-ci)) |
48
+ | `pgkiln working-copy list\|create\|compare\|merge\|refresh\|delete <alias> [<name>] [--prefer copy\|main] [--take <component>]… [--as <developer>]` | (0.77) [Working copies](03-builder.md#working-copies) from the command line ([below](#working-copies-and-locks)) |
49
+ | `pgkiln lock <alias> [--page <n>] [--note <text>]` / `pgkiln unlock <alias> [--page <n>] [--force]` | (0.77) The builder's application and page locks; another developer's lock is broken only with `--force` (logged) |
47
50
  | `pgkiln ddl <schema> [--no-comments] [--no-grants] [--out <file>]` | The DDL of a schema's objects, in an order that runs (as SQL Workshop → DDL, [chapter 3](03-builder.md#sql-workshop)) |
48
51
  | `pgkiln compare <source> <target> [--script]` | Compares two schemas: `+` only in the source, `-` only in the target, `~` different; exit code 1 when they differ. `--script` writes the script that makes the target like the source (drops commented out); nothing is run |
49
52
  | `pgkiln mcp` | An MCP server on standard input/output for AI coding agents, see [chapter 20](20-ai-agents.md) |
@@ -273,6 +276,77 @@ Import page has the check ticked.
273
276
  leaves dot files (`.git`, `.gitattributes`) alone; it refuses a non-empty directory without a
274
277
  `pgkiln.json`, so a typo can't empty the wrong folder.
275
278
 
279
+ ### pgkiln advise in CI
280
+
281
+ (sprint 144; APEX 26.2: APEXlang static analysis with SQLcl CodeScan) `pgkiln advise` runs the
282
+ [Advisor](03-builder.md#advisor) outside the builder: every SQL fragment planned as the application's role, the
283
+ references, and the security checks (pages that write data without sign-in or an authorization scheme, unrestricted
284
+ page protection, long sessions, administrators without a second factor). Given a path (directory, `.zip` or JSON
285
+ export), it imports the application under a temporary alias in a transaction that is always rolled back: nothing
286
+ changes, and process code, supporting objects and plug-in SQL are never run. Given an alias, it checks the application
287
+ in the database (also rolled back).
288
+
289
+ It needs a database: pgkiln's migrations for the `meta` schema, and the application's own tables, so the SQL plans
290
+ against what the new version needs. In CI that is a PostgreSQL service, `pgkiln migrate` and your own migrations:
291
+
292
+ ```yaml
293
+ # .github/workflows/pgkiln.yml
294
+ jobs:
295
+ advise:
296
+ runs-on: ubuntu-latest
297
+ permissions: { contents: read, security-events: write }
298
+ services:
299
+ postgres:
300
+ image: postgres:17
301
+ env: { POSTGRES_USER: pgkiln, POSTGRES_PASSWORD: pgkiln, POSTGRES_DB: pgkiln }
302
+ ports: ['5432:5432']
303
+ options: --health-cmd pg_isready --health-interval 2s --health-retries 30
304
+ env:
305
+ DATABASE_URL: postgres://pgkiln:pgkiln@localhost:5432/pgkiln
306
+ steps:
307
+ - uses: actions/checkout@v4
308
+ - uses: actions/setup-node@v4
309
+ with: { node-version: 22 }
310
+ - run: npx pgkiln migrate
311
+ - run: psql "$DATABASE_URL" -f db/schema.sql # your application's tables
312
+ - run: npx pgkiln advise apps/sales --format sarif -o advise.sarif --fail-on warning
313
+ - uses: github/codeql-action/upload-sarif@v3
314
+ if: always()
315
+ with: { sarif_file: advise.sarif }
316
+ ```
317
+
318
+ | Format | What it gives |
319
+ |---|---|
320
+ | `text` (default) | one finding per line with its file under it, and a summary line |
321
+ | `json` | `{ alias, checked, counts: { error, warning, info }, findings: [{ severity, where, field, message, page, kind, file }] }` |
322
+ | `sarif` | SARIF 2.1.0: a result per finding with its level (error, warning, note), a stable rule id (`pgkiln/region/source`) and the file of its page or component in the export (`apps/sales/pages/0002-orders/regions/0010-order-list.yaml`), so GitHub code scanning annotates the pull request |
323
+
324
+ The paths are relative to the current directory. With an alias there is no directory: `--base apps/sales` names
325
+ the folder its export is kept in. `--fail-on` sets the exit code: 1 when a finding is at that level or worse
326
+ (`error` by default; `never` always 0); 2 for a usage error, 3 when the database can't be reached.
327
+
328
+ ### Working copies and locks
329
+
330
+ (sprint 144; APEX 26.2: the Application and Working Copy APIs) The builder's [working copies](03-builder.md#working-copies)
331
+ and locks, for scripts and CI:
332
+
333
+ ```sh
334
+ pgkiln working-copy create sales "feature x" --as alice # Working copy feature x of sales: application sales-feature-x (812)
335
+ pgkiln export sales-feature-x --format text -o apps/sales-feature-x # change the copy's files …
336
+ pgkiln import apps/sales-feature-x --replace --check
337
+ pgkiln working-copy compare sales-feature-x # copy / main / CONFLICT per component, exit 1 with conflicts
338
+ pgkiln working-copy merge sales-feature-x --take pages/0002/page --as alice
339
+ pgkiln working-copy delete sales-feature-x
340
+ pgkiln lock sales --page 2 --note "release 1.4" --as alice
341
+ pgkiln unlock sales --page 2 --as alice
342
+ ```
343
+
344
+ A merge needs a choice for every conflict (a component changed in both): `--prefer copy` or `--prefer main` for all
345
+ of them, or `--take <component>` (as `compare` lists them) for each that keeps the copy's version. It is refused while
346
+ another developer has locked the target application or a page it changes, as in the builder. Changes go to the
347
+ activity log under `--as`, else `PGKILN_DEVELOPER`, else the operating system user. The MCP server has the same as
348
+ the tools `advise_app`, `working_copy` and `lock_app` ([chapter 20](20-ai-agents.md#tools)).
349
+
276
350
  ### diff
277
351
 
278
352
  `pgkiln diff hr apps/hr` exports the application in memory and compares file by file:
@@ -75,6 +75,9 @@ directory the agent started the server in (the project).
75
75
  | `recent_errors` | the latest errors and refused requests from the activity log | no |
76
76
  | `export_app` | writes an application to a directory (default `apps/<alias>`, format `text`) | files on disk |
77
77
  | `diff_app` | what an import of a directory would change (A/D/M with unified diffs); also reports files it can't read | no |
78
+ | `advise_app` | (0.77) the Advisor on an application or an export (imported and rolled back): SQL planned as the application's role, references, security checks, each finding with its file | no |
79
+ | `working_copy` | (0.77) `list`, `create`, `compare`, `merge`, `refresh` or `delete` working copies; conflicts need `prefer` (copy or main) or `take` (component ids); a merge is refused while another developer has a lock on it | the database |
80
+ | `lock_app` | (0.77) locks an application or a page (`unlock: true` removes it; another developer's lock only with `force: true`, logged) | the database |
78
81
  | `import_app` | imports a directory, zip or JSON file; `replace: true` updates the application in place. It checks the application first (the Advisor, in the import's transaction): errors refuse the import and are listed; `force: true` imports anyway, `dry_run: true` only checks | the database |
79
82
 
80
83
  The files are the [directory format](18-cli.md#the-directory-format) of the command line, in the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pgkiln",
3
- "version": "0.76.0",
3
+ "version": "0.77.0",
4
4
  "description": "Open source low-code, SQL-driven application builder for PostgreSQL, inspired by Oracle APEX",
5
5
  "keywords": [
6
6
  "postgresql",
@@ -3,6 +3,7 @@ import pg from 'pg';
3
3
  import { applyBinds, splitStatements } from '../binds.ts';
4
4
  import { owner, runtime, type Db } from '../db.ts';
5
5
  import { devDbForApp } from '../workspace-db.ts';
6
+ import { settingFrom } from '../instance.ts';
6
7
  import { html, type Raw } from '../html.ts';
7
8
  import { icon } from '../icons.ts';
8
9
  import { templateProblem } from '../runtime/document.ts';
@@ -154,6 +155,49 @@ export async function checkSql(c: pg.PoolClient, sql: string, shape: SqlShape):
154
155
  return unchecked.length ? { severity: 'info', message: `Not checked (only SELECT, INSERT, UPDATE, DELETE, MERGE and DO can be checked without running them): ${unchecked.join(', ')}` } : null;
155
156
  }
156
157
 
158
+ /** process types that write data (sprint 144: the security checks below; a sql process when its code has DML) */
159
+ const WRITES = ['form_dml', 'grid_dml', 'sql'];
160
+ /** (sprint 144) session limits above which the Advisor warns (APEX CodeScan's session limits) */
161
+ export const SESSION_LIMITS = { idleMinutes: 120, maxHours: 24 };
162
+
163
+ /**
164
+ * (sprint 144, AP-10; APEX 26.2: APEXlang static analysis / SQLcl CodeScan) Security defaults of an application:
165
+ * - a page anyone can open (no sign-in) with a process that writes data;
166
+ * - a signed-in page that writes data with neither the page nor the process behind an authorization scheme, in an
167
+ * application that has schemes (info: every signed-in user may use it);
168
+ * - a page with unrestricted protection whose items take values from the URL without a checksum (APEX: value
169
+ * protection; hidden items never take posted values in pgkiln, so URL values are the way in);
170
+ * - the installation's session limits above SESSION_LIMITS.
171
+ */
172
+ async function securityFindings(appId: number, query: (sql: string, params?: unknown[]) => Promise<pg.QueryResult>, byKey: Map<string, Entry>): Promise<Finding[]> {
173
+ const out: Finding[] = [];
174
+ const schemes = (await query('select count(*)::int as n from meta.authz_scheme where app_id = $1', [appId])).rows[0].n as number;
175
+ const pages = (await query(
176
+ `select p.id, p.page_no, p.requires_auth, p.authz, p.protection,
177
+ (select array_agg(pr.name order by pr.seq) from meta.process pr where pr.page_id = p.id and pr.type = any($2) and pr.authz is null and coalesce(pr.build_option, '') <> 'COMMENTED_OUT'
178
+ and (pr.type <> 'sql' or pr.code ~* '\\m(insert|update|delete|merge|truncate)\\M')) as writes,
179
+ (select count(*)::int from meta.item i where i.page_id = p.id and i.type <> 'display') as items
180
+ from meta.page p where p.app_id = $1 and coalesce(p.build_option, '') <> 'COMMENTED_OUT' order by p.page_no`, [appId, WRITES])).rows;
181
+ for (const p of pages) {
182
+ const entry = byKey.get(`page-${p.id}`) ?? null;
183
+ const writes: string[] = p.writes ?? [];
184
+ if (writes.length && !p.requires_auth)
185
+ out.push({ severity: 'warning', entry, field: 'Security → Authentication', message: `Page ${p.page_no} needs no sign-in, and its process ${writes.join(', ')} writes data: anyone can run it. Require authentication, or put the process behind an authorization scheme.` });
186
+ else if (writes.length && !p.authz && schemes)
187
+ out.push({ severity: 'info', entry, field: 'Security → Authorization scheme', message: `Page ${p.page_no} writes data (${writes.join(', ')}) with no authorization scheme on the page or the process: every signed-in user may use it.` });
188
+ if (p.protection === 'unrestricted' && p.items)
189
+ out.push({ severity: writes.length ? 'warning' : 'info', entry, field: 'Security → Page protection', message: `Page ${p.page_no} is unrestricted: a link can set its items without a checksum${writes.length ? ', and the page writes data' : ''}. Use checksum protection unless the URL is meant to be typed.` });
190
+ }
191
+ const settings = (await query(`select name, value from meta.setting where name in ('session_idle_minutes', 'session_max_hours')`).catch(() => ({ rows: [] }))).rows;
192
+ const idle = settingFrom('session_idle_minutes', settings);
193
+ const max = settingFrom('session_max_hours', settings);
194
+ if (idle > SESSION_LIMITS.idleMinutes)
195
+ out.push({ severity: 'warning', entry: null, field: 'Instance settings → Session idle time', message: `Sessions end after ${idle} minutes without requests; more than ${SESSION_LIMITS.idleMinutes} leaves unattended sessions open.` });
196
+ if (max > SESSION_LIMITS.maxHours)
197
+ out.push({ severity: 'warning', entry: null, field: 'Instance settings → Maximum session length', message: `Sessions last up to ${max} hours; more than ${SESSION_LIMITS.maxHours} keeps a stolen session usable for long.` });
198
+ return out;
199
+ }
200
+
157
201
  const ITEM_REF = /(?<![A-Za-z0-9_$&:])(?::|&)(P\d+_[A-Z0-9_]+)\b/gi;
158
202
  const BUILT_IN = new Set(['MUST_NOT_BE_PUBLIC_USER']);
159
203
 
@@ -177,7 +221,11 @@ export async function advise(appId: number, tx?: pg.ClientBase, opts: { db?: Db
177
221
  ? await query(`select t.*, p.page_no from ${table} t join meta.page p on p.id = t.page_id where p.app_id = $1`, [appId])
178
222
  : await query(`select * from ${table} where app_id = $1`, [appId])
179
223
  ).rows.map((row) => ({ kind, row }));
180
- const all = (await Promise.all(Object.entries(COMPONENTS).map(([kind, spec]) => rowsOf(kind, spec.table, spec.scope)))).flat();
224
+ // one client (an import's transaction) runs one query at a time; the pool may run them side by side
225
+ const lists: { kind: string; row: any }[][] = [];
226
+ if (tx) for (const [kind, spec] of Object.entries(COMPONENTS)) lists.push(await rowsOf(kind, spec.table, spec.scope));
227
+ else lists.push(...(await Promise.all(Object.entries(COMPONENTS).map(([kind, spec]) => rowsOf(kind, spec.table, spec.scope)))));
228
+ const all = lists.flat();
181
229
 
182
230
  // metadata checks, as the owner (before the role switch below: the application's role can't read meta)
183
231
  // (087) the application's login page: a page that needs no sign-in, with a login region
@@ -201,6 +249,7 @@ export async function advise(appId: number, tx?: pg.ClientBase, opts: { db?: Db
201
249
  for (const p of (await query(`select p.page_no, p.page_group from meta.page p where p.app_id = $1 and p.page_group is not null
202
250
  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)
203
251
  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).` });
252
+ findings.push(...(await securityFindings(appId, query, byKey)));
204
253
  // ---- SQL, as the application's role, rolled back (a savepoint in the caller's transaction)
205
254
  // (090) an isolated workspace's application: planned on the workspace's developer login, in a
206
255
  // transaction of its own (planning can run the application's functions; RESET ROLE there only
@@ -7,7 +7,7 @@ import { clientIp, logActivity } from '../session.ts';
7
7
  import {
8
8
  compare, copiesOf, copyOf, createCopy, deleteCopy, fingerprint, mergeCopy, WorkingCopyError, type Change,
9
9
  } from '../workingcopy.ts';
10
- import { lockText, type Lock } from './locks.ts';
10
+ import { lockedFor } from '../cli/working-copy.ts';
11
11
  import { appHeader, back, BASE, csrf, developer, flash, input, region, send, shell, type Req } from './ui.ts';
12
12
  import { appAllowed } from './workspaces.ts';
13
13
 
@@ -24,16 +24,6 @@ const appRow = async (id: string) => (/^\d{1,9}$/.test(id) ? owner.one('select i
24
24
  const SIDE: Record<string, string> = { added: 'added', deleted: 'deleted', changed: 'changed' };
25
25
  const STATUS: Record<Change['status'], string> = { copy: 'Changed in the copy', main: 'Changed in the main application', conflict: 'Conflict: changed on both sides' };
26
26
 
27
- /** Another developer's lock on the target application or one of the pages a merge changes. */
28
- const lockedFor = (username: string) => async (appId: number, pages: Set<number>) => {
29
- const l = await owner.one<Lock>(
30
- `select app_id, page_no, locked_by, locked_at::text, note from meta.builder_lock
31
- where app_id = $1 and locked_by <> $2 and (page_no = 0 or page_no = any($3::int[])) order by page_no limit 1`,
32
- [appId, username, [...pages]],
33
- );
34
- return l ? `${lockText(l)} The change is refused until it is unlocked.` : null;
35
- };
36
-
37
27
  function diffPanel(ch: Change) {
38
28
  const paths = [...new Set([...(ch.main?.keys() ?? []), ...(ch.copy?.keys() ?? [])])].sort();
39
29
  return html`<div id="diff">
@@ -0,0 +1,177 @@
1
+ // (sprint 144, AP-10; APEX 26.2: APEXlang static analysis with SQLcl CodeScan) `pgkiln advise`: the builder's
2
+ // Advisor from the command line, for CI. An export (directory, .zip or JSON) is imported into a transaction that is
3
+ // always rolled back and checked there, like `pgkiln import --dry-run`; an application already in the database is
4
+ // checked in place (also rolled back). Every SQL fragment is planned as the application's role, references are
5
+ // followed, and the security defaults are checked (src/builder/advisor.ts). The findings come out as text, JSON or
6
+ // SARIF 2.1.0 (GitHub code scanning annotates pull requests with it); each one points at the export's file of its
7
+ // page or component when there is one.
8
+ import { existsSync } from 'node:fs';
9
+ import { join, relative } from 'node:path';
10
+ import { docToFiles, slug, type FileMap } from '../appfiles.ts';
11
+ import { exportDoc, importDoc, readSource, UsageError, type Db } from './apps.ts';
12
+
13
+ export type Severity = 'error' | 'warning' | 'info';
14
+ export const SEVERITIES: Severity[] = ['error', 'warning', 'info'];
15
+
16
+ export interface AdviseFinding {
17
+ severity: Severity;
18
+ /** e.g. "page 3, region Employees", or "application" */
19
+ where: string;
20
+ field: string;
21
+ message: string;
22
+ /** the component's kind (region, item, lov …), for the SARIF rule, and its name */
23
+ kind: string | null;
24
+ label: string;
25
+ pageNo: number | null;
26
+ /** the export's file of the component (relative to the export's directory), when it has one */
27
+ file: string | null;
28
+ }
29
+
30
+ export interface AdviseResult {
31
+ alias: string;
32
+ /** where the files are, for the paths in the output: the export's directory (or null for an application) */
33
+ dir: string | null;
34
+ checked: number;
35
+ findings: AdviseFinding[];
36
+ }
37
+
38
+ const pad = (n: number) => String(n).padStart(4, '0');
39
+ const stem = (path: string) => (path.split('/').pop() ?? '').replace(/\.[a-z]+$/, '').replace(/^\d{4}-/, '');
40
+
41
+ /** The export's file of a component: its page's folder and its slug, a shared component by its slug, else the page or app file. */
42
+ export function fileOf(files: string[], f: { pageNo: number | null; kind: string | null; label: string }): string | null {
43
+ const yaml = files.filter((p) => /\.(ya?ml|json)$/.test(p));
44
+ const name = slug(f.kind === 'page' ? '' : f.label);
45
+ if (f.pageNo !== null) {
46
+ const dir = yaml.filter((p) => p.startsWith(`pages/${pad(f.pageNo!)}-`));
47
+ if (!dir.length) return null;
48
+ const own = name ? dir.find((p) => !/\/page\.(ya?ml|json)$/.test(p) && stem(p) === name) : undefined;
49
+ return own ?? dir.find((p) => /\/page\.(ya?ml|json)$/.test(p)) ?? dir[0];
50
+ }
51
+ const shared = name ? yaml.find((p) => p.startsWith('shared/') && stem(p) === name) : undefined;
52
+ return shared ?? yaml.find((p) => /^app\.(ya?ml|json)$/.test(p)) ?? null;
53
+ }
54
+
55
+ /**
56
+ * Check an export (path) or an application in the database (alias). Nothing is ever committed: an export is
57
+ * imported under a temporary alias in a transaction that is rolled back.
58
+ */
59
+ export async function adviseTarget(db: Db, target: string): Promise<AdviseResult> {
60
+ const { advise } = await import('../builder/advisor.ts');
61
+ let files: FileMap;
62
+ let dir: string | null = null;
63
+ let alias: string;
64
+ let id: number;
65
+ let checked = 0;
66
+ const findings: AdviseFinding[] = [];
67
+ const collect = async (appId: number) => {
68
+ const r = await advise(appId, db);
69
+ checked = r.checked;
70
+ for (const f of r.findings) {
71
+ const kind = f.entry?.kind ?? null;
72
+ const pageNo = f.entry?.pageNo ?? (/^Page (\d+)\b/.exec(f.field) ? Number(/^Page (\d+)\b/.exec(f.field)![1]) : null);
73
+ findings.push({
74
+ severity: f.severity,
75
+ where: f.entry ? (kind === 'page' ? f.entry.label : `${f.entry.pageNo !== null ? `page ${f.entry.pageNo}, ` : ''}${f.entry.kindLabel.toLowerCase()} ${f.entry.label}`) : pageNo !== null ? `page ${pageNo}` : 'application',
76
+ field: f.field,
77
+ message: f.message,
78
+ kind,
79
+ label: f.entry?.label ?? '',
80
+ pageNo,
81
+ file: null,
82
+ });
83
+ }
84
+ };
85
+ if (existsSync(target)) {
86
+ const source = await readSource(target);
87
+ files = source.files ?? docToFiles(source.doc);
88
+ dir = source.files ? target : null;
89
+ alias = String(source.doc?.app?.alias ?? 'app');
90
+ // a free alias for the check: the export's own may be taken by the application it came from
91
+ const temp = `advise-${process.pid}-${Date.now().toString(36)}`.slice(0, 40);
92
+ await db.query('begin');
93
+ try {
94
+ id = (await db.query('select meta.import_app($1::jsonb, $2) as id', [JSON.stringify(source.doc), temp])).rows[0].id;
95
+ await collect(id);
96
+ } finally {
97
+ await db.query('rollback');
98
+ }
99
+ } else {
100
+ if (/[/\\]|\.(json|zip)$/i.test(target)) throw new UsageError(`${target} not found`);
101
+ alias = target;
102
+ const row = (await db.query('select id from meta.app where alias = $1', [alias])).rows[0];
103
+ if (!row) throw new UsageError(`application ${alias} not found (pgkiln apps lists them), and no file or directory ${alias} exists`);
104
+ id = row.id;
105
+ files = docToFiles(await exportDoc(db, alias), 'text');
106
+ await db.query('begin');
107
+ try {
108
+ await collect(id);
109
+ } finally {
110
+ await db.query('rollback');
111
+ }
112
+ }
113
+ const paths = [...files.keys()];
114
+ for (const f of findings) f.file = fileOf(paths, f);
115
+ return { alias, dir, checked, findings };
116
+ }
117
+
118
+ // ------------------------------------------------------------------ output
119
+
120
+ const order = (s: Severity) => SEVERITIES.indexOf(s);
121
+
122
+ /** Whether the findings fail the run: any at or above the level ("never": no). */
123
+ export function fails(findings: AdviseFinding[], failOn: Severity | 'never') {
124
+ return failOn !== 'never' && findings.some((f) => order(f.severity) <= order(failOn));
125
+ }
126
+
127
+ /** A finding's path for the output: inside the export's directory, relative to the current directory. */
128
+ const pathOf = (r: AdviseResult, f: AdviseFinding, base: string | null) =>
129
+ f.file === null ? null : base !== null ? join(base, f.file).replace(/\\/g, '/') : r.dir ? relative(process.cwd(), join(r.dir, f.file)).replace(/\\/g, '/') : f.file;
130
+
131
+ export function formatText(r: AdviseResult, base: string | null = null) {
132
+ const count = (s: Severity) => r.findings.filter((f) => f.severity === s).length;
133
+ return r.findings.map((f) => {
134
+ const path = pathOf(r, f, base);
135
+ return `${f.severity.toUpperCase().padEnd(7)} ${f.where}${f.field ? ` (${f.field})` : ''}: ${f.message}${path ? `\n ${path}` : ''}\n`;
136
+ }).join('') + `${r.alias}: ${r.checked} SQL fragment(s) checked; ${count('error')} error(s), ${count('warning')} warning(s), ${count('info')} note(s).\n`;
137
+ }
138
+
139
+ export function formatJson(r: AdviseResult, base: string | null = null) {
140
+ return JSON.stringify({
141
+ alias: r.alias,
142
+ checked: r.checked,
143
+ counts: Object.fromEntries(SEVERITIES.map((s) => [s, r.findings.filter((f) => f.severity === s).length])),
144
+ findings: r.findings.map((f) => ({ severity: f.severity, where: f.where, field: f.field, message: f.message, page: f.pageNo, kind: f.kind, file: pathOf(r, f, base) })),
145
+ }, null, 2) + '\n';
146
+ }
147
+
148
+ /** A finding's rule: the component kind and the field (stable across runs, so code scanning can track it). */
149
+ const ruleOf = (f: AdviseFinding) => `pgkiln/${f.kind ?? (f.pageNo !== null ? 'page' : 'application')}/${slug(f.field.replace(/^Page \d+ → /, '')) || 'check'}`;
150
+
151
+ /** SARIF 2.1.0 (GitHub code scanning, other CI tools): one result per finding, at its file when it has one. */
152
+ export function formatSarif(r: AdviseResult, version: string, base: string | null = null) {
153
+ const level = { error: 'error', warning: 'warning', info: 'note' } as const;
154
+ const rules = new Map<string, { id: string; shortDescription: { text: string } }>();
155
+ const results = r.findings.map((f) => {
156
+ const ruleId = ruleOf(f);
157
+ if (!rules.has(ruleId)) rules.set(ruleId, { id: ruleId, shortDescription: { text: `${f.kind ?? 'application'}: ${f.field || 'check'}` } });
158
+ const path = pathOf(r, f, base);
159
+ return {
160
+ ruleId,
161
+ level: level[f.severity],
162
+ message: { text: `${f.where}${f.field ? ` (${f.field})` : ''}: ${f.message}` },
163
+ locations: [{
164
+ ...(path ? { physicalLocation: { artifactLocation: { uri: path }, region: { startLine: 1 } } } : {}),
165
+ logicalLocations: [{ name: f.where, kind: 'object' }],
166
+ }],
167
+ };
168
+ });
169
+ return JSON.stringify({
170
+ $schema: 'https://json.schemastore.org/sarif-2.1.0.json',
171
+ version: '2.1.0',
172
+ runs: [{
173
+ tool: { driver: { name: 'pgkiln advise', version, informationUri: 'https://github.com/pgkiln/pgkiln', rules: [...rules.values()] } },
174
+ results,
175
+ }],
176
+ }, null, 2) + '\n';
177
+ }
package/src/cli/apps.ts CHANGED
@@ -95,7 +95,7 @@ export async function importDoc(db: Db, doc: Doc, { alias, replace, check = 'off
95
95
  const { advise } = await import('../builder/advisor.ts');
96
96
  findings = (await advise(id, db)).findings.map((f) => ({
97
97
  severity: f.severity,
98
- where: f.entry ? `${f.entry.pageNo !== null ? `page ${f.entry.pageNo}, ` : ''}${f.entry.kindLabel.toLowerCase()} ${f.entry.label}` : 'database',
98
+ where: f.entry ? (f.entry.kind === 'page' ? f.entry.label : `${f.entry.pageNo !== null ? `page ${f.entry.pageNo}, ` : ''}${f.entry.kindLabel.toLowerCase()} ${f.entry.label}`) : 'database',
99
99
  field: f.field,
100
100
  message: f.message,
101
101
  }));
package/src/cli/main.ts CHANGED
@@ -84,6 +84,28 @@ async function checkPassword(password: string, username: string) {
84
84
  }
85
85
  }
86
86
 
87
+ /** (sprint 144) pgkiln lock / unlock */
88
+ async function lockCommand(v: Values, alias: string, what: 'lock' | 'unlock') {
89
+ const by = (v.as as string) ?? process.env.PGKILN_DEVELOPER ?? userInfo().username;
90
+ const page = v.page === undefined ? null : Number(v.page);
91
+ if (page !== null && !(Number.isInteger(page) && page > 0)) throw new UsageError('--page: a page number');
92
+ process.env.RUNTIME_DATABASE_URL ??= process.env.DATABASE_URL;
93
+ const wc = await import('./working-copy.ts');
94
+ const { closePools } = await import('../db.ts');
95
+ try {
96
+ out(what === 'lock' ? await wc.lockApp(alias, page, by, (v.note as string) ?? null) : await wc.unlockApp(alias, page, by, !!v.force));
97
+ return EXIT.ok;
98
+ } catch (e) {
99
+ if (e instanceof wc.WorkingCopyError) {
100
+ err(`pgkiln ${what}: ${e.message}\n`);
101
+ return EXIT.differences;
102
+ }
103
+ throw e;
104
+ } finally {
105
+ await closePools();
106
+ }
107
+ }
108
+
87
109
  // ------------------------------------------------------------------ commands
88
110
 
89
111
  const COMMANDS: Record<string, Command> = {
@@ -309,6 +331,133 @@ const COMMANDS: Record<string, Command> = {
309
331
  },
310
332
  },
311
333
 
334
+ advise: {
335
+ usage: 'pgkiln advise <path | alias> [--format text|json|sarif] [--fail-on error|warning|info|never] [-o <file>]',
336
+ summary: 'check an export or an application with the Advisor (for CI: text, JSON or SARIF)',
337
+ details:
338
+ 'Runs the builder\'s Advisor: every SQL fragment is planned as the application\'s database role (unknown\n' +
339
+ 'tables, columns, functions, types and missing grants), references to pages, items, lists of values and\n' +
340
+ 'schemes are followed, and security defaults are checked (pages that write data without sign-in or an\n' +
341
+ 'authorization scheme, unrestricted page protection, long sessions, administrators without a second factor).\n\n' +
342
+ 'A path (an export directory, a .zip or a JSON export) is imported into a transaction that is rolled back:\n' +
343
+ 'nothing changes. It needs a database with pgkiln\'s migrations and the application\'s own tables (in CI, a\n' +
344
+ 'PostgreSQL service, pgkiln migrate and your migrations). An alias checks the application in the database.\n\n' +
345
+ 'SARIF points every finding at the file of its page or component in the export, so GitHub code scanning\n' +
346
+ 'annotates the pull request. Exit code 1 when a finding is at or above --fail-on (default: error).',
347
+ options: { format: { type: 'string', short: 'f' }, 'fail-on': { type: 'string' }, output: { type: 'string', short: 'o' }, base: { type: 'string' } },
348
+ optionHelp: [
349
+ ['-f, --format <format>', 'text (default), json or sarif'],
350
+ ['--fail-on <level>', 'exit code 1 from this level: error (default), warning, info or never'],
351
+ ['-o, --output <file>', 'write the report to a file (the summary still goes to standard error)'],
352
+ ['--base <dir>', 'with an alias: the folder its export lives in, for the paths in the report (e.g. apps/hr)'],
353
+ ],
354
+ positionals: [1, 1],
355
+ async run(v, [target]) {
356
+ const format = (v.format as string) ?? 'text';
357
+ const failOn = (v['fail-on'] as string) ?? 'error';
358
+ if (!['text', 'json', 'sarif'].includes(format)) throw new UsageError(`--format: text, json or sarif, not ${format}`);
359
+ if (!['error', 'warning', 'info', 'never'].includes(failOn)) throw new UsageError(`--fail-on: error, warning, info or never, not ${failOn}`);
360
+ process.env.RUNTIME_DATABASE_URL ??= process.env.DATABASE_URL;
361
+ const { adviseTarget, fails, formatJson, formatSarif, formatText } = await import('./advise.ts');
362
+ const { closePools } = await import('../db.ts');
363
+ try {
364
+ const r = await withDb((db) => adviseTarget(db, target));
365
+ const base = (v.base as string) ?? null;
366
+ const report = format === 'sarif' ? formatSarif(r, version(), base) : format === 'json' ? formatJson(r, base) : formatText(r, base);
367
+ if (v.output) {
368
+ writeFileSync(v.output as string, report);
369
+ const count = (s: string) => r.findings.filter((f) => f.severity === s).length;
370
+ err(`${r.alias}: ${count('error')} error(s), ${count('warning')} warning(s), ${count('info')} note(s); report in ${v.output}.\n`);
371
+ } else out(report);
372
+ return fails(r.findings, failOn as 'error') ? EXIT.differences : EXIT.ok;
373
+ } finally {
374
+ await closePools();
375
+ }
376
+ },
377
+ },
378
+
379
+ 'working-copy': {
380
+ usage: 'pgkiln working-copy <list|create|compare|merge|refresh|delete> <alias> [<name>] [--prefer copy|main] [--take <component>]… [--as <developer>]',
381
+ summary: 'make, compare, merge and delete working copies of an application',
382
+ details:
383
+ 'list <alias> the working copies of an application\n' +
384
+ 'create <alias> <name> a working copy of the application (a new application, alias printed)\n' +
385
+ 'compare <copy> the components that differ from the main application (exit 1 with conflicts)\n' +
386
+ 'merge <copy> the copy\'s changes into the main application\n' +
387
+ 'refresh <copy> the main application\'s changes into the copy\n' +
388
+ 'delete <copy> delete the working copy\n\n' +
389
+ 'Conflicts (a component changed on both sides) need a choice: --prefer copy or --prefer main for all, or\n' +
390
+ '--take <component> (as compare lists them) for each one that keeps the copy\'s version. A merge is refused\n' +
391
+ 'while another developer has locked the target application or a page it changes. Changes are logged under\n' +
392
+ '--as, else PGKILN_DEVELOPER, else the operating system user.',
393
+ options: { prefer: { type: 'string' }, take: { type: 'string', multiple: true }, as: { type: 'string' } },
394
+ optionHelp: [
395
+ ['--prefer copy|main', 'merge, refresh: resolve every conflict this way'],
396
+ ['--take <component>', 'merge, refresh: this conflict keeps the copy\'s version (repeat)'],
397
+ ['--as <developer>', 'who does it (activity log, locks)'],
398
+ ],
399
+ positionals: [2, 3],
400
+ async run(v, [action, alias, name]) {
401
+ const by = (v.as as string) ?? process.env.PGKILN_DEVELOPER ?? userInfo().username;
402
+ const prefer = v.prefer as string | undefined;
403
+ if (prefer !== undefined && prefer !== 'copy' && prefer !== 'main') throw new UsageError('--prefer: copy or main');
404
+ process.env.RUNTIME_DATABASE_URL ??= process.env.DATABASE_URL;
405
+ const wc = await import('./working-copy.ts');
406
+ const { closePools } = await import('../db.ts');
407
+ try {
408
+ switch (action) {
409
+ case 'list': out(await wc.listCopies(alias)); return EXIT.ok;
410
+ case 'create':
411
+ if (!name) throw new UsageError('create needs a name: pgkiln working-copy create <alias> <name>');
412
+ out((await wc.createWorkingCopy(alias, name, by)).text);
413
+ return EXIT.ok;
414
+ case 'compare': {
415
+ const c = await wc.compareCopy(alias);
416
+ out(c.text);
417
+ return c.conflicts ? EXIT.differences : EXIT.ok;
418
+ }
419
+ case 'merge':
420
+ case 'refresh':
421
+ out(await wc.mergeWorkingCopy(alias, { direction: action, prefer: (prefer as 'copy' | 'main') ?? null, take: (v.take as unknown as string[] | undefined) ?? [], by }));
422
+ return EXIT.ok;
423
+ case 'delete': out(await wc.deleteWorkingCopy(alias, by)); return EXIT.ok;
424
+ default: throw new UsageError(`unknown action ${action}: list, create, compare, merge, refresh or delete`);
425
+ }
426
+ } catch (e) {
427
+ if (e instanceof wc.WorkingCopyError) {
428
+ err(`pgkiln working-copy: ${e.message}\n`);
429
+ return EXIT.differences;
430
+ }
431
+ throw e;
432
+ } finally {
433
+ await closePools();
434
+ }
435
+ },
436
+ },
437
+
438
+ lock: {
439
+ usage: 'pgkiln lock <alias> [--page <n>] [--note <text>] [--as <developer>]',
440
+ summary: 'lock an application or one of its pages, as the builder\'s Lock does',
441
+ details: 'Other developers can\'t change it in the builder, import over it or merge into it until it is unlocked.',
442
+ options: { page: { type: 'string', short: 'p' }, note: { type: 'string' }, as: { type: 'string' } },
443
+ optionHelp: [['-p, --page <n>', 'lock this page only'], ['--note <text>', 'why (shown to the others)'], ['--as <developer>', 'whose lock (default PGKILN_DEVELOPER or the operating system user)']],
444
+ positionals: [1, 1],
445
+ async run(v, [alias]) {
446
+ return lockCommand(v, alias, 'lock');
447
+ },
448
+ },
449
+
450
+ unlock: {
451
+ usage: 'pgkiln unlock <alias> [--page <n>] [--force] [--as <developer>]',
452
+ summary: 'unlock an application or a page (another developer\'s lock only with --force, logged)',
453
+ options: { page: { type: 'string', short: 'p' }, force: { type: 'boolean' }, as: { type: 'string' } },
454
+ optionHelp: [['-p, --page <n>', 'unlock this page'], ['--force', 'break another developer\'s lock (logged)'], ['--as <developer>', 'who unlocks']],
455
+ positionals: [1, 1],
456
+ async run(v, [alias]) {
457
+ return lockCommand(v, alias, 'unlock');
458
+ },
459
+ },
460
+
312
461
  mcp: {
313
462
  usage: 'pgkiln mcp',
314
463
  summary: 'run an MCP server on standard input/output for AI coding agents (Claude Code, Cursor, …)',
package/src/cli/mcp.ts CHANGED
@@ -427,6 +427,99 @@ export const TOOLS: Tool[] = [
427
427
  );
428
428
  },
429
429
  },
430
+ // (sprint 144, AP-10) the Advisor and working copies, as `pgkiln advise` / `pgkiln working-copy` / `pgkiln lock`
431
+ {
432
+ name: 'advise_app',
433
+ title: 'Check an application with the Advisor',
434
+ description:
435
+ 'Runs the Advisor on an export directory, zip or JSON file (imported into a transaction that is rolled back: nothing changes) ' +
436
+ 'or on the application in the database: every SQL fragment planned as the application\'s role, references to pages, items, ' +
437
+ 'lists of values and schemes, and security defaults (pages that write data without sign-in or an authorization scheme, ' +
438
+ 'unrestricted page protection, long sessions, administrators without a second factor). Each finding names its file in the export.',
439
+ inputSchema: {
440
+ type: 'object',
441
+ properties: { ...aliasProp, path: { type: 'string', description: 'an export directory, .zip or .json instead of the application in the database' } },
442
+ },
443
+ annotations: { readOnlyHint: true },
444
+ async run(a) {
445
+ const path = str(a, 'path');
446
+ const alias = str(a, 'alias');
447
+ if (!path && !alias) throw new ToolError('give an alias or a path');
448
+ process.env.RUNTIME_DATABASE_URL ??= process.env.DATABASE_URL;
449
+ const { adviseTarget, formatText } = await import('./advise.ts');
450
+ return cap(formatText(await withDb((db) => adviseTarget(db, path ? resolve(path) : alias!))));
451
+ },
452
+ },
453
+ {
454
+ name: 'working_copy',
455
+ title: 'Working copies of an application',
456
+ description:
457
+ 'Work on a copy of an application and merge it back, as App Builder → Working copies does. action: list (the copies of alias), ' +
458
+ 'create (a copy named name; its alias is returned: export, change and import that one), compare (the components that differ from the ' +
459
+ 'main application: copy, main or CONFLICT, with ids), merge (the copy\'s changes into the main application), refresh (the main ' +
460
+ 'application\'s changes into the copy) or delete. Conflicts need prefer (copy or main) or take (the conflict ids that keep the copy\'s ' +
461
+ 'version). A merge is refused while another developer has locked the target application or a page it changes.',
462
+ inputSchema: {
463
+ type: 'object',
464
+ properties: {
465
+ action: { type: 'string', enum: ['list', 'create', 'compare', 'merge', 'refresh', 'delete'] },
466
+ alias: { type: 'string', description: 'the main application (list, create) or the working copy (compare, merge, refresh, delete)' },
467
+ name: { type: 'string', description: 'create: the copy\'s name (letters, digits, spaces, - and _)' },
468
+ prefer: { type: 'string', enum: ['copy', 'main'], description: 'merge, refresh: how to resolve every conflict' },
469
+ take: { type: 'array', items: { type: 'string' }, description: 'merge, refresh: conflict ids (from compare) that keep the copy\'s version' },
470
+ developer: { type: 'string', description: 'who does it, for the activity log and locks (default: MCP)' },
471
+ },
472
+ required: ['action', 'alias'],
473
+ },
474
+ annotations: { readOnlyHint: false, destructiveHint: true },
475
+ async run(a) {
476
+ const action = str(a, 'action', true)!;
477
+ const alias = str(a, 'alias', true)!;
478
+ const by = str(a, 'developer') ?? 'MCP';
479
+ const prefer = str(a, 'prefer');
480
+ if (prefer && prefer !== 'copy' && prefer !== 'main') throw new ToolError('prefer: copy or main');
481
+ const take = Array.isArray(a.take) ? (a.take as unknown[]).filter((x): x is string => typeof x === 'string') : [];
482
+ process.env.RUNTIME_DATABASE_URL ??= process.env.DATABASE_URL;
483
+ const wc = await import('./working-copy.ts');
484
+ switch (action) {
485
+ case 'list': return wc.listCopies(alias);
486
+ case 'create': return (await wc.createWorkingCopy(alias, str(a, 'name', true)!, by)).text;
487
+ case 'compare': return cap((await wc.compareCopy(alias)).text);
488
+ case 'merge':
489
+ case 'refresh': return wc.mergeWorkingCopy(alias, { direction: action, prefer: (prefer as 'copy' | 'main' | undefined) ?? null, take, by });
490
+ case 'delete': return wc.deleteWorkingCopy(alias, by);
491
+ default: throw new ToolError('action: list, create, compare, merge, refresh or delete');
492
+ }
493
+ },
494
+ },
495
+ {
496
+ name: 'lock_app',
497
+ title: 'Lock or unlock an application or page',
498
+ description:
499
+ 'Locks an application (or one page) so other developers can\'t change it, import over it or merge into it, as the builder\'s Lock does; ' +
500
+ 'unlock=true removes the lock (another developer\'s lock only with force=true, which is logged).',
501
+ inputSchema: {
502
+ type: 'object',
503
+ properties: {
504
+ ...aliasProp,
505
+ page: { type: 'integer', description: 'lock this page only' },
506
+ unlock: { type: 'boolean' },
507
+ force: { type: 'boolean', description: 'with unlock: break another developer\'s lock' },
508
+ note: { type: 'string', description: 'why (shown to other developers)' },
509
+ developer: { type: 'string', description: 'whose lock (default: MCP)' },
510
+ },
511
+ required: ['alias'],
512
+ },
513
+ annotations: { readOnlyHint: false },
514
+ async run(a) {
515
+ const alias = str(a, 'alias', true)!;
516
+ const page = a.page === undefined || a.page === null ? null : int(a, 'page', 1, 1_000_000);
517
+ const by = str(a, 'developer') ?? 'MCP';
518
+ process.env.RUNTIME_DATABASE_URL ??= process.env.DATABASE_URL;
519
+ const wc = await import('./working-copy.ts');
520
+ return a.unlock === true ? wc.unlockApp(alias, page, by, a.force === true) : wc.lockApp(alias, page, by, str(a, 'note') ?? null);
521
+ },
522
+ },
430
523
  {
431
524
  name: 'describe_schema',
432
525
  title: 'Describe database objects',
@@ -0,0 +1,121 @@
1
+ // (sprint 144, AP-10; APEX 26.2: the Application and Working Copy APIs for CI / CD) Working copies and builder
2
+ // locks from the command line (`pgkiln working-copy …`, `pgkiln lock` / `unlock`) and the MCP server, with the same
3
+ // rules as the builder (src/builder/workingcopies.ts, locks.ts): a copy is made, compared per component with its
4
+ // main application (three ways), merged back or refreshed; a merge is refused while another developer has locked the
5
+ // target or a page it changes; conflicts need a choice (prefer the copy's or the main version). Every change is in
6
+ // the activity log under the developer named (--as, PGKILN_DEVELOPER or the operating system user). The functions
7
+ // return text and throw WorkingCopyError for what the user must fix.
8
+ import { owner } from '../db.ts';
9
+ import { logActivity } from '../session.ts';
10
+ import { compare, copiesOf, copyOf, createCopy, deleteCopy, fingerprint, mergeCopy, WorkingCopyError, type Change } from '../workingcopy.ts';
11
+ import { lockText, type Lock } from '../builder/locks.ts';
12
+
13
+ export { WorkingCopyError };
14
+
15
+ const IP = 'cli';
16
+
17
+ async function appByAlias(alias: string) {
18
+ const a = await owner.one<{ id: number; alias: string; name: string }>('select id, alias, name from meta.app where alias = $1', [alias]);
19
+ if (!a) throw new WorkingCopyError(`Application ${alias} not found.`);
20
+ return a;
21
+ }
22
+
23
+ /** Another developer's lock on an application or one of the pages a merge changes (as the builder's merge). */
24
+ export const lockedFor = (username: string) => async (appId: number, pages: Set<number>) => {
25
+ const l = await owner.one<Lock>(
26
+ `select app_id, page_no, locked_by, locked_at::text, note from meta.builder_lock
27
+ where app_id = $1 and locked_by <> $2 and (page_no = 0 or page_no = any($3::int[])) order by page_no limit 1`,
28
+ [appId, username, [...pages]],
29
+ );
30
+ return l ? `${lockText(l)} The change is refused until it is unlocked.` : null;
31
+ };
32
+
33
+ /** The working copies of an application (alias of the main application or of a copy). */
34
+ export async function listCopies(alias: string) {
35
+ const a = await appByAlias(alias);
36
+ const wc = await copyOf(a.id);
37
+ const mainId = wc ? wc.main_app_id : a.id;
38
+ const copies = await copiesOf(mainId);
39
+ if (!copies.length) return `${alias} has no working copies.\n`;
40
+ return copies.map((c) => `${c.alias}\t${c.name}\tby ${c.created_by} ${c.created_at.slice(0, 16)}${c.merged_at ? `\tmerged ${c.merged_at.slice(0, 16)}` : ''}${c.refreshed_at ? `\trefreshed ${c.refreshed_at.slice(0, 16)}` : ''}\n`).join('');
41
+ }
42
+
43
+ export async function createWorkingCopy(alias: string, name: string, by: string) {
44
+ const a = await appByAlias(alias);
45
+ if (await copyOf(a.id)) throw new WorkingCopyError(`${alias} is itself a working copy: make the copy from its main application.`);
46
+ const id = await createCopy(a.id, name, by);
47
+ const copy = await owner.one<{ alias: string }>('select alias from meta.app where id = $1', [id]);
48
+ await logActivity({ appId: a.id, username: by, event: 'working_copy', ip: IP, detail: `created ${name} (application ${id})` });
49
+ return { id, alias: copy!.alias, text: `Working copy ${name} of ${alias}: application ${copy!.alias} (${id}).\n` };
50
+ }
51
+
52
+ const STATUS: Record<Change['status'], string> = { copy: 'copy', main: 'main', conflict: 'CONFLICT' };
53
+
54
+ /** The comparison of a working copy with its main application, one component per line. */
55
+ export async function compareCopy(alias: string) {
56
+ const a = await appByAlias(alias);
57
+ const l = await compare(a.id);
58
+ if (!l) throw new WorkingCopyError(`${alias} is not a working copy.`);
59
+ const conflicts = l.changes.filter((c) => c.status === 'conflict');
60
+ const text = l.changes.length
61
+ ? l.changes.map((c) => `${STATUS[c.status].padEnd(8)} ${c.label} [${c.id}]\n`).join('') +
62
+ `${l.changes.length} component(s) differ from ${l.mainAlias}: ${l.changes.filter((c) => c.status === 'copy').length} changed in the copy, ` +
63
+ `${l.changes.filter((c) => c.status === 'main').length} in the main application, ${conflicts.length} conflict(s).\n`
64
+ : `${alias} and ${l.mainAlias} are the same: nothing to merge.\n`;
65
+ return { changes: l.changes, conflicts: conflicts.length, mainAlias: l.mainAlias, text };
66
+ }
67
+
68
+ /**
69
+ * Merge a working copy into its main application ('merge') or bring the main application's changes into the copy
70
+ * ('refresh'). Conflicts need prefer ('copy' or 'main' for all of them) or take (component ids that keep the copy's
71
+ * version; the others keep the main's).
72
+ */
73
+ export async function mergeWorkingCopy(alias: string, o: { direction: 'merge' | 'refresh'; prefer?: 'copy' | 'main' | null; take?: string[]; by: string }) {
74
+ const a = await appByAlias(alias);
75
+ const l = await compare(a.id);
76
+ if (!l) throw new WorkingCopyError(`${alias} is not a working copy.`);
77
+ const conflicts = l.changes.filter((c) => c.status === 'conflict');
78
+ const take = new Set(o.take ?? []);
79
+ for (const id of take) if (!conflicts.some((c) => c.id === id)) throw new WorkingCopyError(`${id} is not one of the conflicts.`);
80
+ if (o.prefer === 'copy') conflicts.forEach((c) => take.add(c.id));
81
+ const open = conflicts.filter((c) => !take.has(c.id) && o.prefer !== 'main' && !(o.take?.length));
82
+ if (open.length)
83
+ throw new WorkingCopyError(`${open.length} conflict(s): choose the copy's or the main version (prefer copy|main, or take <component> for those that keep the copy's):\n` +
84
+ open.map((c) => ` ${c.label} [${c.id}]`).join('\n'));
85
+ const r = await mergeCopy(a.id, { direction: o.direction, takeCopy: take, state: fingerprint(l.changes), username: o.by, lockedBy: lockedFor(o.by) });
86
+ await logActivity({ appId: o.direction === 'merge' ? r.mainId : a.id, username: o.by, event: 'working_copy', ip: IP, detail: `${o.direction === 'merge' ? 'merged' : 'refreshed'} ${l.copy.name} (${r.changes} components)` });
87
+ return o.direction === 'merge' ? `Merged ${alias} into ${l.mainAlias} (${r.changes} component(s)).\n` : `Refreshed ${alias} from ${l.mainAlias} (${r.changes} component(s)).\n`;
88
+ }
89
+
90
+ export async function deleteWorkingCopy(alias: string, by: string) {
91
+ const a = await appByAlias(alias);
92
+ const wc = await copyOf(a.id);
93
+ if (!wc) throw new WorkingCopyError(`${alias} is not a working copy: only working copies are deleted here.`);
94
+ await deleteCopy(a.id);
95
+ await logActivity({ appId: wc.main_app_id, username: by, event: 'working_copy', ip: IP, detail: `deleted ${wc.name}` });
96
+ return `Deleted working copy ${wc.name} (${alias}).\n`;
97
+ }
98
+
99
+ /** Lock an application (page null) or one of its pages for the developer, as the builder's Lock does. */
100
+ export async function lockApp(alias: string, page: number | null, by: string, note: string | null) {
101
+ const a = await appByAlias(alias);
102
+ if (page !== null && !(await owner.one('select 1 as ok from meta.page where app_id = $1 and page_no = $2', [a.id, page]))) throw new WorkingCopyError(`${alias} has no page ${page}.`);
103
+ const r = await owner.query(`insert into meta.builder_lock (app_id, page_no, locked_by, note) values ($1, $2, $3, $4) on conflict (app_id, page_no) do nothing`,
104
+ [a.id, page ?? 0, by, note?.slice(0, 500) || null]);
105
+ if (!r.rowCount) {
106
+ const l = await owner.one<Lock>('select app_id, page_no, locked_by, locked_at::text, note from meta.builder_lock where app_id = $1 and page_no = $2', [a.id, page ?? 0]);
107
+ throw new WorkingCopyError(l ? lockText(l) : 'It is already locked.');
108
+ }
109
+ return `${page ? `Page ${page} of ${alias}` : alias} is locked by ${by}.\n`;
110
+ }
111
+
112
+ /** Unlock; another developer's lock only with force (logged as a broken lock, as an administrator does in the builder). */
113
+ export async function unlockApp(alias: string, page: number | null, by: string, force: boolean) {
114
+ const a = await appByAlias(alias);
115
+ const l = await owner.one<Lock>('select app_id, page_no, locked_by, locked_at::text, note from meta.builder_lock where app_id = $1 and page_no = $2', [a.id, page ?? 0]);
116
+ if (!l) return 'It is not locked.\n';
117
+ if (l.locked_by !== by && !force) throw new WorkingCopyError(`${lockText(l)} Only ${l.locked_by} unlocks it (or use --force).`);
118
+ await owner.query('delete from meta.builder_lock where app_id = $1 and page_no = $2', [a.id, page ?? 0]);
119
+ if (l.locked_by !== by) await logActivity({ appId: a.id, pageNo: page, username: by, event: 'lock_broken', ip: IP, detail: `command line: lock of ${l.locked_by}` });
120
+ return l.locked_by === by ? 'Unlocked.\n' : `${l.locked_by}'s lock was broken.\n`;
121
+ }
package/src/instance.ts CHANGED
@@ -54,6 +54,12 @@ function valid(s: InstanceSetting, v: string | undefined): number | null {
54
54
  return Number.isInteger(n) && n >= s.min && n <= s.max ? n : null;
55
55
  }
56
56
 
57
+ /** (sprint 144) A setting's value in effect from rows of meta.setting read elsewhere (the Advisor's own connection). */
58
+ export function settingFrom(key: InstanceKey, rows: { name: string; value: string }[]): number {
59
+ const s = INSTANCE_SETTINGS.find((x) => x.key === key)!;
60
+ return valid(s, rows.find((r) => r.name === key)?.value) ?? fromEnv(s);
61
+ }
62
+
57
63
  /** Read meta.setting again when the cache is older than 30 s (callers that can wait). */
58
64
  export async function refreshInstanceSettings(force = false) {
59
65
  if (!force && Date.now() - loadedAt < REFRESH_MS) return;