pgkiln 0.77.0 → 0.79.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/db/migrations/143_ai_provider_texts.sql +3 -0
- package/docs/apex-feature-parity.md +4 -4
- package/docs/guide/03-builder.md +5 -0
- package/docs/guide/04-pages-and-regions.md +1 -1
- package/docs/guide/06-processing.md +5 -4
- package/docs/guide/11-from-apex.md +1 -1
- package/docs/guide/18-cli.md +19 -2
- package/docs/guide/19-rest-data-sources.md +2 -1
- package/docs/guide/20-ai-agents.md +1 -1
- package/examples/hr/hr_41_ai_assistant.sql +5 -4
- package/examples/hr/hr_42_hr_assistant.sql +3 -2
- package/examples/hr/hr_78_ai_provider_texts.sql +15 -0
- package/package.json +1 -1
- package/src/builder/advisor.ts +305 -149
- package/src/builder/ai.ts +4 -4
- package/src/builder/assistant.ts +1 -1
- package/src/builder/home.ts +1 -1
- package/src/builder/search.ts +15 -4
- package/src/cli/advise.ts +39 -15
- package/src/cli/main.ts +7 -4
- package/src/cli/mcp.ts +10 -2
- package/src/json-sources.ts +14 -8
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
-- Sprint 145 (AP-11): the description of meta.ai_service still named only Claude and OpenAI; AI services have also
|
|
2
|
+
-- been Gemini and any OpenAI-compatible endpoint (Ollama, vLLM, Azure OpenAI, a gateway) since 0.52.0.
|
|
3
|
+
comment on table meta.ai_service is 'AI services (Claude, OpenAI, Gemini or an OpenAI-compatible endpoint) of this installation; the API key is encrypted and never readable by applications';
|
|
@@ -7,7 +7,7 @@ open item and open an issue or pull request; see [CONTRIBUTING.md](../CONTRIBUTI
|
|
|
7
7
|
Legend: ✅ available · 🟡 partial (see notes) · ❌ not yet · ➖ not planned (a deliberate choice,
|
|
8
8
|
or better served by the PostgreSQL ecosystem; see the notes and [extensions](guide/15-extensions.md))
|
|
9
9
|
|
|
10
|
-
Last reviewed: 2026-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).
|
|
10
|
+
Last reviewed: 2026-11-05 against **APEX 26.2** (sprint 147: `pgkiln advise --offline` checks an export's files without a database (AP-10 leftover); sprint 146: the Advisor checks JSON sources' views against their data profile (AP-8 leftover); sprint 145: the texts that offered AI with "Claude or OpenAI" name all four providers: Claude, OpenAI, Gemini and OpenAI-compatible services (AP-11); 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
|
|
|
@@ -21,7 +21,7 @@ Last reviewed: 2026-11-04 against **APEX 26.2** (sprint 144: `pgkiln advise` for
|
|
|
21
21
|
| User interface | 12 | 0 | 0 | 0 | Region templates incl. hero, inline dialog and drawer, wizard and carousel; Universal Theme-like and responsive; a Theme Roller with style variants and conditional, dynamic styles; about 1,700 icons with Font APEX-like modifiers |
|
|
22
22
|
| Globalization | 6 | 0 | 0 | 0 | One translated app like 26.1 with dynamic translations of table values; number format masks and automatic time zone; twenty-two built-in languages |
|
|
23
23
|
| Data and integration | 16 | 0 | 0 | 1 | Remote servers, static workspace files and workspace web credentials; REST source catalogs from OpenAPI documents; Generate DDL and schema comparison; `meta.data_export` and data export downloads (CSV, JSON, XML, HTML, Excel, PDF); e-mail through SMTP (password or OAuth2), Microsoft Graph, Amazon SES and HTTP APIs; REST APIs via PostgREST, REST data sources that write back and synchronise, web credentials with OAuth2 grants, CSV/XLSX/JSON/XML loading with saved definitions and unloading, SQL scripts and Quick SQL, report PDFs and document templates |
|
|
24
|
-
| 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 |
|
|
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, OpenAI, Gemini or an OpenAI-compatible service: *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
27
|
| **Total** | **171** | **0** | **0** | **3** | 174 APEX features compared: 98% available, none missing, 3 deliberately not planned |
|
|
@@ -48,7 +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)).
|
|
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)). The full check needs a database (pgkiln's migrations and the application's tables); (sprint 147) **`--offline`** checks the files alone like CodeScan: references, security defaults, workflow steps and JSON source profiles, without SQL planning |
|
|
52
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 |
|
|
53
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 |
|
|
54
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)) |
|
|
@@ -282,7 +282,7 @@ review is a diff. Not counted in [At a glance](#at-a-glance): the rows above com
|
|
|
282
282
|
| SQL Workshop utilities: schema comparison, generate DDL | ✅ | (sprint 56) SQL Workshop → **DDL**: a schema's objects (types, sequences, tables, functions, views, constraints, indexes, triggers, RLS and policies, comments, grants) from the catalog, in an order that runs, unqualified so it also creates them in another schema. **Compare**: two schemas by their DDL (only in one, different, with a diff) and a change script that makes the target like the source (tables column by column; drops commented out for review). Also `pgkiln ddl`, `pgkiln compare` and the MCP tools `generate_ddl`, `compare_schemas` ([chapter 3](guide/03-builder.md#sql-workshop)). Two schemas of the same database only (APEX: also within one workspace) |
|
|
283
283
|
| Data Reporter: self-service reports for business users (26.1) | ✅ | A **Data Reporter** region ([chapter 4](guide/04-pages-and-regions.md#data_reporter-data-reporter)): the developer offers tables and views with chosen columns, labels and format masks; signed-in users pick columns, filter, group with totals (count, sum, average, minimum, maximum), sort and chart, and save reports privately or shared with the app's users. Runs as the app's role (grants and RLS apply); works without JavaScript and fits phones. (sprint 68) A **welcome page** like 26.1's: a greeting (the developer's own, or pgkiln's), the sources as cards that start a report, the user's reports latest first, shared ones, and a search. One source per report (offer a view to combine tables); no downloads from a reporter yet (sprint 110) **A workspace tool too**: App Builder → **Data Reporter** ([chapter 3](guide/03-builder.md#data-reporter-datasets-and-reporting-applications)) keeps **datasets** (tables and views of one schema with joins proposed from foreign keys, offered columns with labels, masks and descriptions, a row filter; files and the workspace export) and makes a **reporting application** on them in two clicks: viewers open published reports by a stable URL and pin them, editors build and publish, administrators moderate; every report runs as the application's SELECT-only role through RLS. |
|
|
284
284
|
| REST Source Catalogs (Workspace Utilities) | ✅ | (sprint 67) Workspace utilities → **REST Source Catalogs**: a workspace's web services with a base URL and services, from an **OpenAPI 3 / Swagger 2.0** document (file, pasted or read from a URL on the allow-list), a catalog file or by hand: parameters (under the service's own names), row selector, typed columns and write-back operations from the response schemas and collection/item paths, with notes on what can't be used. An application's *REST data sources → From a REST source catalog* makes a data source from a service, with its own web credential ([chapter 19](guide/19-rest-data-sources.md#rest-source-catalogs)). **Missing:** YAML documents (convert to JSON), APEX's built-in catalogs (e.g. Oracle Fusion) |
|
|
285
|
-
| JSON sources, duality views (24.2) | ✅ | (sprint 142, AP-8) Shared Components → **JSON Sources** ([chapter 19](guide/19-rest-data-sources.md#json-sources-and-document-views)): a `jsonb` column with a data profile proposed from the documents; `security_invoker` views with a typed column per path and a view per array (stable row keys), whose triggers write only the changed paths, so unknown keys stay; forms, reports, grids, master-detail, AutoREST and MCP use them like tables; Create page makes a form with detail grids. Table Properties → **Document view**: a row and its child rows as one `jsonb` document, written back in one statement (the duality view). JSON path columns in reports |
|
|
285
|
+
| JSON sources, duality views (24.2) | ✅ | (sprint 142, AP-8) Shared Components → **JSON Sources** ([chapter 19](guide/19-rest-data-sources.md#json-sources-and-document-views)): a `jsonb` column with a data profile proposed from the documents; `security_invoker` views with a typed column per path and a view per array (stable row keys), whose triggers write only the changed paths, so unknown keys stay; forms, reports, grids, master-detail, AutoREST and MCP use them like tables; Create page makes a form with detail grids. Table Properties → **Document view**: a row and its child rows as one `jsonb` document, written back in one statement (the duality view). JSON path columns in reports. (sprint 146) The Advisor names views that no longer match their profile |
|
|
286
286
|
| Remote Servers (Workspace Utilities): a named base URL per environment for REST data sources, catalogs and AI services | ✅ | (sprint 141, AP-7) Workspace utilities → **Remote servers** ([chapter 19](guide/19-rest-data-sources.md#remote-servers)): a static id, this installation's base URL and/or an environment variable `PGKILN_SERVER_<NAME>` that wins when set, and a default web credential. REST data sources (a server and a path), REST source catalogs and AI services name a server, so changing it moves every source at once; the resolved URL passes the allow-list, the workspace's network access list (which shows each server's host) and the address checks. In the workspace export (an existing server keeps its local URL) |
|
|
287
287
|
| Static Workspace Files (`#WORKSPACE_FILES#`), workspace-level Web Credentials | ✅ | (sprint 141, AP-7) Workspace utilities → **Static workspace files**: CSS, JavaScript, images and fonts with the static application files' types (no HTML), size limit and headers, served to each application of the workspace at `/a/<alias>/workspace-files/<name>`, loaded with `#WORKSPACE_FILES#name` and written as `#WORKSPACE_FILES#` / `#APP_FILES#` in HTML and templates ([chapter 16](guide/16-files.md#static-workspace-files)). **Web credentials** of the workspace, used by name by its applications (an application's own of the same name wins); secrets encrypted, write-only, never exported. Both in the workspace export |
|
|
288
288
|
| Remote database regions (REST Enabled SQL), database links | ➖ | Not built: regions on another database were left out of sprint 141. `postgres_fdw` or `dblink` bring another PostgreSQL database's tables into the application's own |
|
package/docs/guide/03-builder.md
CHANGED
|
@@ -1616,6 +1616,11 @@ Database code (views, functions, RLS policies) isn't part of the application, so
|
|
|
1616
1616
|
when it writes data (hidden items never take posted values, so the URL is the way to set them); the installation's
|
|
1617
1617
|
session idle time above 120 minutes or session length above 24 hours is a warning; and, as before, administrators
|
|
1618
1618
|
who sign in with only a password.
|
|
1619
|
+
- **JSON sources** (sprint 146): each [JSON source](19-rest-data-sources.md#json-sources-and-document-views)'s views against its data
|
|
1620
|
+
profile, as the catalog has them. A view that doesn't exist is an error (pages on it fail); a view the source didn't
|
|
1621
|
+
make (no `json_source_write` trigger), a column the profile has and the view lacks (or the other way round), a
|
|
1622
|
+
column of another type, or a path, read-only flag or key the view writes back differently is a warning: *Create
|
|
1623
|
+
views* (or *Make the views again*) makes them from the profile.
|
|
1619
1624
|
- **PL/pgSQL functions:** when the `plpgsql_check` extension is installed, the functions in the
|
|
1620
1625
|
schemas the application's role can use are checked with `plpgsql_check_function_tb` (see
|
|
1621
1626
|
[extensions](15-extensions.md)).
|
|
@@ -184,7 +184,7 @@ database only checks the shape (at most 12 names of lower case letters, digits a
|
|
|
184
184
|
| [`tree`](#tree) | Rows with a parent as an expandable tree |
|
|
185
185
|
| [`list`](#list-lists) | A list (Shared Components → Lists) as nested links, a badge list, cards or tabs |
|
|
186
186
|
| [`data_reporter`](#data_reporter-data-reporter) | Business users build, save and share their own reports from tables and views the developer offers |
|
|
187
|
-
| [`ai_assistant`](#ai_assistant-ai-assistant) | A chat with an AI service (Claude or OpenAI), with context from queries and tools the model may call (an AI agent) |
|
|
187
|
+
| [`ai_assistant`](#ai_assistant-ai-assistant) | A chat with an AI service (Claude, OpenAI, Gemini or an OpenAI-compatible service), with context from queries and tools the model may call (an AI agent) |
|
|
188
188
|
| [`template_component`](#template-components) | Each row (or all rows) of a SELECT through a template component: badges, contact cards, timelines, your own |
|
|
189
189
|
| [`tasks`](06-processing.md#approvals-and-the-task-list) | Task list: approvals and actions for the signed-in user |
|
|
190
190
|
| [`workflows`](06-processing.md#workflows) | Workflow console: the workflows the user started or administers |
|
|
@@ -398,12 +398,13 @@ From SQL the same is `meta.send_push(...)` ([chapter 9](09-reference.md#push-not
|
|
|
398
398
|
An `ai_generate` process (APEX: *Generate Text with AI*) sends a prompt to a large language model
|
|
399
399
|
and puts the answer into page items. The model is reached through an **AI service** that an
|
|
400
400
|
administrator configures under Workspace utilities → **AI services** ([chapter 3](03-builder.md#ai-services)):
|
|
401
|
-
Claude (Anthropic)
|
|
402
|
-
|
|
401
|
+
Claude (Anthropic), OpenAI, Gemini (Google) or an OpenAI-compatible service (Ollama, vLLM, Azure
|
|
402
|
+
OpenAI, a gateway), the model, the API key and optional daily limits per application. An application
|
|
403
|
+
can only use the services an administrator allowed for it.
|
|
403
404
|
|
|
404
405
|
> **The prompt goes to the provider.** Everything in the prompt, including the item values it
|
|
405
|
-
> contains, is sent to the AI service's provider (Anthropic or
|
|
406
|
-
> and is handled under that provider's terms. Don't put data in a prompt that may not leave your
|
|
406
|
+
> contains, is sent to the AI service's provider (Anthropic, OpenAI or Google, or the OpenAI-compatible
|
|
407
|
+
> endpoint or gateway in its base URL) and is handled under that provider's terms. Don't put data in a prompt that may not leave your
|
|
407
408
|
> organisation, and tell your users when what they type is sent to an AI service.
|
|
408
409
|
|
|
409
410
|
The process is configured in its JSON (`config`), in one of three forms:
|
|
@@ -21,7 +21,7 @@ What's missing is listed in the [feature parity matrix](../apex-feature-parity.m
|
|
|
21
21
|
| `APEX_DEBUG` (`message`, `error`, `warn`, `info`, `trace`), debug levels, *View Debug* | `meta.debug(level, text)`, `meta.debug_enabled(level)`; the app's debug level; Activity → Debug messages ([chapter 6](06-processing.md#debug-messages)) |
|
|
22
22
|
| Instance administration: install/upgrade log | Workspace utilities → Installation (administrators) |
|
|
23
23
|
| `APEX_WEB_SERVICE.make_rest_request`, `g_status_code` | `meta.web_request(url, method, body, headers, credential)` or `meta.web_request_source(source, params)`, then `meta.web_response(id)`. **Asynchronous**: the server makes the request after the page process that queued it, or after the commit ([chapter 9](09-reference.md#web-requests-from-sql)) |
|
|
24
|
-
| Workspace → Generative AI services; *Generate Text with AI* process and dynamic action; `APEX_AI.generate` | Workspace utilities → AI services (Claude or OpenAI; administrators); the `ai_generate` process and dynamic action, with structured outputs into items ([chapter 6](06-processing.md#generate-text-with-ai)); `meta.ai_generate(service, prompt, system, schema)` then `meta.ai_result(id)` ([chapter 9](09-reference.md#ai-requests-from-sql)) |
|
|
24
|
+
| Workspace → Generative AI services; *Generate Text with AI* process and dynamic action; `APEX_AI.generate` | Workspace utilities → AI services (Claude, OpenAI, Gemini or an OpenAI-compatible service such as Ollama; administrators); the `ai_generate` process and dynamic action, with structured outputs into items ([chapter 6](06-processing.md#generate-text-with-ai)); `meta.ai_generate(service, prompt, system, schema)` then `meta.ai_result(id)` ([chapter 9](09-reference.md#ai-requests-from-sql)) |
|
|
25
25
|
| AI Assistant region; AI agents with tools; AI interactive reports (natural language to interactive report) | The `ai_assistant` region: a chat with context queries (RAG over your queries) and tools (SQL as the app role with bound arguments, REST data sources) ([chapter 4](04-pages-and-regions.md#ai_assistant-ai-assistant)); `"ai_filter"` on a report: *Ask in your own words*, the request becomes the report's settings as editable chips, with or without an AI service ([chapter 4](04-pages-and-regions.md#ask-in-your-own-words-ai-interactive-reports)) |
|
|
26
26
|
| App Builder / SQL Workshop AI Assistant (create pages, generate and explain SQL); describing tables for AI | The App Builder's AI service: *Create pages with AI* (proposals you confirm), SQL Workshop → AI (SQL from a question, shown not run; explain a query or error), *Describe tables* (`meta.ai_table_note`, optionally `COMMENT ON`) ([chapter 3](03-builder.md#app-builder-ai)) |
|
|
27
27
|
| Blueprints (spec-driven development) | Create → From a blueprint: a JSON blueprint (tables, pages, menu, sample data), optionally drafted by AI, reviewed, then created in one transaction ([chapter 3](03-builder.md#creating-an-application-from-a-blueprint)) |
|
package/docs/guide/18-cli.md
CHANGED
|
@@ -44,7 +44,7 @@ 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)) |
|
|
47
|
+
| `pgkiln advise <path\|alias> [--offline] [--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. `--offline` checks an export's files without a 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
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
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) |
|
|
50
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)) |
|
|
@@ -315,10 +315,27 @@ jobs:
|
|
|
315
315
|
with: { sarif_file: advise.sarif }
|
|
316
316
|
```
|
|
317
317
|
|
|
318
|
+
**Without a database** (sprint 147): `pgkiln advise <path> --offline` reads the export's files and runs the checks that
|
|
319
|
+
need no database: references between components (pages, items, lists of values, authorization schemes, build options,
|
|
320
|
+
report layouts, regions, workflow definitions and steps, processes in chains), the login page and page groups, the
|
|
321
|
+
pages' security defaults (writing without sign-in or a scheme, unrestricted protection), process settings and JSON
|
|
322
|
+
source profiles. It never connects (`DATABASE_URL` may be unset), so it fits a quick step before the full check, or a
|
|
323
|
+
repository without a database in CI:
|
|
324
|
+
|
|
325
|
+
```yaml
|
|
326
|
+
- run: npx pgkiln advise apps/sales --offline --format sarif -o advise.sarif
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
Offline, SQL is not planned (it needs the application's tables and role), nothing is read from the catalog (vector and
|
|
330
|
+
full-text columns, JSON source views, `plpgsql_check`), and the workspace's remote servers and credentials, branches to
|
|
331
|
+
other applications, the installation's session limits and the administrators' second factor are not checked. The
|
|
332
|
+
summary line says *checked offline*, and JSON output has `"offline": true`. The findings are the same as the full
|
|
333
|
+
check's for everything it does check.
|
|
334
|
+
|
|
318
335
|
| Format | What it gives |
|
|
319
336
|
|---|---|
|
|
320
337
|
| `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 }] }` |
|
|
338
|
+
| `json` | `{ alias, checked, offline, counts: { error, warning, info }, findings: [{ severity, where, field, message, page, kind, file }] }` |
|
|
322
339
|
| `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
340
|
|
|
324
341
|
The paths are relative to the current directory. With an alias there is no directory: `--base apps/sales` names
|
|
@@ -385,7 +385,8 @@ Shared Components → Data Sources → **JSON Sources** → *Add JSON source*:
|
|
|
385
385
|
**Create views** (the source's page) makes them in one transaction with your SQL Workshop connection and records
|
|
386
386
|
the statements in the [Change Log](03-builder.md#change-log); *Open in the SQL Worksheet* shows them first. After
|
|
387
387
|
changing the profile, *Make the views again* drops and makes them (a view other objects depend on can't be
|
|
388
|
-
dropped: the error says which). The
|
|
388
|
+
dropped: the error says which). The [Advisor](03-builder.md#advisor) names a source whose views no longer match
|
|
389
|
+
its profile, e.g. after a column was added to the profile but the views weren't made again. The views are ordinary database objects of your application, like the table:
|
|
389
390
|
put the statements in your own migration scripts for the other environments (the source itself travels with the
|
|
390
391
|
application's export as `json_sources`).
|
|
391
392
|
|
|
@@ -75,7 +75,7 @@ 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 |
|
|
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; `offline: true` with a `path` checks the files without the database (0.79) | no |
|
|
79
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
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 |
|
|
81
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 |
|
|
@@ -13,8 +13,9 @@
|
|
|
13
13
|
-- page submit); without it they submit the page and the processes run.
|
|
14
14
|
--
|
|
15
15
|
-- The example creates no AI service: an administrator adds one named
|
|
16
|
-
-- HR_ASSISTANT (Claude
|
|
17
|
-
--
|
|
16
|
+
-- HR_ASSISTANT (Claude, OpenAI, Gemini or an OpenAI-compatible service,
|
|
17
|
+
-- with an API key, or the server's ANTHROPIC_API_KEY / OPENAI_API_KEY /
|
|
18
|
+
-- GEMINI_API_KEY / OPENAI_COMPATIBLE_API_KEY) under App Builder → Workspace
|
|
18
19
|
-- utilities → AI services and allows the HR application to use it. Until
|
|
19
20
|
-- then the page says so (meta.ai_available) and hides the AI buttons.
|
|
20
21
|
-- The message is sent to the provider of that service.
|
|
@@ -27,10 +28,10 @@ insert into meta.region (page_id, seq, title, type, columns, template, source, c
|
|
|
27
28
|
select p.id, r.seq, r.title, 'static', 12, r.template, r.source, r.cond
|
|
28
29
|
from meta.page p join meta.app a on a.id = p.app_id, (values
|
|
29
30
|
(10, 'No AI service yet', 'standard',
|
|
30
|
-
'<p class="alert alert-info">No AI service is configured for this application yet. An administrator adds an AI service named <b>HR_ASSISTANT</b> (Claude or OpenAI, with an API key) under <i>App Builder → Workspace utilities → AI services</i> and allows the HR application to use it.</p>',
|
|
31
|
+
'<p class="alert alert-info">No AI service is configured for this application yet. An administrator adds an AI service named <b>HR_ASSISTANT</b> (Claude, OpenAI, Gemini or an OpenAI-compatible service, with an API key) under <i>App Builder → Workspace utilities → AI services</i> and allows the HR application to use it.</p>',
|
|
31
32
|
'not meta.ai_available(''HR_ASSISTANT'')'),
|
|
32
33
|
(20, 'Leave message', 'standard',
|
|
33
|
-
'<p>Paste a message in which an employee asks for leave. <b>Summarise</b> writes a short summary for the manager; <b>Fill in the request</b> reads the dates and the reason from it. The message is sent to the AI service HR_ASSISTANT (its provider: Claude or OpenAI); check the answer before you use it.</p>',
|
|
34
|
+
'<p>Paste a message in which an employee asks for leave. <b>Summarise</b> writes a short summary for the manager; <b>Fill in the request</b> reads the dates and the reason from it. The message is sent to the AI service HR_ASSISTANT (its provider: Claude, OpenAI, Gemini or an OpenAI-compatible service); check the answer before you use it.</p>',
|
|
34
35
|
null),
|
|
35
36
|
(30, 'Leave request', 'standard',
|
|
36
37
|
'<p>Check the dates and the reason, then file the request in your name.</p>',
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
-- becomes the report's filters and sort.
|
|
17
17
|
--
|
|
18
18
|
-- Like part 41, the example creates no AI service: an administrator adds
|
|
19
|
-
-- one named HR_ASSISTANT (Claude
|
|
19
|
+
-- one named HR_ASSISTANT (Claude, OpenAI, Gemini or an OpenAI-compatible
|
|
20
|
+
-- service) under App Builder → Workspace
|
|
20
21
|
-- utilities → AI services and allows the HR application to use it. Until
|
|
21
22
|
-- then the page says so. Questions, policy passages and the tools' results
|
|
22
23
|
-- are sent to the provider of that service.
|
|
@@ -43,7 +44,7 @@ insert into meta.region (page_id, seq, title, type, columns, template, source, c
|
|
|
43
44
|
select p.id, r.seq, r.title, r.type, r.cols, 'standard', r.source, r.cond, r.config::jsonb
|
|
44
45
|
from meta.page p join meta.app a on a.id = p.app_id, (values
|
|
45
46
|
(10, 'No AI service yet', 'static', 12,
|
|
46
|
-
'<p class="alert alert-info">No AI service is configured for this application yet. An administrator adds an AI service named <b>HR_ASSISTANT</b> (Claude or OpenAI, with an API key) under <i>App Builder → Workspace utilities → AI services</i> and allows the HR application to use it.</p>',
|
|
47
|
+
'<p class="alert alert-info">No AI service is configured for this application yet. An administrator adds an AI service named <b>HR_ASSISTANT</b> (Claude, OpenAI, Gemini or an OpenAI-compatible service, with an API key) under <i>App Builder → Workspace utilities → AI services</i> and allows the HR application to use it.</p>',
|
|
47
48
|
'not meta.ai_available(''HR_ASSISTANT'')', '{}'),
|
|
48
49
|
(20, 'Ask HR', 'ai_assistant', 6, null, 'meta.ai_available(''HR_ASSISTANT'')',
|
|
49
50
|
$j${
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
-- =====================================================================
|
|
2
|
+
-- HR example, part 78: AI provider texts (sprint 145, AP-11)
|
|
3
|
+
--
|
|
4
|
+
-- Parts 41 and 42 told the user an administrator adds an AI service
|
|
5
|
+
-- HR_ASSISTANT "(Claude or OpenAI ...)". AI services can also be Gemini or
|
|
6
|
+
-- any OpenAI-compatible endpoint (Ollama, vLLM, Azure OpenAI, a gateway).
|
|
7
|
+
-- A fresh install gets the new text from parts 41 and 42; this part
|
|
8
|
+
-- updates the pages of an installation that applied them earlier.
|
|
9
|
+
-- =====================================================================
|
|
10
|
+
|
|
11
|
+
update meta.region r
|
|
12
|
+
set source = replace(source, 'Claude or OpenAI', 'Claude, OpenAI, Gemini or an OpenAI-compatible service')
|
|
13
|
+
from meta.page p join meta.app a on a.id = p.app_id
|
|
14
|
+
where r.page_id = p.id and a.alias = 'hr' and p.page_no in (37, 38)
|
|
15
|
+
and r.source like '%Claude or OpenAI%';
|
package/package.json
CHANGED
package/src/builder/advisor.ts
CHANGED
|
@@ -14,8 +14,9 @@ import { processProblems } from '../runtime/processes.ts';
|
|
|
14
14
|
import { urlConfigProblem, urlTarget } from '../runtime/url-region.ts';
|
|
15
15
|
import { configColumns, searchConfigProblems, textSearchConfig, tsvectorSql } from '../runtime/search.ts';
|
|
16
16
|
import { COMPONENTS } from './components.ts';
|
|
17
|
+
import { arrayTriggerArg, CATALOG_TYPE, jsonSourceProblems, POSITION, ROW_KEY, viewTriggerArg, type JsonSource } from '../json-sources.ts';
|
|
17
18
|
import { DISTANCES, PGVECTOR_MISSING, vectorColumnDims, vectorSchema, type Distance } from '../vector.ts';
|
|
18
|
-
import {
|
|
19
|
+
import { entriesOf, type Entry } from './search.ts';
|
|
19
20
|
import { LOV_CHECKED } from '../runtime/items.ts';
|
|
20
21
|
import { appHeader, BASE, developer, region, send, shell, type Req } from './ui.ts';
|
|
21
22
|
|
|
@@ -157,6 +158,9 @@ export async function checkSql(c: pg.PoolClient, sql: string, shape: SqlShape):
|
|
|
157
158
|
|
|
158
159
|
/** process types that write data (sprint 144: the security checks below; a sql process when its code has DML) */
|
|
159
160
|
const WRITES = ['form_dml', 'grid_dml', 'sql'];
|
|
161
|
+
type Row = { kind: string; row: any };
|
|
162
|
+
/** (sprint 147) an application as rows: from the database (advise) or from an export's files (adviseDoc) */
|
|
163
|
+
export interface AppRows { app: any; pages: any[]; all: Row[]; entries: Entry[]; byKey: Map<string, Entry> }
|
|
160
164
|
/** (sprint 144) session limits above which the Advisor warns (APEX CodeScan's session limits) */
|
|
161
165
|
export const SESSION_LIMITS = { idleMinutes: 120, maxHours: 24 };
|
|
162
166
|
|
|
@@ -169,25 +173,31 @@ export const SESSION_LIMITS = { idleMinutes: 120, maxHours: 24 };
|
|
|
169
173
|
* protection; hidden items never take posted values in pgkiln, so URL values are the way in);
|
|
170
174
|
* - the installation's session limits above SESSION_LIMITS.
|
|
171
175
|
*/
|
|
172
|
-
|
|
176
|
+
/** (sprint 147) the processes of a page that write data: saves, and SQL with a writing statement, not behind a scheme */
|
|
177
|
+
const writesOf = (all: Row[], page: any) => all
|
|
178
|
+
.filter((x) => x.kind === 'process' && x.row.page_id === page.id && WRITES.includes(x.row.type) && !x.row.authz
|
|
179
|
+
&& (x.row.build_option ?? '') !== 'COMMENTED_OUT' && (x.row.type !== 'sql' || /\b(insert|update|delete|merge|truncate)\b/i.test(String(x.row.code ?? ''))))
|
|
180
|
+
.sort((a, b) => (a.row.seq ?? 0) - (b.row.seq ?? 0)).map((x) => String(x.row.name));
|
|
181
|
+
|
|
182
|
+
function pageSecurityFindings(x: AppRows): Finding[] {
|
|
173
183
|
const out: Finding[] = [];
|
|
174
|
-
const schemes = (
|
|
175
|
-
const pages
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
+
const schemes = x.all.filter((r) => r.kind === 'authz_scheme').length;
|
|
185
|
+
for (const p of [...x.pages].filter((p) => (p.build_option ?? '') !== 'COMMENTED_OUT').sort((a, b) => a.page_no - b.page_no)) {
|
|
186
|
+
const entry = x.byKey.get(`page-${p.id}`) ?? null;
|
|
187
|
+
const writes = writesOf(x.all, p);
|
|
188
|
+
const items = x.all.filter((r) => r.kind === 'item' && r.row.page_id === p.id && r.row.type !== 'display').length;
|
|
184
189
|
if (writes.length && !p.requires_auth)
|
|
185
190
|
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
191
|
else if (writes.length && !p.authz && schemes)
|
|
187
192
|
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' &&
|
|
193
|
+
if (p.protection === 'unrestricted' && items)
|
|
189
194
|
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
195
|
}
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function securityFindings(x: AppRows, query: (sql: string, params?: unknown[]) => Promise<pg.QueryResult>): Promise<Finding[]> {
|
|
200
|
+
const out = pageSecurityFindings(x);
|
|
191
201
|
const settings = (await query(`select name, value from meta.setting where name in ('session_idle_minutes', 'session_max_hours')`).catch(() => ({ rows: [] }))).rows;
|
|
192
202
|
const idle = settingFrom('session_idle_minutes', settings);
|
|
193
203
|
const max = settingFrom('session_max_hours', settings);
|
|
@@ -198,6 +208,215 @@ async function securityFindings(appId: number, query: (sql: string, params?: unk
|
|
|
198
208
|
return out;
|
|
199
209
|
}
|
|
200
210
|
|
|
211
|
+
/**
|
|
212
|
+
* (sprint 146, AP-8) JSON sources whose views no longer match their data profile. Create views makes each view from
|
|
213
|
+
* the profile; editing the profile afterwards (or changing the view by hand) leaves the database behind. Per view:
|
|
214
|
+
* missing (error: pages on it fail), not made by this source (no json_source_write trigger: warning), and the
|
|
215
|
+
* profile against the catalog: its columns and their types, and the trigger's argument, which says what the view
|
|
216
|
+
* writes back (a column added, removed, moved to another path or made read only). Read as the owner.
|
|
217
|
+
*/
|
|
218
|
+
export async function jsonSourceFindings(sources: unknown, query: (sql: string, params?: unknown[]) => Promise<pg.QueryResult>): Promise<Finding[]> {
|
|
219
|
+
const out: Finding[] = [];
|
|
220
|
+
const list = (Array.isArray(sources) ? sources : []) as JsonSource[];
|
|
221
|
+
for (const x of list) {
|
|
222
|
+
if (!x || typeof x.schema !== 'string' || typeof x.view !== 'string' || !Array.isArray(x.columns)) continue;
|
|
223
|
+
const field = `JSON source ${x.name}`;
|
|
224
|
+
const views = [
|
|
225
|
+
{ view: x.view, arg: viewTriggerArg(x), cols: x.columns, extra: [] as string[] },
|
|
226
|
+
...(x.arrays ?? []).map((a) => ({ view: a.view, arg: arrayTriggerArg(x, a), cols: a.columns, extra: [ROW_KEY, POSITION] })),
|
|
227
|
+
];
|
|
228
|
+
const rows = (await query(
|
|
229
|
+
`select c.relname as view,
|
|
230
|
+
(select array_agg(array[a.attname::text, format_type(a.atttypid, a.atttypmod)] order by a.attnum)
|
|
231
|
+
from pg_attribute a where a.attrelid = c.oid and a.attnum > 0 and not a.attisdropped) as cols,
|
|
232
|
+
(select convert_from(substring(t.tgargs from 1 for greatest(length(t.tgargs) - 1, 0)), 'UTF8')
|
|
233
|
+
from pg_trigger t where t.tgrelid = c.oid and t.tgname = 'json_source_write') as arg
|
|
234
|
+
from pg_class c join pg_namespace n on n.oid = c.relnamespace
|
|
235
|
+
where n.nspname = $1 and c.relname = any ($2) and c.relkind = 'v'`, [x.schema, views.map((v) => v.view)])).rows;
|
|
236
|
+
const found = new Map(rows.map((r) => [r.view as string, r]));
|
|
237
|
+
for (const v of views) {
|
|
238
|
+
const name = `${x.schema}.${v.view}`;
|
|
239
|
+
const again = 'Shared Components → JSON sources → Create views makes it again.';
|
|
240
|
+
const r = found.get(v.view);
|
|
241
|
+
if (!r) { out.push({ severity: 'error', entry: null, field, message: `View ${name} doesn't exist: pages and reports on it fail. ${again}` }); continue; }
|
|
242
|
+
if (r.arg == null) { out.push({ severity: 'warning', entry: null, field, message: `View ${name} wasn't made by this JSON source (it has no json_source_write trigger): it doesn't write back to ${x.table}.${x.column}.` }); continue; }
|
|
243
|
+
const actual = new Map((r.cols ?? []).map(([n, t]: string[]) => [n, t]));
|
|
244
|
+
const problems: string[] = [];
|
|
245
|
+
for (const n of [...x.key, ...v.extra]) if (!actual.has(n)) problems.push(`no column ${n}`);
|
|
246
|
+
for (const c of v.cols) {
|
|
247
|
+
const t = actual.get(c.name);
|
|
248
|
+
if (t === undefined) problems.push(`no column ${c.name}`);
|
|
249
|
+
else if (CATALOG_TYPE[c.type] && t !== CATALOG_TYPE[c.type]) problems.push(`${c.name} is ${t}, the profile says ${c.type}`);
|
|
250
|
+
}
|
|
251
|
+
const known = new Set([...x.key, ...v.extra, ...v.cols.map((c) => c.name)]);
|
|
252
|
+
for (const n of actual.keys()) if (!known.has(n as string)) problems.push(`column ${n} isn't in the profile`);
|
|
253
|
+
let arg: unknown = null;
|
|
254
|
+
try { arg = JSON.parse(r.arg); } catch { /* compared as text below */ }
|
|
255
|
+
if (!problems.length && JSON.stringify(arg) !== JSON.stringify(v.arg)) problems.push('it writes back other paths than the profile names (a path, a read-only column or the key changed)');
|
|
256
|
+
if (problems.length) out.push({ severity: 'warning', entry: null, field, message: `View ${name} is out of date with the profile: ${problems.join('; ')}. ${again}` });
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return out;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* (sprint 147) Checks on the application's own rows, with no database: the login page (087) and page groups (089).
|
|
264
|
+
*/
|
|
265
|
+
function structureFindings(x: AppRows): Finding[] {
|
|
266
|
+
const out: Finding[] = [];
|
|
267
|
+
const { app, pages, all } = x;
|
|
268
|
+
if (app.login_page) {
|
|
269
|
+
const page = pages.find((p) => Number(p.page_no) === Number(app.login_page));
|
|
270
|
+
const field = 'Application settings → Login page';
|
|
271
|
+
if (!page) out.push({ severity: 'error', entry: null, field, message: `Page ${app.login_page} doesn't exist: the built-in sign-in page is shown instead.` });
|
|
272
|
+
else if (page.requires_auth) out.push({ severity: 'error', entry: null, field, message: `Page ${app.login_page} requires authentication: the built-in sign-in page is shown instead.` });
|
|
273
|
+
else if (!all.some((r) => r.kind === 'region' && r.row.type === 'login' && r.row.page_id === page.id))
|
|
274
|
+
out.push({ severity: 'error', entry: null, field, message: `Page ${app.login_page} has no Login region: nobody can sign in.` });
|
|
275
|
+
}
|
|
276
|
+
const groups = new Set(all.filter((r) => r.kind === 'page_group').map((r) => r.row.name));
|
|
277
|
+
for (const p of [...pages].sort((a, b) => a.page_no - b.page_no))
|
|
278
|
+
if (p.page_group != null && !groups.has(p.page_group))
|
|
279
|
+
out.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).` });
|
|
280
|
+
return out;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* (sprint 147) References between the application's components: pages, items, lists of values, schemes, build options,
|
|
285
|
+
* layouts, regions, processes, workflow steps. `ext.workspace` (the remote servers and web credentials of the
|
|
286
|
+
* application's workspace) and `ext.otherAppPage` (a branch to another application) need a database: without them
|
|
287
|
+
* (an export checked offline) those checks are skipped.
|
|
288
|
+
*/
|
|
289
|
+
async function referenceFindings(x: AppRows, ext: {
|
|
290
|
+
workspace: { servers: Map<string, string | null>; credentials: Set<string> } | null;
|
|
291
|
+
otherAppPage: ((alias: string, pageNo: number) => Promise<boolean>) | null;
|
|
292
|
+
}): Promise<Finding[]> {
|
|
293
|
+
const findings: Finding[] = [];
|
|
294
|
+
const { entries, all, byKey } = x;
|
|
295
|
+
const pageNos = new Set(entries.filter((e) => e.kind === 'page').map((e) => e.pageNo));
|
|
296
|
+
const items = new Set(all.filter((x) => x.kind === 'item' || x.kind === 'app_item').map((x) => x.row.name.toUpperCase()));
|
|
297
|
+
const lovs = new Set(all.filter((x) => x.kind === 'lov').map((x) => x.row.name.toUpperCase()));
|
|
298
|
+
const schemes = new Set(all.filter((x) => x.kind === 'authz_scheme').map((x) => x.row.name));
|
|
299
|
+
const buildOptions = new Set(all.filter((x) => x.kind === 'build_option').map((x) => x.row.name));
|
|
300
|
+
const layouts = new Set(all.filter((x) => x.kind === 'report_layout').map((x) => x.row.name.toUpperCase()));
|
|
301
|
+
const regionIds = new Set(all.filter((x) => x.kind === 'region').map((x) => x.row.id));
|
|
302
|
+
// (104) a document button names a document template or a report query
|
|
303
|
+
const documents = new Set(all.filter((x) => x.kind === 'document_template' || x.kind === 'report_query').map((x) => x.row.name.toUpperCase()));
|
|
304
|
+
const missing = (entry: Entry | null, field: string, message: string, severity: Severity = 'error') => findings.push({ severity, entry, field, message });
|
|
305
|
+
// (141) remote servers and web credentials of the application's workspace (sources and invoke steps may name them)
|
|
306
|
+
const wsServers = ext.workspace?.servers ?? new Map<string, string | null>();
|
|
307
|
+
const wsCredentials = ext.workspace?.credentials ?? new Set<string>();
|
|
308
|
+
const appCredentials = new Set(all.filter((x) => x.kind === 'web_credential').map((x) => String(x.row.name).toUpperCase()));
|
|
309
|
+
|
|
310
|
+
for (const e of entries) {
|
|
311
|
+
for (const f of e.fields) {
|
|
312
|
+
if (!f.value) continue;
|
|
313
|
+
if (f.kind === 'page' && f.value && !pageNos.has(Number(f.value))) missing(e, f.label, `Page ${f.value} doesn't exist.`);
|
|
314
|
+
if (f.kind === 'build_option' && !buildOptions.has(f.value.replace(/^!/, '')))
|
|
315
|
+
missing(e, f.label, `Build option ${f.value.replace(/^!/, '')} doesn't exist, so the component is always left out.`);
|
|
316
|
+
if (f.kind === 'authz') {
|
|
317
|
+
const name = f.value.replace(/^!/, '');
|
|
318
|
+
if (!schemes.has(name) && !BUILT_IN.has(name)) missing(e, f.label, `Authorization scheme ${name} doesn't exist (nobody passes it).`);
|
|
319
|
+
}
|
|
320
|
+
for (const m of f.value.matchAll(/LOV:([A-Za-z0-9_]+)/g)) if (!lovs.has(m[1].toUpperCase())) missing(e, f.label, `List of values ${m[1].toUpperCase()} doesn't exist.`);
|
|
321
|
+
if (f.kind === 'code' || f.kind === 'text' || f.kind === 'textarea' || f.kind === 'json')
|
|
322
|
+
for (const m of f.value.matchAll(ITEM_REF))
|
|
323
|
+
if (!items.has(m[1].toUpperCase())) missing(e, f.label, `Item ${m[1].toUpperCase()} doesn't exist (its value is always empty).`, 'warning');
|
|
324
|
+
if (f.kind === 'json') {
|
|
325
|
+
let cfg: any;
|
|
326
|
+
try {
|
|
327
|
+
cfg = JSON.parse(f.value);
|
|
328
|
+
} catch {
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
if (cfg?.link?.page !== undefined && !pageNos.has(Number(cfg.link.page))) missing(e, f.label, `The link goes to page ${cfg.link.page}, which doesn't exist.`);
|
|
332
|
+
if (cfg?.link?.items) for (const k of Object.keys(cfg.link.items)) if (!items.has(k.toUpperCase())) missing(e, f.label, `The link sets item ${k}, which doesn't exist.`, 'warning');
|
|
333
|
+
if (cfg?.pdf?.layout && !layouts.has(String(cfg.pdf.layout).toUpperCase())) missing(e, f.label, `Report layout ${cfg.pdf.layout} doesn't exist (the built-in layout is used).`, 'warning');
|
|
334
|
+
if (typeof cfg?.public_reports === 'string' && !schemes.has(cfg.public_reports)) missing(e, f.label, `Authorization scheme ${cfg.public_reports} doesn't exist.`);
|
|
335
|
+
if (e.kind === 'region' && cfg?.report !== undefined && !regionIds.has(Number(cfg.report))) missing(e, f.label, `Region ${cfg.report} doesn't exist.`);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
// forms and grids
|
|
340
|
+
for (const { kind, row } of all) {
|
|
341
|
+
const e = byKey.get(`${kind}-${row.id}`) ?? null;
|
|
342
|
+
// a grid on a REST data source takes its key from the source's key columns when it has no primary key column
|
|
343
|
+
const restKeys = row.rest_source
|
|
344
|
+
? all.find((x) => x.kind === 'rest_source' && String(x.row.name).toUpperCase() === String(row.rest_source).toUpperCase())?.row.key_columns
|
|
345
|
+
: null;
|
|
346
|
+
if (kind === 'region' && row.type === 'grid' && (!(row.table_name || row.rest_source) || !(row.pk_column || restKeys?.length)))
|
|
347
|
+
missing(e, 'Source', 'A grid needs a table (or a REST data source with key columns) and a primary key column.');
|
|
348
|
+
if (kind === 'region' && row.type === 'grid' && !all.some((x) => x.kind === 'process' && x.row.type === 'grid_dml' && x.row.region_id === row.id))
|
|
349
|
+
missing(e, 'Source', 'No grid_dml process saves this grid, so it is read-only.', 'info');
|
|
350
|
+
if (kind === 'region' && row.type === 'form' && row.pk_item && !items.has(String(row.pk_item).toUpperCase())) missing(e, 'Primary key item', `Item ${row.pk_item} doesn't exist.`);
|
|
351
|
+
// (140) a list item (select list, radio group, shuttle, select one or many …) shows no choices without a list of values
|
|
352
|
+
if (kind === 'item' && LOV_CHECKED.has(row.type) && !String(row.lov ?? '').trim())
|
|
353
|
+
missing(e, 'List of values', `A ${row.type} item needs a list of values: it has no choices, and no value passes the check on submit.`, 'warning');
|
|
354
|
+
// (078) a URL region: a literal origin, valid attributes
|
|
355
|
+
if (kind === 'region' && row.type === 'url') {
|
|
356
|
+
const target = urlTarget(row.source);
|
|
357
|
+
if ('error' in target) missing(e, 'Source', target.error);
|
|
358
|
+
const problem = urlConfigProblem(row.config);
|
|
359
|
+
if (problem) missing(e, 'Attributes', problem);
|
|
360
|
+
}
|
|
361
|
+
if (kind === 'computation' && !items.has(String(row.item_name).toUpperCase())) missing(e, 'Item', `Item ${row.item_name} doesn't exist (the computation sets nothing).`);
|
|
362
|
+
if (kind === 'computation' && row.type === 'item' && !items.has(String(row.expression).toUpperCase())) missing(e, 'Expression', `Item ${row.expression} doesn't exist (its value is always empty).`, 'warning');
|
|
363
|
+
if (kind === 'process' && (row.type === 'form_dml' || row.type === 'grid_dml') && !row.region_id) missing(e, 'Region', `A ${row.type} process needs its region.`);
|
|
364
|
+
if (kind === 'process') {
|
|
365
|
+
for (const problem of processProblems(row.type, row.config)) missing(e, 'Configuration', problem);
|
|
366
|
+
if (row.parent_process && !all.some((x) => x.kind === 'process' && x.row.page_id === row.page_id && x.row.type === 'chain' && x.row.name === row.parent_process))
|
|
367
|
+
missing(e, 'Chain (parent process)', `There is no chain process "${row.parent_process}" on this page, so this process never runs.`);
|
|
368
|
+
if (row.type === 'workflow' && (row.config?.action ?? 'start') === 'start' && row.config?.definition
|
|
369
|
+
&& !all.some((x) => x.kind === 'workflow_definition' && x.row.name === String(row.config.definition).toUpperCase()))
|
|
370
|
+
missing(e, 'Configuration', `Workflow definition ${row.config.definition} doesn't exist.`);
|
|
371
|
+
}
|
|
372
|
+
if (kind === 'branch' && row.target_type === 'page' && row.target_page && !pageNos.has(Number(row.target_page))) missing(e, 'Page', `Page ${row.target_page} doesn't exist.`);
|
|
373
|
+
if (kind === 'branch' && row.target_type === 'app' && ext.otherAppPage && !(await ext.otherAppPage(row.target_app, row.target_page)))
|
|
374
|
+
missing(e, 'Application (app)', `Application ${row.target_app} has no page ${row.target_page}.`);
|
|
375
|
+
if (kind === 'button' && row.action === 'document' && !documents.has(String(row.document ?? '').toUpperCase()))
|
|
376
|
+
missing(e, 'Document template', row.document ? `Document template or report query ${row.document} doesn't exist.` : 'A document button needs a document template or a report query.');
|
|
377
|
+
if (kind === 'rest_module') for (const problem of handlerProblems(row.handlers)) missing(e, 'Handlers (JSON)', problem);
|
|
378
|
+
if (kind === 'rest_source') {
|
|
379
|
+
// (141) an export may name a server or credential this workspace doesn't have (yet): it imports, and says so here
|
|
380
|
+
if (ext.workspace && row.server && !wsServers.has(row.server))
|
|
381
|
+
missing(e, 'Remote server', `Remote server ${row.server} doesn't exist in this application's workspace: add it under Workspace utilities → Remote servers (calls fail until then).`, 'warning');
|
|
382
|
+
const credential = row.credential || (row.server ? wsServers.get(row.server) : null);
|
|
383
|
+
if (ext.workspace && credential && !appCredentials.has(credential) && !wsCredentials.has(credential))
|
|
384
|
+
missing(e, 'Web credential', `Web credential ${credential} is neither the application's nor its workspace's (calls fail until it exists).`, 'warning');
|
|
385
|
+
}
|
|
386
|
+
if (kind === 'workflow_definition') {
|
|
387
|
+
const tasks = new Set(all.filter((x) => x.kind === 'task_definition').map((x) => x.row.name.toUpperCase()));
|
|
388
|
+
const outcomes = outcomeMap(all.filter((x) => x.kind === 'task_definition').map((x) => x.row));
|
|
389
|
+
for (const problem of stepProblems(row.steps, tasks, { outcomes, drafts: false })) missing(e, 'Steps (JSON)', problem);
|
|
390
|
+
for (const warning of stepWarnings(row.steps)) missing(e, 'Steps (JSON)', warning, 'warning');
|
|
391
|
+
// invoke_api steps: REST data sources, parameters, web credentials, variables (sprint 32)
|
|
392
|
+
const refs = {
|
|
393
|
+
sources: new Map(all.filter((x) => x.kind === 'rest_source').map((x) => [String(x.row.name).toUpperCase(), x.row])),
|
|
394
|
+
credentials: new Set([...appCredentials, ...wsCredentials]),
|
|
395
|
+
startVars: [...String(row.title ?? '').matchAll(/&([A-Za-z][A-Za-z0-9_]*)\./g)].map((m) => m[1]),
|
|
396
|
+
};
|
|
397
|
+
const invokeRefs = invokeStepReferences(row.steps, refs);
|
|
398
|
+
for (const problem of invokeRefs.errors) missing(e, 'Steps (JSON)', problem);
|
|
399
|
+
for (const warning of invokeRefs.warnings) missing(e, 'Steps (JSON)', warning, 'warning');
|
|
400
|
+
if (row.dev_version) {
|
|
401
|
+
const field = `Steps of version ${row.dev_version} (development)`;
|
|
402
|
+
for (const problem of stepProblems(row.dev_steps, tasks, { outcomes })) missing(e, field, problem, 'warning');
|
|
403
|
+
for (const warning of stepWarnings(row.dev_steps)) missing(e, field, warning, 'warning');
|
|
404
|
+
const devRefs = invokeStepReferences(row.dev_steps, refs);
|
|
405
|
+
for (const problem of [...devRefs.errors, ...devRefs.warnings]) missing(e, field, problem, 'warning');
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
if (kind === 'report_query' && row.layout && !layouts.has(String(row.layout).toUpperCase())) missing(e, 'Report layout', `Report layout ${row.layout} doesn't exist (the default layout is used).`, 'warning');
|
|
409
|
+
if (kind === 'app_computation' && row.type === 'item' && !items.has(String(row.expression).toUpperCase())) missing(e, 'Expression', `Item ${row.expression} doesn't exist (its value is always empty).`, 'warning');
|
|
410
|
+
if (kind === 'document_template') {
|
|
411
|
+
const problem = templateProblem(row.template ?? '');
|
|
412
|
+
if (problem) missing(e, 'Template (HTML)', problem);
|
|
413
|
+
if (row.layout && !layouts.has(String(row.layout).toUpperCase())) missing(e, 'Report layout', `Report layout ${row.layout} doesn't exist (the default layout is used).`, 'warning');
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return findings;
|
|
418
|
+
}
|
|
419
|
+
|
|
201
420
|
const ITEM_REF = /(?<![A-Za-z0-9_$&:])(?::|&)(P\d+_[A-Z0-9_]+)\b/gi;
|
|
202
421
|
const BUILT_IN = new Set(['MUST_NOT_BE_PUBLIC_USER']);
|
|
203
422
|
|
|
@@ -210,8 +429,7 @@ export async function advise(appId: number, tx?: pg.ClientBase, opts: { db?: Db
|
|
|
210
429
|
const query = (sql: string, params: unknown[] = []) => (tx ? tx.query(sql, params) : owner.query(sql, params));
|
|
211
430
|
const one = async (sql: string, params: unknown[] = []) => (await query(sql, params)).rows[0];
|
|
212
431
|
const app = await one('select * from meta.app where id = $1', [appId]);
|
|
213
|
-
const
|
|
214
|
-
const byKey = new Map(entries.map((e) => [`${e.kind}-${e.id}`, e]));
|
|
432
|
+
const pages = (await query('select * from meta.page where app_id = $1 order by page_no', [appId])).rows;
|
|
215
433
|
const findings: Finding[] = [];
|
|
216
434
|
let checked = 0;
|
|
217
435
|
|
|
@@ -226,17 +444,16 @@ export async function advise(appId: number, tx?: pg.ClientBase, opts: { db?: Db
|
|
|
226
444
|
if (tx) for (const [kind, spec] of Object.entries(COMPONENTS)) lists.push(await rowsOf(kind, spec.table, spec.scope));
|
|
227
445
|
else lists.push(...(await Promise.all(Object.entries(COMPONENTS).map(([kind, spec]) => rowsOf(kind, spec.table, spec.scope)))));
|
|
228
446
|
const all = lists.flat();
|
|
447
|
+
const byKind = new Map<string, any[]>();
|
|
448
|
+
for (const x of all) byKind.set(x.kind, [...(byKind.get(x.kind) ?? []), x.row]);
|
|
449
|
+
for (const [kind, spec] of Object.entries(COMPONENTS))
|
|
450
|
+
byKind.get(kind)?.sort((a, b) => spec.scope === 'page' ? (a.page_no - b.page_no || (a.seq ?? 0) - (b.seq ?? 0) || a.id - b.id) : a.id - b.id);
|
|
451
|
+
const entries = entriesOf(appId, pages, byKind);
|
|
452
|
+
const byKey = new Map(entries.map((e) => [`${e.kind}-${e.id}`, e]));
|
|
453
|
+
const rows: AppRows = { app, pages, all, entries, byKey };
|
|
229
454
|
|
|
230
455
|
// metadata checks, as the owner (before the role switch below: the application's role can't read meta)
|
|
231
|
-
|
|
232
|
-
if (app.login_page) {
|
|
233
|
-
const page = await one('select id, requires_auth from meta.page where app_id = $1 and page_no = $2', [appId, app.login_page]);
|
|
234
|
-
const field = 'Application settings → Login page';
|
|
235
|
-
if (!page) findings.push({ severity: 'error', entry: null, field, message: `Page ${app.login_page} doesn't exist: the built-in sign-in page is shown instead.` });
|
|
236
|
-
else if (page.requires_auth) findings.push({ severity: 'error', entry: null, field, message: `Page ${app.login_page} requires authentication: the built-in sign-in page is shown instead.` });
|
|
237
|
-
else if (!all.some((x) => x.kind === 'region' && x.row.type === 'login' && Number(x.row.page_no) === Number(app.login_page)))
|
|
238
|
-
findings.push({ severity: 'error', entry: null, field, message: `Page ${app.login_page} has no Login region: nobody can sign in.` });
|
|
239
|
-
}
|
|
456
|
+
findings.push(...structureFindings(rows));
|
|
240
457
|
// (sprint 130, SEC-3) administrators of the application who sign in with only a password
|
|
241
458
|
if (app.authentication === 'app_users' && (app.local_login || (app.ldap_directories ?? []).length)) {
|
|
242
459
|
const covered = app.mfa === 'required' || (app.mfa === 'roles' && (app.mfa_roles ?? []).includes('admin'));
|
|
@@ -245,11 +462,8 @@ export async function advise(appId: number, tx?: pg.ClientBase, opts: { db?: Db
|
|
|
245
462
|
findings.push({ severity: 'warning', entry: null, field: 'Application settings → Two-factor sign-in',
|
|
246
463
|
message: `${admins === 1 ? 'A user' : `${admins} users`} with the admin role sign${admins === 1 ? 's' : ''} in with only a password: require two-factor sign-in (Required, or Required for roles: admin).` });
|
|
247
464
|
}
|
|
248
|
-
|
|
249
|
-
|
|
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)
|
|
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)));
|
|
465
|
+
findings.push(...(await securityFindings(rows, query)));
|
|
466
|
+
findings.push(...(await jsonSourceFindings(app.json_sources, query)));
|
|
253
467
|
// ---- SQL, as the application's role, rolled back (a savepoint in the caller's transaction)
|
|
254
468
|
// (090) an isolated workspace's application: planned on the workspace's developer login, in a
|
|
255
469
|
// transaction of its own (planning can run the application's functions; RESET ROLE there only
|
|
@@ -426,128 +640,12 @@ export async function advise(appId: number, tx?: pg.ClientBase, opts: { db?: Db
|
|
|
426
640
|
}
|
|
427
641
|
|
|
428
642
|
// ---- references
|
|
429
|
-
const pageNos = new Set(entries.filter((e) => e.kind === 'page').map((e) => e.pageNo));
|
|
430
|
-
const items = new Set(all.filter((x) => x.kind === 'item' || x.kind === 'app_item').map((x) => x.row.name.toUpperCase()));
|
|
431
|
-
const lovs = new Set(all.filter((x) => x.kind === 'lov').map((x) => x.row.name.toUpperCase()));
|
|
432
|
-
const schemes = new Set(all.filter((x) => x.kind === 'authz_scheme').map((x) => x.row.name));
|
|
433
|
-
const buildOptions = new Set(all.filter((x) => x.kind === 'build_option').map((x) => x.row.name));
|
|
434
|
-
const layouts = new Set(all.filter((x) => x.kind === 'report_layout').map((x) => x.row.name.toUpperCase()));
|
|
435
|
-
const regionIds = new Set(all.filter((x) => x.kind === 'region').map((x) => x.row.id));
|
|
436
|
-
// (104) a document button names a document template or a report query
|
|
437
|
-
const documents = new Set(all.filter((x) => x.kind === 'document_template' || x.kind === 'report_query').map((x) => x.row.name.toUpperCase()));
|
|
438
|
-
const missing = (entry: Entry | null, field: string, message: string, severity: Severity = 'error') => findings.push({ severity, entry, field, message });
|
|
439
|
-
// (141) remote servers and web credentials of the application's workspace (sources and invoke steps may name them)
|
|
440
643
|
const wsServers = new Map((await query('select static_id, credential from meta.remote_server where workspace_id = meta.app_workspace($1)', [appId])).rows.map((r) => [r.static_id as string, r.credential as string | null]));
|
|
441
644
|
const wsCredentials = new Set((await query('select name from meta.workspace_credential where workspace_id = meta.app_workspace($1)', [appId])).rows.map((r) => r.name as string));
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
if (!f.value) continue;
|
|
447
|
-
if (f.kind === 'page' && f.value && !pageNos.has(Number(f.value))) missing(e, f.label, `Page ${f.value} doesn't exist.`);
|
|
448
|
-
if (f.kind === 'build_option' && !buildOptions.has(f.value.replace(/^!/, '')))
|
|
449
|
-
missing(e, f.label, `Build option ${f.value.replace(/^!/, '')} doesn't exist, so the component is always left out.`);
|
|
450
|
-
if (f.kind === 'authz') {
|
|
451
|
-
const name = f.value.replace(/^!/, '');
|
|
452
|
-
if (!schemes.has(name) && !BUILT_IN.has(name)) missing(e, f.label, `Authorization scheme ${name} doesn't exist (nobody passes it).`);
|
|
453
|
-
}
|
|
454
|
-
for (const m of f.value.matchAll(/LOV:([A-Za-z0-9_]+)/g)) if (!lovs.has(m[1].toUpperCase())) missing(e, f.label, `List of values ${m[1].toUpperCase()} doesn't exist.`);
|
|
455
|
-
if (f.kind === 'code' || f.kind === 'text' || f.kind === 'textarea' || f.kind === 'json')
|
|
456
|
-
for (const m of f.value.matchAll(ITEM_REF))
|
|
457
|
-
if (!items.has(m[1].toUpperCase())) missing(e, f.label, `Item ${m[1].toUpperCase()} doesn't exist (its value is always empty).`, 'warning');
|
|
458
|
-
if (f.kind === 'json') {
|
|
459
|
-
let cfg: any;
|
|
460
|
-
try {
|
|
461
|
-
cfg = JSON.parse(f.value);
|
|
462
|
-
} catch {
|
|
463
|
-
continue;
|
|
464
|
-
}
|
|
465
|
-
if (cfg?.link?.page !== undefined && !pageNos.has(Number(cfg.link.page))) missing(e, f.label, `The link goes to page ${cfg.link.page}, which doesn't exist.`);
|
|
466
|
-
if (cfg?.link?.items) for (const k of Object.keys(cfg.link.items)) if (!items.has(k.toUpperCase())) missing(e, f.label, `The link sets item ${k}, which doesn't exist.`, 'warning');
|
|
467
|
-
if (cfg?.pdf?.layout && !layouts.has(String(cfg.pdf.layout).toUpperCase())) missing(e, f.label, `Report layout ${cfg.pdf.layout} doesn't exist (the built-in layout is used).`, 'warning');
|
|
468
|
-
if (typeof cfg?.public_reports === 'string' && !schemes.has(cfg.public_reports)) missing(e, f.label, `Authorization scheme ${cfg.public_reports} doesn't exist.`);
|
|
469
|
-
if (e.kind === 'region' && cfg?.report !== undefined && !regionIds.has(Number(cfg.report))) missing(e, f.label, `Region ${cfg.report} doesn't exist.`);
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
// forms and grids
|
|
474
|
-
for (const { kind, row } of all) {
|
|
475
|
-
const e = byKey.get(`${kind}-${row.id}`) ?? null;
|
|
476
|
-
// a grid on a REST data source takes its key from the source's key columns when it has no primary key column
|
|
477
|
-
const restKeys = row.rest_source
|
|
478
|
-
? all.find((x) => x.kind === 'rest_source' && String(x.row.name).toUpperCase() === String(row.rest_source).toUpperCase())?.row.key_columns
|
|
479
|
-
: null;
|
|
480
|
-
if (kind === 'region' && row.type === 'grid' && (!(row.table_name || row.rest_source) || !(row.pk_column || restKeys?.length)))
|
|
481
|
-
missing(e, 'Source', 'A grid needs a table (or a REST data source with key columns) and a primary key column.');
|
|
482
|
-
if (kind === 'region' && row.type === 'grid' && !all.some((x) => x.kind === 'process' && x.row.type === 'grid_dml' && x.row.region_id === row.id))
|
|
483
|
-
missing(e, 'Source', 'No grid_dml process saves this grid, so it is read-only.', 'info');
|
|
484
|
-
if (kind === 'region' && row.type === 'form' && row.pk_item && !items.has(String(row.pk_item).toUpperCase())) missing(e, 'Primary key item', `Item ${row.pk_item} doesn't exist.`);
|
|
485
|
-
// (140) a list item (select list, radio group, shuttle, select one or many …) shows no choices without a list of values
|
|
486
|
-
if (kind === 'item' && LOV_CHECKED.has(row.type) && !String(row.lov ?? '').trim())
|
|
487
|
-
missing(e, 'List of values', `A ${row.type} item needs a list of values: it has no choices, and no value passes the check on submit.`, 'warning');
|
|
488
|
-
// (078) a URL region: a literal origin, valid attributes
|
|
489
|
-
if (kind === 'region' && row.type === 'url') {
|
|
490
|
-
const target = urlTarget(row.source);
|
|
491
|
-
if ('error' in target) missing(e, 'Source', target.error);
|
|
492
|
-
const problem = urlConfigProblem(row.config);
|
|
493
|
-
if (problem) missing(e, 'Attributes', problem);
|
|
494
|
-
}
|
|
495
|
-
if (kind === 'computation' && !items.has(String(row.item_name).toUpperCase())) missing(e, 'Item', `Item ${row.item_name} doesn't exist (the computation sets nothing).`);
|
|
496
|
-
if (kind === 'computation' && row.type === 'item' && !items.has(String(row.expression).toUpperCase())) missing(e, 'Expression', `Item ${row.expression} doesn't exist (its value is always empty).`, 'warning');
|
|
497
|
-
if (kind === 'process' && (row.type === 'form_dml' || row.type === 'grid_dml') && !row.region_id) missing(e, 'Region', `A ${row.type} process needs its region.`);
|
|
498
|
-
if (kind === 'process') {
|
|
499
|
-
for (const problem of processProblems(row.type, row.config)) missing(e, 'Configuration', problem);
|
|
500
|
-
if (row.parent_process && !all.some((x) => x.kind === 'process' && x.row.page_id === row.page_id && x.row.type === 'chain' && x.row.name === row.parent_process))
|
|
501
|
-
missing(e, 'Chain (parent process)', `There is no chain process "${row.parent_process}" on this page, so this process never runs.`);
|
|
502
|
-
if (row.type === 'workflow' && (row.config?.action ?? 'start') === 'start' && row.config?.definition
|
|
503
|
-
&& !all.some((x) => x.kind === 'workflow_definition' && x.row.name === String(row.config.definition).toUpperCase()))
|
|
504
|
-
missing(e, 'Configuration', `Workflow definition ${row.config.definition} doesn't exist.`);
|
|
505
|
-
}
|
|
506
|
-
if (kind === 'branch' && row.target_type === 'page' && row.target_page && !pageNos.has(Number(row.target_page))) missing(e, 'Page', `Page ${row.target_page} doesn't exist.`);
|
|
507
|
-
if (kind === 'branch' && row.target_type === 'app'
|
|
508
|
-
&& !(await one('select 1 as ok from meta.app a join meta.page p on p.app_id = a.id where a.alias = $1 and p.page_no = $2', [row.target_app, row.target_page])))
|
|
509
|
-
missing(e, 'Application (app)', `Application ${row.target_app} has no page ${row.target_page}.`);
|
|
510
|
-
if (kind === 'button' && row.action === 'document' && !documents.has(String(row.document ?? '').toUpperCase()))
|
|
511
|
-
missing(e, 'Document template', row.document ? `Document template or report query ${row.document} doesn't exist.` : 'A document button needs a document template or a report query.');
|
|
512
|
-
if (kind === 'rest_module') for (const problem of handlerProblems(row.handlers)) missing(e, 'Handlers (JSON)', problem);
|
|
513
|
-
if (kind === 'rest_source') {
|
|
514
|
-
// (141) an export may name a server or credential this workspace doesn't have (yet): it imports, and says so here
|
|
515
|
-
if (row.server && !wsServers.has(row.server))
|
|
516
|
-
missing(e, 'Remote server', `Remote server ${row.server} doesn't exist in this application's workspace: add it under Workspace utilities → Remote servers (calls fail until then).`, 'warning');
|
|
517
|
-
const credential = row.credential || (row.server ? wsServers.get(row.server) : null);
|
|
518
|
-
if (credential && !appCredentials.has(credential) && !wsCredentials.has(credential))
|
|
519
|
-
missing(e, 'Web credential', `Web credential ${credential} is neither the application's nor its workspace's (calls fail until it exists).`, 'warning');
|
|
520
|
-
}
|
|
521
|
-
if (kind === 'workflow_definition') {
|
|
522
|
-
const tasks = new Set(all.filter((x) => x.kind === 'task_definition').map((x) => x.row.name.toUpperCase()));
|
|
523
|
-
const outcomes = outcomeMap(all.filter((x) => x.kind === 'task_definition').map((x) => x.row));
|
|
524
|
-
for (const problem of stepProblems(row.steps, tasks, { outcomes, drafts: false })) missing(e, 'Steps (JSON)', problem);
|
|
525
|
-
for (const warning of stepWarnings(row.steps)) missing(e, 'Steps (JSON)', warning, 'warning');
|
|
526
|
-
// invoke_api steps: REST data sources, parameters, web credentials, variables (sprint 32)
|
|
527
|
-
const refs = {
|
|
528
|
-
sources: new Map(all.filter((x) => x.kind === 'rest_source').map((x) => [String(x.row.name).toUpperCase(), x.row])),
|
|
529
|
-
credentials: new Set([...appCredentials, ...wsCredentials]),
|
|
530
|
-
startVars: [...String(row.title ?? '').matchAll(/&([A-Za-z][A-Za-z0-9_]*)\./g)].map((m) => m[1]),
|
|
531
|
-
};
|
|
532
|
-
const invokeRefs = invokeStepReferences(row.steps, refs);
|
|
533
|
-
for (const problem of invokeRefs.errors) missing(e, 'Steps (JSON)', problem);
|
|
534
|
-
for (const warning of invokeRefs.warnings) missing(e, 'Steps (JSON)', warning, 'warning');
|
|
535
|
-
if (row.dev_version) {
|
|
536
|
-
const field = `Steps of version ${row.dev_version} (development)`;
|
|
537
|
-
for (const problem of stepProblems(row.dev_steps, tasks, { outcomes })) missing(e, field, problem, 'warning');
|
|
538
|
-
for (const warning of stepWarnings(row.dev_steps)) missing(e, field, warning, 'warning');
|
|
539
|
-
const devRefs = invokeStepReferences(row.dev_steps, refs);
|
|
540
|
-
for (const problem of [...devRefs.errors, ...devRefs.warnings]) missing(e, field, problem, 'warning');
|
|
541
|
-
}
|
|
542
|
-
}
|
|
543
|
-
if (kind === 'report_query' && row.layout && !layouts.has(String(row.layout).toUpperCase())) missing(e, 'Report layout', `Report layout ${row.layout} doesn't exist (the default layout is used).`, 'warning');
|
|
544
|
-
if (kind === 'app_computation' && row.type === 'item' && !items.has(String(row.expression).toUpperCase())) missing(e, 'Expression', `Item ${row.expression} doesn't exist (its value is always empty).`, 'warning');
|
|
545
|
-
if (kind === 'document_template') {
|
|
546
|
-
const problem = templateProblem(row.template ?? '');
|
|
547
|
-
if (problem) missing(e, 'Template (HTML)', problem);
|
|
548
|
-
if (row.layout && !layouts.has(String(row.layout).toUpperCase())) missing(e, 'Report layout', `Report layout ${row.layout} doesn't exist (the default layout is used).`, 'warning');
|
|
549
|
-
}
|
|
550
|
-
}
|
|
645
|
+
findings.push(...(await referenceFindings(rows, {
|
|
646
|
+
workspace: { servers: wsServers, credentials: wsCredentials },
|
|
647
|
+
otherAppPage: async (alias, pageNo) => !!(await one('select 1 as ok from meta.app a join meta.page p on p.app_id = a.id where a.alias = $1 and p.page_no = $2', [alias, pageNo])),
|
|
648
|
+
})));
|
|
551
649
|
|
|
552
650
|
// ---- (117) REST modules refuse (403) to run as a superuser, a role that bypasses RLS or a member of pgkiln's
|
|
553
651
|
// owner: say so here before the endpoints fail. Without a db_role the runtime connection's own user is the role.
|
|
@@ -586,6 +684,64 @@ export async function advise(appId: number, tx?: pg.ClientBase, opts: { db?: Db
|
|
|
586
684
|
|
|
587
685
|
const SEVERITY_LABEL: Record<Severity, string> = { error: 'Error', warning: 'Warning', info: 'Note' };
|
|
588
686
|
|
|
687
|
+
// ------------------------------------------------------------------ (sprint 147) offline: an export without a database
|
|
688
|
+
|
|
689
|
+
/** The export's list of a kind of component (app.json's keys: lovs, nav, list_entries, data_load_definitions …). */
|
|
690
|
+
const docKey = (kind: string) =>
|
|
691
|
+
kind === 'nav_entry' ? 'nav' : kind === 'data_load_def' ? 'data_load_definitions'
|
|
692
|
+
: kind.endsWith('y') ? `${kind.slice(0, -1)}ies` : /(s|ch)$/.test(kind) ? `${kind}es` : `${kind}s`;
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* An export (meta.export_app's JSON, or filesToDoc of its files) as the rows the checks read. Rows keep the ids the
|
|
696
|
+
* export carries (regions: processes and buttons name them by region_id); the others, and pages, get ids of their
|
|
697
|
+
* own (negative, so they never meet an exported id).
|
|
698
|
+
*/
|
|
699
|
+
export function rowsOfDoc(doc: any): AppRows {
|
|
700
|
+
if (!doc || typeof doc !== 'object' || !doc.app || typeof doc.app !== 'object') throw new Error('not an application export (no "app")');
|
|
701
|
+
let next = 0;
|
|
702
|
+
const idOf = (row: any) => (typeof row?.id === 'number' ? row.id : --next);
|
|
703
|
+
// the checks read names as text (a database row always has one)
|
|
704
|
+
const named = (row: any) => ({ ...row, name: typeof row.name === 'string' ? row.name : String(row.name ?? '') });
|
|
705
|
+
const pages = (Array.isArray(doc.pages) ? doc.pages : []).filter((p: any) => p && typeof p === 'object').map((p: any) => ({ ...named(p), id: --next }));
|
|
706
|
+
const all: Row[] = [];
|
|
707
|
+
for (const [kind, spec] of Object.entries(COMPONENTS)) {
|
|
708
|
+
if (spec.scope === 'page') {
|
|
709
|
+
for (const p of pages) {
|
|
710
|
+
const list = (doc.pages.find((d: any) => d && d.page_no === p.page_no)?.[docKey(kind)] ?? []) as any[];
|
|
711
|
+
for (const row of Array.isArray(list) ? list : []) if (row && typeof row === 'object') all.push({ kind, row: { ...named(row), id: idOf(row), page_id: p.id, page_no: p.page_no } });
|
|
712
|
+
}
|
|
713
|
+
} else {
|
|
714
|
+
const list = doc[docKey(kind)];
|
|
715
|
+
for (const row of Array.isArray(list) ? list : []) if (row && typeof row === 'object') all.push({ kind, row: { ...named(row), id: idOf(row) } });
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
const byKind = new Map<string, any[]>();
|
|
719
|
+
for (const x of all) byKind.set(x.kind, [...(byKind.get(x.kind) ?? []), x.row]);
|
|
720
|
+
const entries = entriesOf(0, pages, byKind);
|
|
721
|
+
return { app: doc.app, pages, all, entries, byKey: new Map(entries.map((e) => [`${e.kind}-${e.id}`, e])) };
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* The checks that need no database, on an export: references between its components, the login page and page
|
|
726
|
+
* groups, the pages' security defaults, workflow steps, process settings and JSON source profiles. Not done
|
|
727
|
+
* offline: planning SQL (it needs the application's tables and role), the catalog (vector and full-text columns, JSON
|
|
728
|
+
* source views, plpgsql_check), the workspace's remote servers and credentials, branches to other applications, and
|
|
729
|
+
* the installation's session limits and users.
|
|
730
|
+
*/
|
|
731
|
+
export async function adviseDoc(doc: any): Promise<{ findings: Finding[]; checked: number }> {
|
|
732
|
+
const x = rowsOfDoc(doc);
|
|
733
|
+
const findings: Finding[] = [...structureFindings(x), ...pageSecurityFindings(x)];
|
|
734
|
+
for (const src of Array.isArray(x.app.json_sources) ? x.app.json_sources : []) {
|
|
735
|
+
if (!src || typeof src !== 'object') continue;
|
|
736
|
+
const problems = jsonSourceProblems(src);
|
|
737
|
+
if (problems.length) findings.push({ severity: 'error', entry: null, field: `JSON source ${src?.name ?? ''}`, message: problems.join(' ') });
|
|
738
|
+
}
|
|
739
|
+
findings.push(...(await referenceFindings(x, { workspace: null, otherAppPage: null })));
|
|
740
|
+
const order = { error: 0, warning: 1, info: 2 };
|
|
741
|
+
findings.sort((a, b) => order[a.severity] - order[b.severity] || (a.entry?.pageNo ?? 0) - (b.entry?.pageNo ?? 0));
|
|
742
|
+
return { findings, checked: 0 };
|
|
743
|
+
}
|
|
744
|
+
|
|
589
745
|
export async function advisorRoutes(app: FastifyInstance) {
|
|
590
746
|
app.get(`${BASE}/apps/:id/advisor`, async (req: Req, reply) => {
|
|
591
747
|
const s = await developer(req, reply);
|
package/src/builder/ai.ts
CHANGED
|
@@ -12,10 +12,10 @@ import { isAdmin } from './locks.ts';
|
|
|
12
12
|
import { back, BASE, csrf, developer, flash, input, region, select, send, shell, type Req } from './ui.ts';
|
|
13
13
|
|
|
14
14
|
// Workspace utilities → AI services (administrators): the AI services of
|
|
15
|
-
// this installation (Claude
|
|
16
|
-
// base URL, write-only API key), which
|
|
17
|
-
// their daily limits, a test, and the
|
|
18
|
-
// AI usage (developers): what an application may use and what it used.
|
|
15
|
+
// this installation (Claude, OpenAI, Gemini or an OpenAI-compatible endpoint,
|
|
16
|
+
// model, effort, output and time limits, base URL, write-only API key), which
|
|
17
|
+
// applications may use each one with their daily limits, a test, and the
|
|
18
|
+
// usage log. Applications → Activity → AI usage (developers): what an application may use and what it used.
|
|
19
19
|
// API keys are encrypted (src/secrets.ts) and never sent back to the browser.
|
|
20
20
|
|
|
21
21
|
const PROVIDERS: [string, string][] = [['anthropic', 'Claude (Anthropic)'], ['openai', 'OpenAI'], ['gemini', 'Gemini (Google, OpenAI-compatible endpoint)'], ['openai_compatible', 'OpenAI-compatible (Ollama, vLLM, Azure OpenAI, a gateway)']];
|
package/src/builder/assistant.ts
CHANGED
|
@@ -41,7 +41,7 @@ export async function assistantSettingsForm(pageId: number, appId: number, r: {
|
|
|
41
41
|
const agents = await agentOptions(appId);
|
|
42
42
|
const agent = typeof cfg.agent === 'string' ? cfg.agent.toUpperCase() : '';
|
|
43
43
|
return html`<h3 class="u-mt15">AI assistant settings</h3>
|
|
44
|
-
<p class="muted u-mt0">A chat with an AI service. What users type, the context rows and the tools' results go to the service's provider (Claude or OpenAI). Context queries and SQL tools run as the application's database role; SQL tools are rolled back unless they may change data.</p>
|
|
44
|
+
<p class="muted u-mt0">A chat with an AI service. What users type, the context rows and the tools' results go to the service's provider (Claude, OpenAI, Gemini, or the OpenAI-compatible endpoint in its base URL). Context queries and SQL tools run as the application's database role; SQL tools are rolled back unless they may change data.</p>
|
|
45
45
|
${allowed.length ? '' : NO_SERVICE}
|
|
46
46
|
${problems.length ? html`<div class="alert alert-error" role="alert">${problems.join(' ')}</div>` : ''}
|
|
47
47
|
<form method="post" action="${BASE}/pages/${pageId}/region/${r.id}/assistant" class="component-form">${csrf(s)}
|
package/src/builder/home.ts
CHANGED
|
@@ -301,7 +301,7 @@ export async function homeRoutes(app: FastifyInstance) {
|
|
|
301
301
|
${card(`${BASE}/ui-defaults`, icon('sliders-horizontal'), 'UI defaults', 'Labels, format masks, help and lists of values per table column, used by the page wizards.')}
|
|
302
302
|
${card(`${BASE}/icons`, icon('image'), 'Icons', 'Browse the icons of the icon picker.')}
|
|
303
303
|
${card(`${BASE}/vector-providers`, icon('search'), 'Vector providers', 'Embeddings for search by meaning: the applications\' vector providers, their vector columns, and whether pgvector is installed.')}
|
|
304
|
-
${(await isAdmin(s.username)) ? card(`${BASE}/ai`, icon('bolt'), 'AI services', 'Claude and OpenAI for the applications: models, keys, limits and usage (administrators).') : ''}
|
|
304
|
+
${(await isAdmin(s.username)) ? card(`${BASE}/ai`, icon('bolt'), 'AI services', 'Claude, OpenAI, Gemini and OpenAI-compatible services for the applications: models, keys, limits and usage (administrators).') : ''}
|
|
305
305
|
${(await isAdmin(s.username)) ? card(`${BASE}/mail`, icon('send'), 'Mail services', 'SMTP, Microsoft 365, Amazon SES, Resend, Postmark, SendGrid or Mailgun for the applications\' e-mail (administrators).') : ''}
|
|
306
306
|
${(await isAdmin(s.username)) ? card(`${BASE}/telemetry`, icon('activity'), 'OpenTelemetry', 'Traces, logs and metrics to your own monitoring through an OpenTelemetry Collector; /metrics for Prometheus (administrators).') : ''}
|
|
307
307
|
${(await isAdmin(s.username)) ? card(`${BASE}/installation`, icon('history'), 'Installation', 'Version, install and upgrade runs, applied migrations (administrators).') : ''}
|
package/src/builder/search.ts
CHANGED
|
@@ -43,6 +43,20 @@ const asText = (v: unknown) => (v === null || v === undefined ? '' : Array.isArr
|
|
|
43
43
|
/** Every component of an application with its searchable fields. */
|
|
44
44
|
export async function appEntries(appId: number, query: (sql: string, params?: unknown[]) => Promise<{ rows: any[] }> = (sql, params) => owner.query(sql, params)): Promise<Entry[]> {
|
|
45
45
|
const pages = (await query('select * from meta.page where app_id = $1 order by page_no', [appId])).rows;
|
|
46
|
+
const rows = new Map<string, any[]>();
|
|
47
|
+
for (const [kind, spec] of Object.entries(COMPONENTS))
|
|
48
|
+
rows.set(kind, spec.scope === 'page'
|
|
49
|
+
? (await query(`select t.* from ${spec.table} t join meta.page p on p.id = t.page_id where p.app_id = $1 order by p.page_no, t.seq, t.id`, [appId])).rows
|
|
50
|
+
: (await query(`select * from ${spec.table} where app_id = $1 order by id`, [appId])).rows);
|
|
51
|
+
return entriesOf(appId, pages, rows);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* (sprint 147) The entries of pages and component rows however they were read: from the database (appEntries) or
|
|
56
|
+
* from an export's files (`pgkiln advise --offline`). A page row has `id` and `page_no`; a page component's row has
|
|
57
|
+
* `page_id`; rows are per kind of COMPONENTS, in order.
|
|
58
|
+
*/
|
|
59
|
+
export function entriesOf(appId: number, pages: any[], rows: Map<string, any[]>): Entry[] {
|
|
46
60
|
const pageById = new Map(pages.map((p) => [p.id, p]));
|
|
47
61
|
const out: Entry[] = pages.map((p) => ({
|
|
48
62
|
kind: 'page', id: p.id, label: `Page ${p.page_no}: ${p.name}`, kindLabel: 'Page', icon: 'file', pageNo: p.page_no,
|
|
@@ -50,10 +64,7 @@ export async function appEntries(appId: number, query: (sql: string, params?: un
|
|
|
50
64
|
fields: PAGE_FIELDS.map((f) => ({ ...f, value: asText(p[f.name]) })),
|
|
51
65
|
}));
|
|
52
66
|
for (const [kind, spec] of Object.entries(COMPONENTS)) {
|
|
53
|
-
const
|
|
54
|
-
? (await query(`select t.* from ${spec.table} t join meta.page p on p.id = t.page_id where p.app_id = $1 order by p.page_no, t.seq, t.id`, [appId])).rows
|
|
55
|
-
: (await query(`select * from ${spec.table} where app_id = $1 order by id`, [appId])).rows;
|
|
56
|
-
for (const row of rows) {
|
|
67
|
+
for (const row of rows.get(kind) ?? []) {
|
|
57
68
|
const page = spec.scope === 'page' ? pageById.get(row.page_id) : null;
|
|
58
69
|
out.push({
|
|
59
70
|
kind, id: row.id, label: spec.summary(row), kindLabel: spec.label, icon: spec.icon, pageNo: page?.page_no ?? null,
|
package/src/cli/advise.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { existsSync } from 'node:fs';
|
|
|
9
9
|
import { join, relative } from 'node:path';
|
|
10
10
|
import { docToFiles, slug, type FileMap } from '../appfiles.ts';
|
|
11
11
|
import { exportDoc, importDoc, readSource, UsageError, type Db } from './apps.ts';
|
|
12
|
+
import type { Finding } from '../builder/advisor.ts';
|
|
12
13
|
|
|
13
14
|
export type Severity = 'error' | 'warning' | 'info';
|
|
14
15
|
export const SEVERITIES: Severity[] = ['error', 'warning', 'info'];
|
|
@@ -33,6 +34,8 @@ export interface AdviseResult {
|
|
|
33
34
|
dir: string | null;
|
|
34
35
|
checked: number;
|
|
35
36
|
findings: AdviseFinding[];
|
|
37
|
+
/** (sprint 147) checked from the files alone, without a database */
|
|
38
|
+
offline?: boolean;
|
|
36
39
|
}
|
|
37
40
|
|
|
38
41
|
const pad = (n: number) => String(n).padStart(4, '0');
|
|
@@ -52,6 +55,39 @@ export function fileOf(files: string[], f: { pageNo: number | null; kind: string
|
|
|
52
55
|
return shared ?? yaml.find((p) => /^app\.(ya?ml|json)$/.test(p)) ?? null;
|
|
53
56
|
}
|
|
54
57
|
|
|
58
|
+
/** An Advisor finding as the report's: where, the component's kind and page. */
|
|
59
|
+
function toAdvise(f: Finding): AdviseFinding {
|
|
60
|
+
const kind = f.entry?.kind ?? null;
|
|
61
|
+
const pageNo = f.entry?.pageNo ?? (/^Page (\d+)\b/.exec(f.field) ? Number(/^Page (\d+)\b/.exec(f.field)![1]) : null);
|
|
62
|
+
return {
|
|
63
|
+
severity: f.severity,
|
|
64
|
+
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',
|
|
65
|
+
field: f.field,
|
|
66
|
+
message: f.message,
|
|
67
|
+
kind,
|
|
68
|
+
label: f.entry?.label ?? '',
|
|
69
|
+
pageNo,
|
|
70
|
+
file: null,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* (sprint 147) Check an export's files alone, with no database: references, the login page and page groups, the
|
|
76
|
+
* pages' security defaults, workflow steps, process settings and JSON source profiles (adviseDoc). SQL is not
|
|
77
|
+
* planned and nothing is read from a catalog; the report says so.
|
|
78
|
+
*/
|
|
79
|
+
export async function adviseOffline(target: string): Promise<AdviseResult> {
|
|
80
|
+
if (!existsSync(target)) throw new UsageError(`${target} not found (--offline checks an export: a directory, a .zip or a JSON file)`);
|
|
81
|
+
const { adviseDoc } = await import('../builder/advisor.ts');
|
|
82
|
+
const source = await readSource(target);
|
|
83
|
+
const files = source.files ?? docToFiles(source.doc);
|
|
84
|
+
const r = await adviseDoc(source.doc);
|
|
85
|
+
const findings = r.findings.map(toAdvise);
|
|
86
|
+
const paths = [...files.keys()];
|
|
87
|
+
for (const f of findings) f.file = fileOf(paths, f);
|
|
88
|
+
return { alias: String(source.doc?.app?.alias ?? 'app'), dir: source.files ? target : null, checked: 0, findings, offline: true };
|
|
89
|
+
}
|
|
90
|
+
|
|
55
91
|
/**
|
|
56
92
|
* Check an export (path) or an application in the database (alias). Nothing is ever committed: an export is
|
|
57
93
|
* imported under a temporary alias in a transaction that is rolled back.
|
|
@@ -67,20 +103,7 @@ export async function adviseTarget(db: Db, target: string): Promise<AdviseResult
|
|
|
67
103
|
const collect = async (appId: number) => {
|
|
68
104
|
const r = await advise(appId, db);
|
|
69
105
|
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
|
-
}
|
|
106
|
+
for (const f of r.findings) findings.push(toAdvise(f));
|
|
84
107
|
};
|
|
85
108
|
if (existsSync(target)) {
|
|
86
109
|
const source = await readSource(target);
|
|
@@ -133,13 +156,14 @@ export function formatText(r: AdviseResult, base: string | null = null) {
|
|
|
133
156
|
return r.findings.map((f) => {
|
|
134
157
|
const path = pathOf(r, f, base);
|
|
135
158
|
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`;
|
|
159
|
+
}).join('') + `${r.alias}: ${r.offline ? 'checked offline (references and security; SQL not planned without a database)' : `${r.checked} SQL fragment(s) checked`}; ${count('error')} error(s), ${count('warning')} warning(s), ${count('info')} note(s).\n`;
|
|
137
160
|
}
|
|
138
161
|
|
|
139
162
|
export function formatJson(r: AdviseResult, base: string | null = null) {
|
|
140
163
|
return JSON.stringify({
|
|
141
164
|
alias: r.alias,
|
|
142
165
|
checked: r.checked,
|
|
166
|
+
offline: !!r.offline,
|
|
143
167
|
counts: Object.fromEntries(SEVERITIES.map((s) => [s, r.findings.filter((f) => f.severity === s).length])),
|
|
144
168
|
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
169
|
}, null, 2) + '\n';
|
package/src/cli/main.ts
CHANGED
|
@@ -332,7 +332,7 @@ const COMMANDS: Record<string, Command> = {
|
|
|
332
332
|
},
|
|
333
333
|
|
|
334
334
|
advise: {
|
|
335
|
-
usage: 'pgkiln advise <path | alias> [--format text|json|sarif] [--fail-on error|warning|info|never] [-o <file>]',
|
|
335
|
+
usage: 'pgkiln advise <path | alias> [--offline] [--format text|json|sarif] [--fail-on error|warning|info|never] [-o <file>]',
|
|
336
336
|
summary: 'check an export or an application with the Advisor (for CI: text, JSON or SARIF)',
|
|
337
337
|
details:
|
|
338
338
|
'Runs the builder\'s Advisor: every SQL fragment is planned as the application\'s database role (unknown\n' +
|
|
@@ -342,10 +342,13 @@ const COMMANDS: Record<string, Command> = {
|
|
|
342
342
|
'A path (an export directory, a .zip or a JSON export) is imported into a transaction that is rolled back:\n' +
|
|
343
343
|
'nothing changes. It needs a database with pgkiln\'s migrations and the application\'s own tables (in CI, a\n' +
|
|
344
344
|
'PostgreSQL service, pgkiln migrate and your migrations). An alias checks the application in the database.\n\n' +
|
|
345
|
+
'With --offline a path is checked from its files alone, without a database: references, the login page, page\n' +
|
|
346
|
+
'groups, security defaults, workflow steps and JSON source profiles; SQL is not planned.\n\n' +
|
|
345
347
|
'SARIF points every finding at the file of its page or component in the export, so GitHub code scanning\n' +
|
|
346
348
|
'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' } },
|
|
349
|
+
options: { format: { type: 'string', short: 'f' }, 'fail-on': { type: 'string' }, output: { type: 'string', short: 'o' }, base: { type: 'string' }, offline: { type: 'boolean' } },
|
|
348
350
|
optionHelp: [
|
|
351
|
+
['--offline', 'check an export\'s files without a database (no SQL planning)'],
|
|
349
352
|
['-f, --format <format>', 'text (default), json or sarif'],
|
|
350
353
|
['--fail-on <level>', 'exit code 1 from this level: error (default), warning, info or never'],
|
|
351
354
|
['-o, --output <file>', 'write the report to a file (the summary still goes to standard error)'],
|
|
@@ -358,10 +361,10 @@ const COMMANDS: Record<string, Command> = {
|
|
|
358
361
|
if (!['text', 'json', 'sarif'].includes(format)) throw new UsageError(`--format: text, json or sarif, not ${format}`);
|
|
359
362
|
if (!['error', 'warning', 'info', 'never'].includes(failOn)) throw new UsageError(`--fail-on: error, warning, info or never, not ${failOn}`);
|
|
360
363
|
process.env.RUNTIME_DATABASE_URL ??= process.env.DATABASE_URL;
|
|
361
|
-
const { adviseTarget, fails, formatJson, formatSarif, formatText } = await import('./advise.ts');
|
|
364
|
+
const { adviseOffline, adviseTarget, fails, formatJson, formatSarif, formatText } = await import('./advise.ts');
|
|
362
365
|
const { closePools } = await import('../db.ts');
|
|
363
366
|
try {
|
|
364
|
-
const r = await withDb((db) => adviseTarget(db, target));
|
|
367
|
+
const r = v.offline ? await adviseOffline(target) : await withDb((db) => adviseTarget(db, target));
|
|
365
368
|
const base = (v.base as string) ?? null;
|
|
366
369
|
const report = format === 'sarif' ? formatSarif(r, version(), base) : format === 'json' ? formatJson(r, base) : formatText(r, base);
|
|
367
370
|
if (v.output) {
|
package/src/cli/mcp.ts
CHANGED
|
@@ -438,15 +438,23 @@ export const TOOLS: Tool[] = [
|
|
|
438
438
|
'unrestricted page protection, long sessions, administrators without a second factor). Each finding names its file in the export.',
|
|
439
439
|
inputSchema: {
|
|
440
440
|
type: 'object',
|
|
441
|
-
properties: {
|
|
441
|
+
properties: {
|
|
442
|
+
...aliasProp,
|
|
443
|
+
path: { type: 'string', description: 'an export directory, .zip or .json instead of the application in the database' },
|
|
444
|
+
offline: { type: 'boolean', description: 'with path: check the files alone, without the database (references and security; no SQL planning)' },
|
|
445
|
+
},
|
|
442
446
|
},
|
|
443
447
|
annotations: { readOnlyHint: true },
|
|
444
448
|
async run(a) {
|
|
445
449
|
const path = str(a, 'path');
|
|
446
450
|
const alias = str(a, 'alias');
|
|
447
451
|
if (!path && !alias) throw new ToolError('give an alias or a path');
|
|
452
|
+
const { adviseOffline, adviseTarget, formatText } = await import('./advise.ts');
|
|
453
|
+
if (a.offline === true) {
|
|
454
|
+
if (!path) throw new ToolError('offline needs a path (an export)');
|
|
455
|
+
return cap(formatText(await adviseOffline(resolve(path))));
|
|
456
|
+
}
|
|
448
457
|
process.env.RUNTIME_DATABASE_URL ??= process.env.DATABASE_URL;
|
|
449
|
-
const { adviseTarget, formatText } = await import('./advise.ts');
|
|
450
458
|
return cap(formatText(await withDb((db) => adviseTarget(db, path ? resolve(path) : alias!))));
|
|
451
459
|
},
|
|
452
460
|
},
|
package/src/json-sources.ts
CHANGED
|
@@ -242,6 +242,18 @@ export function proposeProfile(docs: unknown[], o: { key: string[]; view: string
|
|
|
242
242
|
export interface Grant { grantee: string; privileges: string[] }
|
|
243
243
|
|
|
244
244
|
const triggerArg = (o: object) => lit(JSON.stringify(o));
|
|
245
|
+
const argColumns = (cols: JsonColumn[]) => cols.map((c) => ({ name: c.name, path: c.path, ...(c.readonly ? { readonly: true } : {}) }));
|
|
246
|
+
/** (sprint 146) the argument of a source's view trigger, meta.json_source_write: what the view writes back */
|
|
247
|
+
export const viewTriggerArg = (x: JsonSource) =>
|
|
248
|
+
({ name: x.name, schema: x.schema, table: x.table, key: x.key, column: x.column, rows: !!x.rows, columns: argColumns(x.columns) });
|
|
249
|
+
/** (sprint 146) the argument of an array view's trigger, meta.json_array_write */
|
|
250
|
+
export const arrayTriggerArg = (x: JsonSource, a: JsonArray) =>
|
|
251
|
+
({ name: x.name, schema: x.schema, table: x.table, key: x.key, column: x.column, array: a.path, row_key: ROW_KEY, position: POSITION, columns: argColumns(a.columns) });
|
|
252
|
+
/** (sprint 146) a profile type as format_type() names it in the catalog */
|
|
253
|
+
export const CATALOG_TYPE: Record<JsonType, string> = {
|
|
254
|
+
text: 'text', integer: 'integer', bigint: 'bigint', numeric: 'numeric', boolean: 'boolean', date: 'date',
|
|
255
|
+
timestamp: 'timestamp without time zone', timestamptz: 'timestamp with time zone', jsonb: 'jsonb',
|
|
256
|
+
};
|
|
245
257
|
const grantsSql = (view: string, grants: Grant[]) =>
|
|
246
258
|
grants.filter((g) => g.privileges.length).map((g) => `grant ${g.privileges.join(', ')} on ${view} to ${g.grantee === 'PUBLIC' ? 'public' : pg.escapeIdentifier(g.grantee)}`);
|
|
247
259
|
|
|
@@ -260,20 +272,14 @@ export function jsonSourceDdl(x: JsonSource, o: { app: string; grants: Grant[];
|
|
|
260
272
|
const cols = x.columns.map((c) => `${columnSql(doc, c)} as ${ident(c.name)}`);
|
|
261
273
|
out.push(`create view ${view} with (security_invoker = true) as\nselect ${[keySel, ...cols].join(',\n ')}\n from ${table} t`);
|
|
262
274
|
out.push(`comment on view ${view} is ${lit(`JSON source ${x.name} of application ${o.app}: ${x.schema}.${x.table}.${x.column} (made by pgkiln; Shared Components → JSON sources makes it again)`)}`);
|
|
263
|
-
out.push(`create trigger json_source_write\n instead of insert or update or delete on ${view}\n for each row execute function meta.json_source_write(${triggerArg(
|
|
264
|
-
name: x.name, schema: x.schema, table: x.table, key: x.key, column: x.column, rows: !!x.rows,
|
|
265
|
-
columns: x.columns.map((c) => ({ name: c.name, path: c.path, ...(c.readonly ? { readonly: true } : {}) })),
|
|
266
|
-
})})`);
|
|
275
|
+
out.push(`create trigger json_source_write\n instead of insert or update or delete on ${view}\n for each row execute function meta.json_source_write(${triggerArg(viewTriggerArg(x))})`);
|
|
267
276
|
out.push(...grantsSql(view, o.grants));
|
|
268
277
|
for (const { a, view: av } of arrays) {
|
|
269
278
|
const arr = `${doc} #> ${pathSql(a.path)}`;
|
|
270
279
|
const ecols = a.columns.map((c) => `${columnSql('e.v', c)} as ${ident(c.name)}`);
|
|
271
280
|
out.push(`create view ${av} with (security_invoker = true) as\nselect ${[keySel, `k.key as ${ROW_KEY}`, `k.idx + 1 as ${POSITION}`, ...ecols].join(',\n ')}\n from ${table} t\n cross join lateral meta.jsonb_element_keys(${arr}) k\n cross join lateral (select (${arr}) -> k.idx as v) e`);
|
|
272
281
|
out.push(`comment on view ${av} is ${lit(`JSON source ${x.name} of application ${o.app}: the array ${a.path.join('.')} of ${x.schema}.${x.table}.${x.column}, one row per element (made by pgkiln)`)}`);
|
|
273
|
-
out.push(`create trigger json_source_write\n instead of insert or update or delete on ${av}\n for each row execute function meta.json_array_write(${triggerArg(
|
|
274
|
-
name: x.name, schema: x.schema, table: x.table, key: x.key, column: x.column, array: a.path, row_key: ROW_KEY, position: POSITION,
|
|
275
|
-
columns: a.columns.map((c) => ({ name: c.name, path: c.path, ...(c.readonly ? { readonly: true } : {}) })),
|
|
276
|
-
})})`);
|
|
282
|
+
out.push(`create trigger json_source_write\n instead of insert or update or delete on ${av}\n for each row execute function meta.json_array_write(${triggerArg(arrayTriggerArg(x, a))})`);
|
|
277
283
|
out.push(...grantsSql(av, o.grants));
|
|
278
284
|
}
|
|
279
285
|
return out;
|