diff --git a/.github/workflows/check-canary-treeshake.yml b/.github/workflows/check-canary-treeshake.yml new file mode 100644 index 00000000000..9c625a6daaa --- /dev/null +++ b/.github/workflows/check-canary-treeshake.yml @@ -0,0 +1,66 @@ +name: Verify canary tree-shake + +# Builds a non-canary production bundle (CANARY_BUILD unset) and +# greps the resulting app.bundle.js for canary-only symbols. If any +# appear, the build-time DefinePlugin gate failed and the canary +# (~7 KiB) is shipping to end users along with the 2x walk cost on +# every keystroke. The script exits non-zero on any leak. +# +# Lightweight: no PostgreSQL service, no pgAdmin runtime, no Python. +# Just node + webpack + grep. Runs on every PR + master push. + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + + workflow_dispatch: + +concurrency: + group: '${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}' + cancel-in-progress: true + +jobs: + verify-canary-treeshake: + runs-on: ubuntu-22.04 + + steps: + - uses: actions/setup-node@v4 + with: + node-version: '20.x' + + - uses: actions/checkout@v4 + + - name: Upgrade yarn + run: | + yarn set version berry + yarn set version 4 + + - name: Install Node modules + run: | + cd web + yarn install + + - name: Build NON-canary production bundle + run: | + cd web + # CANARY_BUILD intentionally NOT set — DefinePlugin should + # substitute process.env.__CANARY_BUILD__ → literal false + # and webpack should DCE the canary path + tree-shake the + # canary module's import. + NODE_ENV=production NODE_OPTIONS=--max-old-space-size=4096 \ + ./node_modules/.bin/webpack --config webpack.config.js + + - name: Verify canary is tree-shaken + run: | + cd web + ./scripts/verify-canary-treeshake.sh + + - name: Archive bundle on failure (for triage) + if: failure() + uses: actions/upload-artifact@v4 + with: + name: leaked-canary-bundle + path: web/pgadmin/static/js/generated/app.bundle.js + if-no-files-found: ignore diff --git a/.github/workflows/run-schemaview-ui-smoke.yml b/.github/workflows/run-schemaview-ui-smoke.yml new file mode 100644 index 00000000000..de33835f530 --- /dev/null +++ b/.github/workflows/run-schemaview-ui-smoke.yml @@ -0,0 +1,315 @@ +name: SchemaView UI smoke (Playwright) + +# Runs the audit-smoke.spec.js Playwright tests against a real +# pgAdmin instance with __INCREMENTAL_AUDIT__ + __throw_on_canary +# _divergence__ enabled. Asserts no canary divergence across 5 +# dialog flows (3 create + 2 edit). +# +# Synthetic Jest tests cover 87 schemas × 3 modes via the harness, +# but only a real browser exercises the production DepListener +# wiring + fixedRows async resolution + parallel-promise React +# batching that triggered three of this branch's bug fixes. This +# is the last line of defense before flipping the walker on. + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + + workflow_dispatch: + +concurrency: + group: '${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}' + cancel-in-progress: true + +jobs: + schemaview-ui-smoke: + runs-on: ubuntu-22.04 + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20.x' + + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + + # PostgreSQL setup — mirrors run-feature-tests-pg.yml. + - name: Setup the PGDG APT repo + run: | + sudo sh -c 'echo "deb https://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list' + wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add - + + # ubuntu-22.04 runners come with an older PostgreSQL pre- + # installed. Dropping its cluster + removing the package + # before installing PG 16 ensures /etc/postgresql/16/main/ + # is created cleanly by the new install (without this step, + # the pg_hba.conf path doesn't exist and the next step fails). + - name: Uninstall any pre-installed PostgreSQL + run: | + if [ -n "$(ls /etc/postgresql/*/*/postgresql.conf 2>/dev/null)" ]; then + installed_pg_version=$( pg_config --version | cut -d ' ' -f 2 | cut -d '.' -f 1 ) + echo "Found pre-installed PostgreSQL $installed_pg_version; removing" + if [ "$installed_pg_version" != "16" ]; then + sudo pg_dropcluster $installed_pg_version main --stop || true + sudo apt-get -y remove "postgresql-${installed_pg_version}" || true + fi + fi + + - name: Install PostgreSQL 16 + run: | + sudo apt update + sudo apt install -y libpq-dev libffi-dev libssl-dev libkrb5-dev zlib1g-dev postgresql-16 + + - name: Start PostgreSQL on port 5916 + run: | + sudo su -c "echo 'local all all trust' > /etc/postgresql/16/main/pg_hba.conf" + sudo su -c "echo 'host all all 127.0.0.1/32 trust' >> /etc/postgresql/16/main/pg_hba.conf" + sudo sed -i "s/port = 543[0-9]/port = 5916/g" /etc/postgresql/16/main/postgresql.conf + sudo su - postgres -c "/usr/lib/postgresql/16/bin/postgres -D /var/lib/postgresql/16/main -c config_file=/etc/postgresql/16/main/postgresql.conf &" + until sudo runuser -l postgres -c "pg_isready -p 5916" 2>/dev/null; do + >&2 echo "Postgres unavailable - sleeping 2s" + sleep 2 + done + + # pgAdmin Python deps. + - name: Install Python dependencies + run: | + python -m venv venv + . venv/bin/activate + pip install --upgrade pip + pip install -r requirements.txt + + # config_local.py: desktop mode, no master password, no OS + # secret storage — so the smoke can drive the + # "Connect to Server" password prompt without ever hitting + # an Unlock Saved Passwords modal. + - name: Create pgAdmin config + test paths + run: | + mkdir -p var + cat <<'EOF' > web/config_local.py + from config import * + + DEBUG = True + SERVER_MODE = False + MASTER_PASSWORD_REQUIRED = False + USE_OS_SECRET_STORAGE = False + UPGRADE_CHECK_ENABLED = False + CONSOLE_LOG_LEVEL = DEBUG + FILE_LOG_LEVEL = DEBUG + DEFAULT_SERVER = '127.0.0.1' + + import os + ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + LOG_FILE = ROOT + '/var/pgadmin4.log' + SESSION_DB_PATH = ROOT + '/var/sessions' + STORAGE_DIR = ROOT + '/var/storage' + SQLITE_PATH = ROOT + '/var/pgadmin4.db' + AZURE_CREDENTIAL_CACHE_DIR = ROOT + '/var/azurecredentialcache' + EOF + + # CI's fresh PG 16 database is empty — no tables, no functions + # to "Edit Properties" on. The Edit-mode smoke specs need at + # least one child of each type under the default `postgres` + # database. Pre-create them so the smoke has real edit-mode + # targets. + # + # Covers all 15 Edit-mode specs in audit-smoke-extended.spec.js + # plus the 2 in audit-smoke.spec.js (Edit Table, Edit Function). + # Edit Role / Edit Tablespace / Edit Type pass without seed + # because every PG install ships with at least one of each. + - name: Seed schema objects for edit-mode smoke + run: | + PGPASSWORD= psql -h 127.0.0.1 -p 5916 -U postgres -d postgres -c " + -- Original 2 (audit-smoke.spec.js Edit Table / Function) + CREATE TABLE IF NOT EXISTS audit_smoke_table ( + id integer PRIMARY KEY, + name text NOT NULL + ); + CREATE OR REPLACE FUNCTION audit_smoke_func() RETURNS integer AS \$\$ + SELECT 1; + \$\$ LANGUAGE sql IMMUTABLE; + + -- Extended Edit-mode prerequisites (12 new): + + CREATE OR REPLACE VIEW audit_smoke_view AS + SELECT id, name FROM audit_smoke_table; + + CREATE MATERIALIZED VIEW IF NOT EXISTS audit_smoke_mview AS + SELECT id, name FROM audit_smoke_table; + + CREATE SEQUENCE IF NOT EXISTS audit_smoke_seq; + + DO \$\$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'audit_smoke_type') THEN + CREATE TYPE audit_smoke_type AS (a integer, b text); + END IF; + END \$\$; + + DO \$\$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'audit_smoke_domain') THEN + CREATE DOMAIN audit_smoke_domain AS integer CHECK (VALUE >= 0); + END IF; + END \$\$; + + CREATE OR REPLACE PROCEDURE audit_smoke_proc() LANGUAGE plpgsql + AS \$\$ BEGIN NULL; END; \$\$; + + DO \$\$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_proc WHERE proname = 'audit_smoke_agg' + ) THEN + CREATE AGGREGATE audit_smoke_agg (integer) ( + SFUNC = int4pl, STYPE = integer, INITCOND = '0' + ); + END IF; + END \$\$; + + CREATE EXTENSION IF NOT EXISTS postgres_fdw; + DO \$\$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_foreign_server WHERE srvname = 'audit_smoke_fsrv' + ) THEN + CREATE SERVER audit_smoke_fsrv + FOREIGN DATA WRAPPER postgres_fdw + OPTIONS (host '127.0.0.1', dbname 'postgres'); + END IF; + END \$\$; + CREATE FOREIGN TABLE IF NOT EXISTS audit_smoke_ftable ( + id integer, name text + ) SERVER audit_smoke_fsrv OPTIONS (table_name 'audit_smoke_table'); + + DO \$\$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_collation WHERE collname = 'audit_smoke_coll') THEN + CREATE COLLATION audit_smoke_coll (locale = 'C'); + END IF; + END \$\$; + + DO \$\$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_ts_config WHERE cfgname = 'audit_smoke_fts') THEN + CREATE TEXT SEARCH CONFIGURATION audit_smoke_fts (COPY = english); + END IF; + END \$\$; + + CREATE OR REPLACE FUNCTION audit_smoke_tgfn() RETURNS trigger + LANGUAGE plpgsql AS \$\$ BEGIN RETURN NEW; END; \$\$; + + DROP TRIGGER IF EXISTS audit_smoke_trig ON audit_smoke_table; + CREATE TRIGGER audit_smoke_trig BEFORE INSERT ON audit_smoke_table + FOR EACH ROW EXECUTE FUNCTION audit_smoke_tgfn(); + + CREATE INDEX IF NOT EXISTS audit_smoke_idx + ON audit_smoke_table (name); + " + + # Initialise pgAdmin's config SQLite. load-servers below needs + # the DB to already exist — it doesn't auto-create. (Locally + # verified: load-servers on a missing SQLite path errors with + # "SQLite database file does not exist." before populating.) + - name: Initialise pgAdmin config DB + run: | + . venv/bin/activate + cd web && python setup.py setup-db + + # Pre-register a server in pgAdmin's SQLite via setup.py + # load-servers. The smoke spec's ensureServerRegistered helper + # looks for an existing server in the tree; without this it + # falls back to whatever's there (nothing in fresh CI). + - name: Seed test server in pgAdmin SQLite + run: | + cat <<'EOF' > /tmp/servers.json + { + "Servers": { + "1": { + "Name": "CI-PG16", + "Group": "Servers", + "Port": 5916, + "Username": "postgres", + "Host": "127.0.0.1", + "MaintenanceDB": "postgres", + "SSLMode": "prefer" + } + } + } + EOF + . venv/bin/activate + cd web && python setup.py load-servers /tmp/servers.json + + # Yarn + Node modules. + - name: Upgrade yarn + run: | + yarn set version berry + yarn set version 4 + + - name: Install Node modules + run: | + cd web + yarn install + + # CANARY build — keeps the canary IN the bundle for the audit. + - name: Build canary bundle + run: | + cd web + CANARY_BUILD=true NODE_ENV=production \ + NODE_OPTIONS=--max-old-space-size=4096 \ + ./node_modules/.bin/webpack --config webpack.config.js + + # Start pgAdmin in the background. + - name: Start pgAdmin + run: | + . venv/bin/activate + cd web + nohup python pgAdmin4.py > ../var/pgadmin-stdout.log 2>&1 & + # Wait for /browser/ to respond. + for i in $(seq 1 30); do + if curl -fsS -o /dev/null http://127.0.0.1:5050/browser/; then + echo "pgAdmin up" + break + fi + sleep 2 + done + curl -fsS -o /dev/null http://127.0.0.1:5050/browser/ + + # Playwright (browser install + smoke run). + - name: Install Playwright deps + run: | + cd web/regression/perf-bench + yarn install + ./node_modules/.bin/playwright install --with-deps chromium + + - name: Run audit-smoke + env: + PGADMIN_URL: http://127.0.0.1:5050/browser/ + PGHOST: 127.0.0.1 + PGPORT: '5916' + PGUSER: postgres + PGPASSWORD: '' + PGDATABASE: postgres + PGADMIN_SERVER_NAME: CI-PG16 + run: | + cd web/regression/perf-bench + ./node_modules/.bin/playwright test audit-smoke --reporter=line + + - name: Archive pgAdmin server log + if: success() || failure() + uses: actions/upload-artifact@v4 + with: + name: pgadmin-smoke-log + path: | + var/pgadmin4.log + var/pgadmin-stdout.log + if-no-files-found: ignore + + - name: Archive Playwright trace + screenshots on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-smoke-failure + path: | + web/regression/perf-bench/playwright-report + web/regression/perf-bench/test-results + if-no-files-found: ignore diff --git a/web/.eslintrc.js b/web/.eslintrc.js index 3dade343b64..e36a7821027 100644 --- a/web/.eslintrc.js +++ b/web/.eslintrc.js @@ -14,6 +14,7 @@ const babel = require('@babel/eslint-plugin'); const babelParser = require('@babel/eslint-parser'); const ts = require('typescript-eslint'); const unusedImports = require('eslint-plugin-unused-imports'); +const pgadminLocal = require('./eslint-plugins/local-rules'); module.exports = [ @@ -59,12 +60,24 @@ module.exports = [ 'global': 'readonly', 'jest': 'readonly', 'process': 'readonly', + // `pgAdmin` is provided to the browser bundle via the + // ProvidePlugin in webpack.config.js. Lint sees it as + // undefined; declare it so source code (e.g. + // bench-fixture.js) doesn't need per-line disables. + 'pgAdmin': 'readonly', + // `expect` is a Jest global used at module scope in + // setup-jest.js (outside any describe/it block). The + // eslint-plugin-jest config supplies it inside test + // blocks; module-scope usage in the harness needs an + // explicit declaration. + 'expect': 'readonly', }, }, 'plugins': { 'react': reactjs, '@babel': babel, 'unused-imports': unusedImports, + 'pgadmin-local': pgadminLocal, }, 'rules': { 'indent': [ @@ -106,7 +119,8 @@ module.exports = [ 'args': 'after-used', 'argsIgnorePattern': '^_', }, - ] + ], + 'pgadmin-local/register-schema': 'error', }, 'settings': { 'react': { diff --git a/web/eslint-plugins/local-rules/index.js b/web/eslint-plugins/local-rules/index.js new file mode 100644 index 00000000000..7b1e69f577f --- /dev/null +++ b/web/eslint-plugins/local-rules/index.js @@ -0,0 +1,25 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2026, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +// Local ESLint plugin — rules specific to pgAdmin that don't belong +// in a shared package. Wire from .eslintrc.js as: +// +// const localRules = require('./eslint-plugins/local-rules'); +// module.exports = [{ +// plugins: { 'pgadmin-local': localRules }, +// rules: { 'pgadmin-local/register-schema': 'error' }, +// }]; + +'use strict'; + +module.exports = { + rules: { + 'register-schema': require('./rules/register-schema'), + }, +}; diff --git a/web/eslint-plugins/local-rules/rules/register-schema.js b/web/eslint-plugins/local-rules/rules/register-schema.js new file mode 100644 index 00000000000..5ec29548056 --- /dev/null +++ b/web/eslint-plugins/local-rules/rules/register-schema.js @@ -0,0 +1,146 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2026, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +// ESLint rule: every default-exported BaseUISchema subclass must be +// wrapped in `registerSchema()`. Enforces design D10 — the audit +// harness enumerates schemas via the registry; an unregistered +// schema is silently skipped, defeating the canary's coverage. +// +// What the rule flags: +// - `export default class Foo extends BaseUISchema {}` — direct +// class declaration, no wrap possible without modification +// - `export default Foo;` where Foo is a class extending +// BaseUISchema declared in the same file +// - `export default decorate(Foo);` — wrapping in any function +// other than `registerSchema` hides the schema from the registry +// - `export default decorate(class Foo extends BaseUISchema {});` +// +// What's intentionally NOT flagged: +// - inner schemas not default-exported (they're not enumerated) +// - re-exports (`export { default } from './foo'`) — the rule fires +// in the source file +// - classes extending something other than `BaseUISchema` directly +// (rare in the codebase; cross-file inheritance chains would need +// a type-aware lint that ESLint can't provide without TS) + +'use strict'; + +const REGISTER_FN = 'registerSchema'; +const BASE_NAME = 'BaseUISchema'; + +const isClassNode = (node) => + node && (node.type === 'ClassDeclaration' || node.type === 'ClassExpression'); + +// Direct check: superclass is the literal identifier `BaseUISchema`. +// Doesn't follow imports — schemas in this codebase always import +// BaseUISchema by name, so a name match is sufficient. Edge cases +// (renamed imports) are vanishingly rare and would be caught by the +// audit harness reporting a missing schema entry. +const extendsBaseUISchema = (node) => { + if (!isClassNode(node)) return false; + const sup = node.superClass; + return sup && sup.type === 'Identifier' && sup.name === BASE_NAME; +}; + +const findClassByName = (scope, name) => { + let cursor = scope; + while (cursor) { + const v = cursor.set.get(name); + if (v) { + for (const def of v.defs) { + if (isClassNode(def.node)) return def.node; + } + } + cursor = cursor.upper; + } + return null; +}; + +// Resolves the "ultimate exported class" from the default-export RHS. +// Returns the class node if found, plus a flag indicating whether the +// path from the export down to the class was through a registerSchema +// call (any intermediate non-registerSchema function makes it false). +const resolveExportedClass = (decl, scope) => { + // export default class Foo extends BaseUISchema {} + if (isClassNode(decl)) { + return { classNode: decl, wrapped: false }; + } + + // export default Identifier + if (decl.type === 'Identifier') { + const classNode = findClassByName(scope, decl.name); + return { classNode, wrapped: false }; + } + + // export default someFn(...) + if (decl.type === 'CallExpression') { + const isRegister = decl.callee.type === 'Identifier' + && decl.callee.name === REGISTER_FN; + const arg = decl.arguments[0]; + if (!arg) return { classNode: null, wrapped: false }; + + if (isClassNode(arg)) { + return { classNode: arg, wrapped: isRegister }; + } + if (arg.type === 'Identifier') { + const classNode = findClassByName(scope, arg.name); + return { classNode, wrapped: isRegister }; + } + // Nested calls (e.g. registerSchema(decorate(Foo))) — treat as + // unwrapped: the registry receives a wrapped value, not the raw + // class. Future enhancement could whitelist specific decorators. + if (arg.type === 'CallExpression') { + const inner = resolveExportedClass(arg, scope); + // Even if outer is registerSchema, the inner call hides the + // class identity from the registry — flag it. + return { classNode: inner.classNode, wrapped: false }; + } + } + + return { classNode: null, wrapped: false }; +}; + +module.exports = { + meta: { + type: 'problem', + docs: { + description: + 'Default-exported BaseUISchema subclasses must be wrapped in ' + + 'registerSchema() so the audit harness can enumerate them.', + }, + schema: [], + messages: { + missingWrap: + 'Default-exported BaseUISchema subclass \'{{name}}\' must be ' + + 'wrapped in registerSchema() — required by design D10 so the ' + + 'audit harness can enumerate it. Use: ' + + 'export default registerSchema({{name}});', + }, + }, + + create(context) { + return { + ExportDefaultDeclaration(node) { + const scope = context.sourceCode.getScope(node); + const { classNode, wrapped } = resolveExportedClass( + node.declaration, scope + ); + + if (!classNode || !extendsBaseUISchema(classNode)) return; + if (wrapped) return; + + context.report({ + node, + messageId: 'missingWrap', + data: { name: classNode.id ? classNode.id.name : '' }, + }); + }, + }; + }, +}; diff --git a/web/jest.config.js b/web/jest.config.js index 0b4ffb646ae..e56c34398a2 100644 --- a/web/jest.config.js +++ b/web/jest.config.js @@ -4,6 +4,15 @@ const webpackAliasToJestModules = ()=>{ const ret = { '\\.svg\\?svgr$': '/regression/javascript/__mocks__/svg.js', 'react-dom/server': 'react-dom/server.edge', + // react-data-grid + react-dnd ship ESM-only; babel-jest can't + // transform them in place. Route to local mocks so the schema-ui + // files that import them at module load time can be audited under + // Jest (instead of being SKIP'd as import failures). + '^react-data-grid$': '/regression/javascript/__mocks__/react-data-grid.jsx', + '^react-dnd$': '/regression/javascript/__mocks__/react-dnd.jsx', + '^react-dnd-html5-backend$': '/regression/javascript/__mocks__/react-dnd-html5-backend.js', + '^react-resize-detector$': '/regression/javascript/__mocks__/react-resize-detector.jsx', + '^marked$': '/regression/javascript/__mocks__/marked.js', }; Object.keys(webpackShimAlias).forEach((an)=>{ // eg - sources: ./pgadmin/static/js/ to '^sources/(.*)$': '/pgadmin/static/js/$1' diff --git a/web/package.json b/web/package.json index af7b0cf8852..69f2d2942e8 100644 --- a/web/package.json +++ b/web/package.json @@ -41,6 +41,7 @@ "eslint-plugin-react": "^7.37.5", "eslint-plugin-unused-imports": "^4.4.1", "exports-loader": "^5.0.0", + "fast-check": "^4.8.0", "globals": "^17.5.0", "html-react-parser": "^5.2.17", "image-minimizer-webpack-plugin": "^4.1.4", diff --git a/web/pgadmin/browser/server_groups/servers/databases/casts/static/js/cast.ui.js b/web/pgadmin/browser/server_groups/servers/databases/casts/static/js/cast.ui.js index 071032f5975..3db144a6366 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/casts/static/js/cast.ui.js +++ b/web/pgadmin/browser/server_groups/servers/databases/casts/static/js/cast.ui.js @@ -8,8 +8,9 @@ ////////////////////////////////////////////////////////////// import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; import { isEmptyString } from 'sources/validators'; -export default class CastSchema extends BaseUISchema { +class CastSchema extends BaseUISchema { constructor(fieldOptions={}, initValues={}) { super({ name: undefined, // Name of the cast @@ -187,4 +188,6 @@ export default class CastSchema extends BaseUISchema { return false; } } +export default registerSchema(CastSchema); + diff --git a/web/pgadmin/browser/server_groups/servers/databases/dbms_job_scheduler/dbms_jobs/static/js/dbms_job.ui.js b/web/pgadmin/browser/server_groups/servers/databases/dbms_job_scheduler/dbms_jobs/static/js/dbms_job.ui.js index 72b882fe610..3a19a70daa1 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/dbms_job_scheduler/dbms_jobs/static/js/dbms_job.ui.js +++ b/web/pgadmin/browser/server_groups/servers/databases/dbms_job_scheduler/dbms_jobs/static/js/dbms_job.ui.js @@ -9,12 +9,13 @@ import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; import { isEmptyString } from 'sources/validators'; import moment from 'moment'; import { getActionSchema, getRepeatSchema } from '../../../static/js/dbms_job_scheduler_common.ui'; -export default class DBMSJobSchema extends BaseUISchema { +class DBMSJobSchema extends BaseUISchema { constructor(fieldOptions={}) { super({ jsjobid: null, @@ -196,3 +197,5 @@ export default class DBMSJobSchema extends BaseUISchema { } } } +export default registerSchema(DBMSJobSchema); + diff --git a/web/pgadmin/browser/server_groups/servers/databases/dbms_job_scheduler/dbms_programs/static/js/dbms_program.ui.js b/web/pgadmin/browser/server_groups/servers/databases/dbms_job_scheduler/dbms_programs/static/js/dbms_program.ui.js index 66d14b23674..525dfefc7de 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/dbms_job_scheduler/dbms_programs/static/js/dbms_program.ui.js +++ b/web/pgadmin/browser/server_groups/servers/databases/dbms_job_scheduler/dbms_programs/static/js/dbms_program.ui.js @@ -9,10 +9,11 @@ import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; import { isEmptyString } from 'sources/validators'; import { getActionSchema } from '../../../static/js/dbms_job_scheduler_common.ui'; -export default class DBMSProgramSchema extends BaseUISchema { +class DBMSProgramSchema extends BaseUISchema { constructor(fieldOptions={}) { super({ jsprid: null, @@ -74,3 +75,5 @@ export default class DBMSProgramSchema extends BaseUISchema { } } } +export default registerSchema(DBMSProgramSchema); + diff --git a/web/pgadmin/browser/server_groups/servers/databases/dbms_job_scheduler/dbms_schedules/static/js/dbms_schedule.ui.js b/web/pgadmin/browser/server_groups/servers/databases/dbms_job_scheduler/dbms_schedules/static/js/dbms_schedule.ui.js index a12bd0c8629..eb204decbe1 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/dbms_job_scheduler/dbms_schedules/static/js/dbms_schedule.ui.js +++ b/web/pgadmin/browser/server_groups/servers/databases/dbms_job_scheduler/dbms_schedules/static/js/dbms_schedule.ui.js @@ -9,11 +9,12 @@ import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; import { isEmptyString } from 'sources/validators'; import moment from 'moment'; import { getRepeatSchema } from '../../../static/js/dbms_job_scheduler_common.ui'; -export default class DBMSScheduleSchema extends BaseUISchema { +class DBMSScheduleSchema extends BaseUISchema { constructor() { super({ jsscid: null, @@ -87,3 +88,5 @@ export default class DBMSScheduleSchema extends BaseUISchema { } } } +export default registerSchema(DBMSScheduleSchema); + diff --git a/web/pgadmin/browser/server_groups/servers/databases/dbms_job_scheduler/static/js/dbms_jobscheduler.ui.js b/web/pgadmin/browser/server_groups/servers/databases/dbms_job_scheduler/static/js/dbms_jobscheduler.ui.js index 1a38a355b37..4d093b9fbe8 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/dbms_job_scheduler/static/js/dbms_jobscheduler.ui.js +++ b/web/pgadmin/browser/server_groups/servers/databases/dbms_job_scheduler/static/js/dbms_jobscheduler.ui.js @@ -9,8 +9,9 @@ import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; -export default class DBMSJobSchedulerSchema extends BaseUISchema { +class DBMSJobSchedulerSchema extends BaseUISchema { constructor() { super({ jobid: null, @@ -43,3 +44,5 @@ export default class DBMSJobSchedulerSchema extends BaseUISchema { }]; } } +export default registerSchema(DBMSJobSchedulerSchema); + diff --git a/web/pgadmin/browser/server_groups/servers/databases/event_triggers/static/js/event_trigger.ui.js b/web/pgadmin/browser/server_groups/servers/databases/event_triggers/static/js/event_trigger.ui.js index 5b2bc95dabb..9cc59f96388 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/event_triggers/static/js/event_trigger.ui.js +++ b/web/pgadmin/browser/server_groups/servers/databases/event_triggers/static/js/event_trigger.ui.js @@ -9,11 +9,12 @@ import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; import SecLabelSchema from '../../../../static/js/sec_label.ui'; import { isEmptyString } from 'sources/validators'; -export default class EventTriggerSchema extends BaseUISchema { +class EventTriggerSchema extends BaseUISchema { constructor(fieldOptions={}, initValues={}) { super({ oid: undefined, @@ -128,3 +129,5 @@ export default class EventTriggerSchema extends BaseUISchema { } } } +export default registerSchema(EventTriggerSchema); + diff --git a/web/pgadmin/browser/server_groups/servers/databases/extensions/static/js/extension.ui.js b/web/pgadmin/browser/server_groups/servers/databases/extensions/static/js/extension.ui.js index d2f4a1b1b0b..1aff2b82ce9 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/extensions/static/js/extension.ui.js +++ b/web/pgadmin/browser/server_groups/servers/databases/extensions/static/js/extension.ui.js @@ -9,9 +9,10 @@ import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; import { isEmptyString } from 'sources/validators'; -export default class ExtensionsSchema extends BaseUISchema { +class ExtensionsSchema extends BaseUISchema { constructor(fieldOptions = {}) { super({ name: null, @@ -181,3 +182,5 @@ export default class ExtensionsSchema extends BaseUISchema { } } +export default registerSchema(ExtensionsSchema); + diff --git a/web/pgadmin/browser/server_groups/servers/databases/foreign_data_wrappers/foreign_servers/static/js/foreign_server.ui.js b/web/pgadmin/browser/server_groups/servers/databases/foreign_data_wrappers/foreign_servers/static/js/foreign_server.ui.js index 1acca7e51c9..9a8c95a7391 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/foreign_data_wrappers/foreign_servers/static/js/foreign_server.ui.js +++ b/web/pgadmin/browser/server_groups/servers/databases/foreign_data_wrappers/foreign_servers/static/js/foreign_server.ui.js @@ -9,9 +9,10 @@ import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; import OptionsSchema from '../../../../../static/js/options.ui'; -export default class ForeignServerSchema extends BaseUISchema { +class ForeignServerSchema extends BaseUISchema { constructor(getPrivilegeRoleSchema, fieldOptions={}, initValues={}) { super({ name: undefined, @@ -84,3 +85,5 @@ export default class ForeignServerSchema extends BaseUISchema { ]; } } +export default registerSchema(ForeignServerSchema); + diff --git a/web/pgadmin/browser/server_groups/servers/databases/foreign_data_wrappers/foreign_servers/user_mappings/static/js/user_mapping.ui.js b/web/pgadmin/browser/server_groups/servers/databases/foreign_data_wrappers/foreign_servers/user_mappings/static/js/user_mapping.ui.js index ad17e05c1c5..eee80b0ca95 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/foreign_data_wrappers/foreign_servers/user_mappings/static/js/user_mapping.ui.js +++ b/web/pgadmin/browser/server_groups/servers/databases/foreign_data_wrappers/foreign_servers/user_mappings/static/js/user_mapping.ui.js @@ -9,9 +9,10 @@ import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; import OptionsSchema from '../../../../../../static/js/options.ui'; -export default class UserMappingSchema extends BaseUISchema { +class UserMappingSchema extends BaseUISchema { constructor(fieldOptions={}, initValues={}) { super({ name: undefined, @@ -54,3 +55,5 @@ export default class UserMappingSchema extends BaseUISchema { ]; } } +export default registerSchema(UserMappingSchema); + diff --git a/web/pgadmin/browser/server_groups/servers/databases/foreign_data_wrappers/static/js/foreign_data_wrapper.ui.js b/web/pgadmin/browser/server_groups/servers/databases/foreign_data_wrappers/static/js/foreign_data_wrapper.ui.js index 9212ca26a80..8c589d24187 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/foreign_data_wrappers/static/js/foreign_data_wrapper.ui.js +++ b/web/pgadmin/browser/server_groups/servers/databases/foreign_data_wrappers/static/js/foreign_data_wrapper.ui.js @@ -9,9 +9,10 @@ import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; import OptionsSchema from '../../../../static/js/options.ui'; -export default class ForeignDataWrapperSchema extends BaseUISchema { +class ForeignDataWrapperSchema extends BaseUISchema { constructor(getPrivilegeRoleSchema, fieldOptions={}, initValues={}) { super({ name: undefined, @@ -89,3 +90,5 @@ export default class ForeignDataWrapperSchema extends BaseUISchema { ]; } } +export default registerSchema(ForeignDataWrapperSchema); + diff --git a/web/pgadmin/browser/server_groups/servers/databases/languages/static/js/language.ui.js b/web/pgadmin/browser/server_groups/servers/databases/languages/static/js/language.ui.js index 906a184344a..9d62a7f591c 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/languages/static/js/language.ui.js +++ b/web/pgadmin/browser/server_groups/servers/databases/languages/static/js/language.ui.js @@ -8,11 +8,12 @@ ////////////////////////////////////////////////////////////// import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; import { isEmptyString } from 'sources/validators'; import SecLabelSchema from '../../../../static/js/sec_label.ui'; import _ from 'lodash'; -export default class LanguageSchema extends BaseUISchema { +class LanguageSchema extends BaseUISchema { constructor(getPrivilegeRoleSchema, fieldOptions={}, node_info={}, initValues={}) { super({ name: undefined, @@ -230,4 +231,6 @@ export default class LanguageSchema extends BaseUISchema { return false; } } +export default registerSchema(LanguageSchema); + diff --git a/web/pgadmin/browser/server_groups/servers/databases/publications/static/js/publication.ui.js b/web/pgadmin/browser/server_groups/servers/databases/publications/static/js/publication.ui.js index 8f829956db4..5f149e07668 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/publications/static/js/publication.ui.js +++ b/web/pgadmin/browser/server_groups/servers/databases/publications/static/js/publication.ui.js @@ -8,6 +8,7 @@ ////////////////////////////////////////////////////////////// import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; import _ from 'lodash'; export class DefaultWithSchema extends BaseUISchema { @@ -156,7 +157,7 @@ export class PublicationTableSchema extends BaseUISchema { } } -export default class PublicationSchema extends BaseUISchema { +class PublicationSchema extends BaseUISchema { constructor(fieldOptions={}, node_info={}, initValues={}) { super({ name: undefined, @@ -309,3 +310,5 @@ export default class PublicationSchema extends BaseUISchema { ]; } } +export default registerSchema(PublicationSchema); + diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/aggregates/static/js/aggregate.ui.js b/web/pgadmin/browser/server_groups/servers/databases/schemas/aggregates/static/js/aggregate.ui.js index 8bacf53c9a6..377fc63ac97 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/aggregates/static/js/aggregate.ui.js +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/aggregates/static/js/aggregate.ui.js @@ -8,9 +8,10 @@ ////////////////////////////////////////////////////////////// import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; import gettext from 'sources/gettext'; -export default class AggregateSchema extends BaseUISchema { +class AggregateSchema extends BaseUISchema { constructor(fieldOptions = {},initValues={}) { super({ name: undefined, @@ -183,3 +184,5 @@ export default class AggregateSchema extends BaseUISchema { ]; } } +export default registerSchema(AggregateSchema); + diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/catalog_objects/columns/static/js/catalog_object_column.ui.js b/web/pgadmin/browser/server_groups/servers/databases/schemas/catalog_objects/columns/static/js/catalog_object_column.ui.js index 209410ad92b..230eaf6d1a9 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/catalog_objects/columns/static/js/catalog_object_column.ui.js +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/catalog_objects/columns/static/js/catalog_object_column.ui.js @@ -9,9 +9,10 @@ import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; -export default class CatalogObjectColumnSchema extends BaseUISchema { +class CatalogObjectColumnSchema extends BaseUISchema { constructor(fieldOptions={}, initValues={}) { super({ attname: undefined, @@ -64,3 +65,5 @@ export default class CatalogObjectColumnSchema extends BaseUISchema { ]; } } +export default registerSchema(CatalogObjectColumnSchema); + diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/catalog_objects/static/js/catalog_object.ui.js b/web/pgadmin/browser/server_groups/servers/databases/schemas/catalog_objects/static/js/catalog_object.ui.js index a32162956b3..88164609806 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/catalog_objects/static/js/catalog_object.ui.js +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/catalog_objects/static/js/catalog_object.ui.js @@ -9,8 +9,9 @@ import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; -export default class CatalogObjectSchema extends BaseUISchema { +class CatalogObjectSchema extends BaseUISchema { constructor() { super({ name: undefined, @@ -42,3 +43,5 @@ export default class CatalogObjectSchema extends BaseUISchema { ]; } } +export default registerSchema(CatalogObjectSchema); + diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/collations/static/js/collation.ui.js b/web/pgadmin/browser/server_groups/servers/databases/schemas/collations/static/js/collation.ui.js index ed10adad7f8..148bd366492 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/collations/static/js/collation.ui.js +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/collations/static/js/collation.ui.js @@ -8,10 +8,11 @@ ////////////////////////////////////////////////////////////// import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; import gettext from 'sources/gettext'; import { isEmptyString } from 'sources/validators'; -export default class CollationSchema extends BaseUISchema { +class CollationSchema extends BaseUISchema { constructor(fieldOptions = {}, initValues={}, nodeInfo={}) { super({ name: undefined, @@ -281,3 +282,5 @@ export default class CollationSchema extends BaseUISchema { } } +export default registerSchema(CollationSchema); + diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/domains/domain_constraints/static/js/domain_constraints.ui.js b/web/pgadmin/browser/server_groups/servers/databases/schemas/domains/domain_constraints/static/js/domain_constraints.ui.js index 82248a4ac2c..7f97e638b47 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/domains/domain_constraints/static/js/domain_constraints.ui.js +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/domains/domain_constraints/static/js/domain_constraints.ui.js @@ -9,8 +9,9 @@ import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; -export default class DomainConstraintSchema extends BaseUISchema { +class DomainConstraintSchema extends BaseUISchema { constructor(initValues) { super({ name: undefined, @@ -57,3 +58,5 @@ export default class DomainConstraintSchema extends BaseUISchema { ]; } } +export default registerSchema(DomainConstraintSchema); + diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/domains/static/js/domain.ui.js b/web/pgadmin/browser/server_groups/servers/databases/schemas/domains/static/js/domain.ui.js index 1584963c036..fb4c2d150c7 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/domains/static/js/domain.ui.js +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/domains/static/js/domain.ui.js @@ -9,6 +9,7 @@ import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; import SecLabelSchema from '../../../../../static/js/sec_label.ui'; import { isEmptyString } from 'sources/validators'; import _ from 'lodash'; @@ -38,6 +39,11 @@ export class DomainConstSchema extends BaseUISchema { }, { id: 'convalidated', label: gettext('Validate?'), cell: 'checkbox', type: 'checkbox', + // readonly reads obj.top.origData.constraints — declare the parent + // path so incremental option walks re-evaluate this row when the + // origData constraints collection changes (e.g. on initialise + // after save). + deps: [['constraints']], readonly: function(state) { let currCon = _.find( obj.top.origData.constraints, (con) => con.conoid == state.conoid @@ -65,7 +71,7 @@ export class DomainConstSchema extends BaseUISchema { } } -export default class DomainSchema extends BaseUISchema { +class DomainSchema extends BaseUISchema { constructor(fieldOptions={}, initValues={}) { super({ name: undefined, @@ -224,3 +230,5 @@ export default class DomainSchema extends BaseUISchema { ]; } } +export default registerSchema(DomainSchema); + diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/foreign_tables/static/js/foreign_table.ui.js b/web/pgadmin/browser/server_groups/servers/databases/schemas/foreign_tables/static/js/foreign_table.ui.js index 33b28df1ee5..d3f1d61902c 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/foreign_tables/static/js/foreign_table.ui.js +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/foreign_tables/static/js/foreign_table.ui.js @@ -10,6 +10,7 @@ import gettext from 'sources/gettext'; import SecLabelSchema from '../../../../../static/js/sec_label.ui'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; import OptionsSchema from '../../../../../static/js/options.ui'; import { isEmptyString } from 'sources/validators'; import VariableSchema from 'top/browser/server_groups/servers/static/js/variable.ui'; @@ -20,7 +21,7 @@ import { getNodeAjaxOptions } from '../../../../../../../static/js/node_ajax'; import { getPrivilegesForTableAndLikeObjects } from '../../../tables/static/js/table.ui'; -export default class ForeignTableSchema extends BaseUISchema { +class ForeignTableSchema extends BaseUISchema { constructor(getPrivilegeRoleSchema, getVariableSchema, getColumns, fieldOptions={}, initValues={}) { super({ name: undefined, @@ -129,60 +130,47 @@ export default class ForeignTableSchema extends BaseUISchema { options: obj.fieldOptions.tables, optionsLoaded: (res)=>obj.inheritedTableList=res, deferredDepChange: (state, source, topState, actionObj)=>{ - return new Promise((resolve)=>{ - // current table list and previous table list - let newColInherits = state.inherits || []; - let oldColInherits = actionObj.oldState.inherits || []; - - let tabName; - let tabColsResponse; - - // Add columns logic - // If new table is added in list - if(newColInherits.length > 1 && newColInherits.length > oldColInherits.length) { - // Find newly added table from current list - tabName = _.difference(newColInherits, oldColInherits); - tabColsResponse = obj.getColumns({attrelid: this.getTableOid(tabName[0])}); - } else if (newColInherits.length == 1) { - // First table added - tabColsResponse = obj.getColumns({attrelid: this.getTableOid(newColInherits[0])}); - } - - if(tabColsResponse) { - tabColsResponse.then((res)=>{ - resolve((tmpstate)=>{ - let finalCols = res.map((col)=>obj.columnsObj.getNewData(col)); - finalCols = [...tmpstate.columns, ...finalCols]; - return { - adding_inherit_cols: false, - columns: finalCols, - }; - }); - }); - } + const newColInherits = state.inherits || []; + const oldColInherits = actionObj.oldState.inherits || []; + const added = _.difference(newColInherits, oldColInherits); + const removed = _.difference(oldColInherits, newColInherits); + + // REMOVE takes precedence: any removed parent means stale + // columns to clean up. Covers pure shrink AND same-length + // swap (e.g. multi-select replace) — without this branch a + // swap would leave the removed parent's columns sitting in + // the grid until the user did something else. + if(removed.length > 0) { + const removeOid = this.getTableOid(removed[0]); + // Guard: if inheritedTableList is stale and the removed + // table can't be resolved to an OID, opt out. Filtering on + // `inheritedid != undefined` would silently drop local + // user-added columns (`null == undefined` in JS). + if(removeOid == null) return undefined; + return Promise.resolve((tmpstate)=>({ + adding_inherit_cols: false, + columns: (tmpstate.columns || []) + .filter((col)=>col.inheritedid != removeOid), + })); + } - // Remove columns logic - let removeOid; - if(newColInherits.length > 0 && newColInherits.length < oldColInherits.length) { - // Find deleted table from previous list - tabName = _.difference(oldColInherits, newColInherits); - removeOid = this.getTableOid(tabName[0]); - } else if (oldColInherits.length === 1 && newColInherits.length < 1) { - // We got last table from list - tabName = oldColInherits[0]; - removeOid = this.getTableOid(tabName); - } - if(removeOid) { - resolve((tmpstate)=>{ - let finalCols = tmpstate.columns; - _.remove(tmpstate.columns, (col)=>col.inheritedid==removeOid); + // Pure ADD: list grew without any removals. + if(added.length > 0) { + const fetchOid = this.getTableOid(added[0]); + if(fetchOid == null) return undefined; + return obj.getColumns({attrelid: fetchOid}).then((res)=>( + (tmpstate)=>{ + const fetched = res.map((col)=>obj.columnsObj.getNewData(col)); return { adding_inherit_cols: false, - columns: finalCols + columns: [...(tmpstate.columns || []), ...fetched], }; - }); - } - }); + } + )); + } + + // Lists are equivalent — no work to do. + return undefined; }, }, { @@ -261,6 +249,8 @@ export default class ForeignTableSchema extends BaseUISchema { } } } +export default registerSchema(ForeignTableSchema); + export function getNodeColumnSchema(treeNodeInfo, itemNodeData, pgBrowser) { diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/fts_configurations/static/js/fts_configuration.ui.js b/web/pgadmin/browser/server_groups/servers/databases/schemas/fts_configurations/static/js/fts_configuration.ui.js index 2f423ff179d..3ba4824cdbc 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/fts_configurations/static/js/fts_configuration.ui.js +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/fts_configurations/static/js/fts_configuration.ui.js @@ -9,6 +9,7 @@ import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; import { DataGridFormHeader } from 'sources/SchemaView/DataGridView'; import { isEmptyString } from '../../../../../../../../static/js/validators'; @@ -74,7 +75,7 @@ class TokenSchema extends BaseUISchema { } } -export default class FTSConfigurationSchema extends BaseUISchema { +class FTSConfigurationSchema extends BaseUISchema { constructor(fieldOptions={}, initValues={}) { super({ name: undefined, // FTS Configuration name @@ -189,3 +190,5 @@ export default class FTSConfigurationSchema extends BaseUISchema { } } } +export default registerSchema(FTSConfigurationSchema); + diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/fts_dictionaries/static/js/fts_dictionary.ui.js b/web/pgadmin/browser/server_groups/servers/databases/schemas/fts_dictionaries/static/js/fts_dictionary.ui.js index 6ef8b81d58d..d8aaf272feb 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/fts_dictionaries/static/js/fts_dictionary.ui.js +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/fts_dictionaries/static/js/fts_dictionary.ui.js @@ -9,9 +9,10 @@ import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; import OptionsSchema from '../../../../../static/js/options.ui'; -export default class FTSDictionarySchema extends BaseUISchema { +class FTSDictionarySchema extends BaseUISchema { constructor(fieldOptions={}, initValues={}) { super({ name: undefined, // FTS Dictionary name @@ -74,3 +75,5 @@ export default class FTSDictionarySchema extends BaseUISchema { ]; } } +export default registerSchema(FTSDictionarySchema); + diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/fts_parsers/static/js/fts_parser.ui.js b/web/pgadmin/browser/server_groups/servers/databases/schemas/fts_parsers/static/js/fts_parser.ui.js index f36c4eca39d..b597085773e 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/fts_parsers/static/js/fts_parser.ui.js +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/fts_parsers/static/js/fts_parser.ui.js @@ -9,8 +9,9 @@ import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; -export default class FTSParserSchema extends BaseUISchema { +class FTSParserSchema extends BaseUISchema { constructor(fieldOptions = {}, initValues={}) { super({ name: null, @@ -138,3 +139,5 @@ export default class FTSParserSchema extends BaseUISchema { }]; } } +export default registerSchema(FTSParserSchema); + diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/fts_templates/static/js/fts_template.ui.js b/web/pgadmin/browser/server_groups/servers/databases/schemas/fts_templates/static/js/fts_template.ui.js index 5cf2751253a..1fd68346b67 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/fts_templates/static/js/fts_template.ui.js +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/fts_templates/static/js/fts_template.ui.js @@ -9,8 +9,9 @@ import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; -export default class FTSTemplateSchema extends BaseUISchema { +class FTSTemplateSchema extends BaseUISchema { constructor(fieldOptions = {}, initValues={}) { super({ name: null, @@ -93,3 +94,5 @@ export default class FTSTemplateSchema extends BaseUISchema { }]; } } +export default registerSchema(FTSTemplateSchema); + diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/functions/static/js/function.ui.js b/web/pgadmin/browser/server_groups/servers/databases/schemas/functions/static/js/function.ui.js index c6f1b649441..f5e3f7e55c8 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/functions/static/js/function.ui.js +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/functions/static/js/function.ui.js @@ -9,6 +9,7 @@ import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; import SecLabelSchema from '../../../../../static/js/sec_label.ui'; import { isEmptyString } from 'sources/validators'; import _ from 'lodash'; @@ -90,7 +91,7 @@ export class DefaultArgumentSchema extends BaseUISchema { } } -export default class FunctionSchema extends BaseUISchema { +class FunctionSchema extends BaseUISchema { constructor(getPrivilegeRoleSchema, getNodeVariableSchema, fieldOptions={}, node_info={}, type='function', initValues={}) { super({ name: undefined, @@ -473,3 +474,5 @@ export default class FunctionSchema extends BaseUISchema { } } } +export default registerSchema(FunctionSchema); + diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/functions/static/js/trigger_function.ui.js b/web/pgadmin/browser/server_groups/servers/databases/schemas/functions/static/js/trigger_function.ui.js index 6221f61dcdc..553399753a5 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/functions/static/js/trigger_function.ui.js +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/functions/static/js/trigger_function.ui.js @@ -10,10 +10,11 @@ import gettext from 'sources/gettext'; import SecLabelSchema from '../../../../../static/js/sec_label.ui'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; import { isEmptyString } from 'sources/validators'; -export default class TriggerFunctionSchema extends BaseUISchema { +class TriggerFunctionSchema extends BaseUISchema { constructor(getPrivilegeRoleSchema, getVariableSchema, fieldOptions={}, initValues={}) { super({ name: null, @@ -269,3 +270,5 @@ export default class TriggerFunctionSchema extends BaseUISchema { } } } +export default registerSchema(TriggerFunctionSchema); + diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/operators/static/js/operator.ui.js b/web/pgadmin/browser/server_groups/servers/databases/schemas/operators/static/js/operator.ui.js index db97ebcc67e..349eeae3169 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/operators/static/js/operator.ui.js +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/operators/static/js/operator.ui.js @@ -8,9 +8,10 @@ ////////////////////////////////////////////////////////////// import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; import gettext from 'sources/gettext'; -export default class OperatorSchema extends BaseUISchema { +class OperatorSchema extends BaseUISchema { constructor(fieldOptions = {},initValues={}) { super({ name: undefined, @@ -122,4 +123,6 @@ export default class OperatorSchema extends BaseUISchema { ]; } } +export default registerSchema(OperatorSchema); + diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/packages/edbfuncs/static/js/edbfunc.ui.js b/web/pgadmin/browser/server_groups/servers/databases/schemas/packages/edbfuncs/static/js/edbfunc.ui.js index 3325fa38254..06288178002 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/packages/edbfuncs/static/js/edbfunc.ui.js +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/packages/edbfuncs/static/js/edbfunc.ui.js @@ -9,8 +9,9 @@ import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; -export default class EDBFuncSchema extends BaseUISchema { +class EDBFuncSchema extends BaseUISchema { constructor(fieldOptions = {}, initValues={}) { super({ name: undefined, @@ -81,3 +82,5 @@ export default class EDBFuncSchema extends BaseUISchema { }]; } } +export default registerSchema(EDBFuncSchema); + diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/packages/edbvars/static/js/edbvar.ui.js b/web/pgadmin/browser/server_groups/servers/databases/schemas/packages/edbvars/static/js/edbvar.ui.js index 1f938b38dd0..700bb94ec8f 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/packages/edbvars/static/js/edbvar.ui.js +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/packages/edbvars/static/js/edbvar.ui.js @@ -9,8 +9,9 @@ import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; -export default class EDBVarSchema extends BaseUISchema { +class EDBVarSchema extends BaseUISchema { constructor(fieldOptions = {}, initValues={}) { super({ name: undefined, @@ -44,3 +45,5 @@ export default class EDBVarSchema extends BaseUISchema { }]; } } +export default registerSchema(EDBVarSchema); + diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/packages/static/js/package.ui.js b/web/pgadmin/browser/server_groups/servers/databases/schemas/packages/static/js/package.ui.js index 5e82987a23d..43a15e8a170 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/packages/static/js/package.ui.js +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/packages/static/js/package.ui.js @@ -9,9 +9,10 @@ import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; import { isEmptyString } from 'sources/validators'; -export default class PackageSchema extends BaseUISchema { +class PackageSchema extends BaseUISchema { constructor(getPrivilegeRoleSchema, fieldOptions = {}, initValues={}) { super({ name: undefined, @@ -158,3 +159,5 @@ export default class PackageSchema extends BaseUISchema { return null; } } +export default registerSchema(PackageSchema); + diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/sequences/static/js/sequence.ui.js b/web/pgadmin/browser/server_groups/servers/databases/schemas/sequences/static/js/sequence.ui.js index 46b08e9563e..864bbb07cc2 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/sequences/static/js/sequence.ui.js +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/sequences/static/js/sequence.ui.js @@ -9,6 +9,7 @@ import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; import SecLabelSchema from '../../../../../static/js/sec_label.ui'; import { emptyValidator, isEmptyString } from '../../../../../../../../static/js/validators'; @@ -73,7 +74,7 @@ export class OwnedBySchema extends BaseUISchema { } -export default class SequenceSchema extends BaseUISchema { +class SequenceSchema extends BaseUISchema { constructor(getPrivilegeRoleSchema, fieldOptions={}, initValues={}) { super({ name: undefined, @@ -285,3 +286,5 @@ export default class SequenceSchema extends BaseUISchema { return null; } } +export default registerSchema(SequenceSchema); + diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/static/js/catalog.ui.js b/web/pgadmin/browser/server_groups/servers/databases/schemas/static/js/catalog.ui.js index 41ab884367c..8aec2a7fe90 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/static/js/catalog.ui.js +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/static/js/catalog.ui.js @@ -9,8 +9,9 @@ import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; -export default class CatalogSchema extends BaseUISchema { +class CatalogSchema extends BaseUISchema { constructor(fieldOptions = {}, initValues={}) { super({ name: undefined, @@ -54,3 +55,5 @@ export default class CatalogSchema extends BaseUISchema { ]; } } +export default registerSchema(CatalogSchema); + diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/static/js/schema.ui.js b/web/pgadmin/browser/server_groups/servers/databases/schemas/static/js/schema.ui.js index 305695aa71d..6e0419e9ada 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/static/js/schema.ui.js +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/static/js/schema.ui.js @@ -9,11 +9,12 @@ import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; import { DefaultPrivSchema } from '../../../static/js/database.ui'; import SecLabelSchema from '../../../../static/js/sec_label.ui'; import { isEmptyString } from 'sources/validators'; -export default class PGSchema extends BaseUISchema { +class PGSchema extends BaseUISchema { constructor(getPrivilegeRoleSchema, fieldOptions = {}, initValues={}) { super({ name: undefined, @@ -110,3 +111,5 @@ export default class PGSchema extends BaseUISchema { return null; } } +export default registerSchema(PGSchema); + diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/synonyms/static/js/synonym.ui.js b/web/pgadmin/browser/server_groups/servers/databases/schemas/synonyms/static/js/synonym.ui.js index 756c42f8af9..b770ae8140f 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/synonyms/static/js/synonym.ui.js +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/synonyms/static/js/synonym.ui.js @@ -9,9 +9,10 @@ import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; import { emptyValidator } from 'sources/validators'; -export default class SynonymSchema extends BaseUISchema { +class SynonymSchema extends BaseUISchema { constructor(fieldOptions={}, nodeInfo={}, initValues={}) { super({ targettype: 'r', @@ -134,3 +135,5 @@ export default class SynonymSchema extends BaseUISchema { } } } +export default registerSchema(SynonymSchema); + diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/columns/static/js/column.ui.js b/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/columns/static/js/column.ui.js index 062464c504f..1dcb4a40d3e 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/columns/static/js/column.ui.js +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/columns/static/js/column.ui.js @@ -9,6 +9,7 @@ import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; import VariableSchema from 'top/browser/server_groups/servers/static/js/variable.ui'; import SecLabelSchema from 'top/browser/server_groups/servers/static/js/sec_label.ui'; import _ from 'lodash'; @@ -30,7 +31,7 @@ export function getNodeColumnSchema(treeNodeInfo, itemNodeData, pgBrowser) { ); } -export default class ColumnSchema extends BaseUISchema { +class ColumnSchema extends BaseUISchema { constructor(getPrivilegeRoleSchema, nodeInfo, cltypeOptions, collspcnameOptions, geometryTypes, inErd=false) { super({ name: undefined, @@ -176,7 +177,10 @@ export default class ColumnSchema extends BaseUISchema { // Need to show this field only when creating new table // [in SubNode control] id: 'is_primary_key', label: gettext('Primary key?'), - cell: 'switch', type: 'switch', width: 100, enableResizing: false, deps:['name', ['primary_key']], + cell: 'switch', type: 'switch', width: 100, enableResizing: false, + // readonly/editable also read top.sessData['oid'] and ['is_partitioned']; + // declare them so option re-eval still fires under incremental walks. + deps:['name', ['primary_key'], ['oid'], ['is_partitioned']], visible: ()=>{ return obj.top?.nodeInfo && _.isUndefined( obj.top.nodeInfo['table'] || obj.top.nodeInfo['view'] || @@ -750,3 +754,5 @@ export default class ColumnSchema extends BaseUISchema { return false; } } +export default registerSchema(ColumnSchema); + diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/compound_triggers/static/js/compound_trigger.ui.js b/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/compound_triggers/static/js/compound_trigger.ui.js index 88733564604..623cfda3184 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/compound_triggers/static/js/compound_trigger.ui.js +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/compound_triggers/static/js/compound_trigger.ui.js @@ -9,6 +9,7 @@ import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; import { isEmptyString } from 'sources/validators'; @@ -72,7 +73,7 @@ export class ForEventsSchema extends BaseUISchema { } } -export default class CompoundTriggerSchema extends BaseUISchema { +class CompoundTriggerSchema extends BaseUISchema { constructor(fieldOptions={}, nodeInfo={}, initValues={}) { super({ name: undefined, @@ -203,3 +204,5 @@ export default class CompoundTriggerSchema extends BaseUISchema { 'END;'); } } +export default registerSchema(CompoundTriggerSchema); + diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/constraints/check_constraint/static/js/check_constraint.ui.js b/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/constraints/check_constraint/static/js/check_constraint.ui.js index 675b352f953..8a86030f86a 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/constraints/check_constraint/static/js/check_constraint.ui.js +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/constraints/check_constraint/static/js/check_constraint.ui.js @@ -8,9 +8,10 @@ ////////////////////////////////////////////////////////////// import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; import _ from 'lodash'; import { isEmptyString } from 'sources/validators'; -export default class CheckConstraintSchema extends BaseUISchema { +class CheckConstraintSchema extends BaseUISchema { constructor() { super({ name: undefined, @@ -100,3 +101,5 @@ export default class CheckConstraintSchema extends BaseUISchema { return false; } } +export default registerSchema(CheckConstraintSchema); + diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/constraints/exclusion_constraint/static/js/exclusion_constraint.ui.js b/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/constraints/exclusion_constraint/static/js/exclusion_constraint.ui.js index 451caa339a9..e77042ad476 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/constraints/exclusion_constraint/static/js/exclusion_constraint.ui.js +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/constraints/exclusion_constraint/static/js/exclusion_constraint.ui.js @@ -8,6 +8,7 @@ ////////////////////////////////////////////////////////////// import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; import _ from 'lodash'; import { isEmptyString } from 'sources/validators'; import { SCHEMA_STATE_ACTIONS } from 'sources/SchemaView'; @@ -198,7 +199,7 @@ class ExclusionColumnSchema extends BaseUISchema { } } -export default class ExclusionConstraintSchema extends BaseUISchema { +class ExclusionConstraintSchema extends BaseUISchema { constructor(fieldOptions={}, nodeInfo={}) { super({ name: undefined, @@ -284,6 +285,16 @@ export default class ExclusionConstraintSchema extends BaseUISchema { type: 'select', group: gettext('Definition'), options: this.fieldOptions.amname, deferredDepChange: (state, source, topState, actionObj)=>{ + // Opt out cleanly when nothing would change: amname unchanged + // or no columns to wipe. Previously this queued a confirmation + // dialog unconditionally. + // actionObj.oldState is guaranteed by the reducer to be the + // pre-dispatch clone; no need for optional chaining (and the + // cancel branch below accesses it unconditionally). + if(state.amname === actionObj.oldState.amname + || !state.columns?.length) { + return undefined; + } return new Promise((resolve)=>{ pgAdmin.Browser.notifier.confirm( gettext('Change access method?'), @@ -311,11 +322,9 @@ export default class ExclusionConstraintSchema extends BaseUISchema { })); }, function() { - resolve(()=>{ - return { - amname: actionObj.oldState.amname, - }; - }); + resolve(()=>({ + amname: actionObj.oldState.amname, + })); } ); }); @@ -455,3 +464,5 @@ export default class ExclusionConstraintSchema extends BaseUISchema { return false; } } +export default registerSchema(ExclusionConstraintSchema); + diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/constraints/foreign_key/static/js/foreign_key.ui.js b/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/constraints/foreign_key/static/js/foreign_key.ui.js index 6007b9d69eb..bc61dc10fc5 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/constraints/foreign_key/static/js/foreign_key.ui.js +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/constraints/foreign_key/static/js/foreign_key.ui.js @@ -9,6 +9,7 @@ import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; import _ from 'lodash'; import { isEmptyString } from 'sources/validators'; import { SCHEMA_STATE_ACTIONS } from 'sources/SchemaView'; @@ -145,7 +146,7 @@ export class ForeignKeyColumnSchema extends BaseUISchema { } } -export default class ForeignKeySchema extends BaseUISchema { +class ForeignKeySchema extends BaseUISchema { constructor(fieldOptions={}, nodeInfo={}, getColumns=()=>[], initValues={}, inErd=false) { super({ name: undefined, @@ -428,3 +429,5 @@ export default class ForeignKeySchema extends BaseUISchema { return false; } } +export default registerSchema(ForeignKeySchema); + diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/constraints/index_constraint/static/js/primary_key.ui.js b/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/constraints/index_constraint/static/js/primary_key.ui.js index 6d903f20dbe..1dc0f1de114 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/constraints/index_constraint/static/js/primary_key.ui.js +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/constraints/index_constraint/static/js/primary_key.ui.js @@ -8,13 +8,14 @@ ////////////////////////////////////////////////////////////// import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; import _ from 'lodash'; import { isEmptyString } from 'sources/validators'; import { SCHEMA_STATE_ACTIONS } from '../../../../../../../../../../static/js/SchemaView'; import TableSchema from '../../../../static/js/table.ui'; -export default class PrimaryKeySchema extends BaseUISchema { +class PrimaryKeySchema extends BaseUISchema { constructor(fieldOptions={}, nodeInfo={}) { super({ name: undefined, @@ -271,3 +272,5 @@ export default class PrimaryKeySchema extends BaseUISchema { return false; } } +export default registerSchema(PrimaryKeySchema); + diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/constraints/index_constraint/static/js/unique_constraint.ui.js b/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/constraints/index_constraint/static/js/unique_constraint.ui.js index 86d68cc2f22..32b1c3b5502 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/constraints/index_constraint/static/js/unique_constraint.ui.js +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/constraints/index_constraint/static/js/unique_constraint.ui.js @@ -8,12 +8,13 @@ ////////////////////////////////////////////////////////////// import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; import _ from 'lodash'; import { isEmptyString } from 'sources/validators'; import { SCHEMA_STATE_ACTIONS } from '../../../../../../../../../../static/js/SchemaView'; import TableSchema from '../../../../static/js/table.ui'; -export default class UniqueConstraintSchema extends BaseUISchema { +class UniqueConstraintSchema extends BaseUISchema { constructor(fieldOptions={}, nodeInfo={}) { super({ name: undefined, @@ -271,3 +272,5 @@ export default class UniqueConstraintSchema extends BaseUISchema { return false; } } +export default registerSchema(UniqueConstraintSchema); + diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/indexes/static/js/index.ui.js b/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/indexes/static/js/index.ui.js index 9753327152e..1bb7395288e 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/indexes/static/js/index.ui.js +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/indexes/static/js/index.ui.js @@ -11,6 +11,7 @@ import _ from 'lodash'; import { DataGridFormHeader } from 'sources/SchemaView/DataGridView'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; import gettext from 'sources/gettext'; import pgAdmin from 'sources/pgadmin'; import { isEmptyString } from 'sources/validators'; @@ -184,7 +185,11 @@ class IndexColumnSchema extends BaseUISchema { }, node: 'index', url_jump_after_node: 'schema', - deps: ['amname'], + // The cell-filter reads obj.top?.sessData.amname (the parent Index + // row's amname), not a sibling column's amname. Use an absolute + // path so the dep registers against the parent field; the relative + // form was dead (column rows don't have an `amname` field). + deps: [['amname']], },{ id: 'sort_order', label: gettext('Sort order'), type: 'select', cell: 'select', options: [ @@ -351,7 +356,7 @@ export class WithSchema extends BaseUISchema { } } -export default class IndexSchema extends BaseUISchema { +class IndexSchema extends BaseUISchema { constructor(fieldOptions = {}, nodeData = {}, initValues={}) { super({ name: undefined, @@ -383,6 +388,11 @@ export default class IndexSchema extends BaseUISchema { this.indexColumnSchema = new IndexColumnSchema(this.node_info); this.indexHeaderSchema.indexColumnSchema = this.indexColumnSchema; this.withSchema = new WithSchema(this.node_info); + + // Opt into SchemaView's incremental option evaluation. Safe after the + // op_class parent-amname dep was declared (commit 91fcd6b09 + + // e80f9d7ee). See web/regression/perf-bench/README.md. + this.incrementalOptions = true; } get idAttribute() { @@ -476,35 +486,25 @@ export default class IndexSchema extends BaseUISchema { }; }, deferredDepChange: (state, source, topState, actionObj) => { - const setColumns = (resolve)=>{ - resolve(()=>{ - state.columns.splice(0, state.columns?.length); - return { - columns: state.columns, - }; - }); - }; - if((state.amname != actionObj?.oldState.amname) && state.columns?.length > 0) { - return new Promise((resolve)=>{ - pgAdmin.Browser.notifier.confirm( - gettext('Warning'), - gettext('Changing access method will clear columns collection. Do you want to continue?'), - function () { - setColumns(resolve); - }, - function() { - resolve(()=>{ - state.amname = actionObj?.oldState.amname; - return { - amname: state.amname, - }; - }); - } - ); - }); - } else { - return Promise.resolve(()=>{/*This is intentional (SonarQube)*/}); + // No-op when amname didn't change or there's nothing to clear. + // Returning undefined opts out of the deferred queue. + // actionObj.oldState is guaranteed by the reducer to be the + // pre-dispatch clone; no need for optional chaining (and the + // cancel branch below accesses it unconditionally). + if (state.amname === actionObj.oldState.amname + || !state.columns?.length) { + return undefined; } + return new Promise((resolve)=>{ + pgAdmin.Browser.notifier.confirm( + gettext('Warning'), + gettext('Changing access method will clear columns collection. Do you want to continue?'), + // Confirmed — clear the columns collection. + () => resolve(() => ({ columns: [] })), + // Cancelled — revert amname to its previous value. + () => resolve(() => ({ amname: actionObj.oldState.amname })), + ); + }); }, }, { @@ -682,3 +682,5 @@ export default class IndexSchema extends BaseUISchema { return null; } } +export default registerSchema(IndexSchema); + diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/partitions/static/js/partition.ui.js b/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/partitions/static/js/partition.ui.js index 54b6579954b..eeab68c8b10 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/partitions/static/js/partition.ui.js +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/partitions/static/js/partition.ui.js @@ -9,6 +9,7 @@ import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; import SecLabelSchema from 'top/browser/server_groups/servers/static/js/sec_label.ui'; import _ from 'lodash'; import { ConstraintsSchema, getPrivilegesForTableAndLikeObjects } from '../../../static/js/table.ui'; @@ -76,7 +77,7 @@ export function getNodePartitionTableSchema(treeNodeInfo, itemNodeData, pgBrowse ); } -export default class PartitionTableSchema extends BaseUISchema { +class PartitionTableSchema extends BaseUISchema { constructor(fieldOptions={}, nodeInfo={}, schemas={}, getPrivilegeRoleSchema={}, getColumns=()=>[], getCollations=()=>[], getOperatorClass=()=>[], getAttachTables=()=>[], initValues={}) { super({ @@ -124,6 +125,11 @@ export default class PartitionTableSchema extends BaseUISchema { this.partitionKeysObj = new PartitionKeysSchema([], getCollations, getOperatorClass); this.partitionsObj = new PartitionsSchema(this.nodeInfo, getCollations, getOperatorClass, fieldOptions.table_amname_list, getAttachTables); this.constraintsObj = this.schemas.constraints(); + + // Same audit basis as TableSchema — re-uses the same nested ColumnSchema + // + PartitionsSchema + PartitionKeysSchema row schemas, all already + // deps-audited. + this.incrementalOptions = true; } get idAttribute() { @@ -452,3 +458,5 @@ export default class PartitionTableSchema extends BaseUISchema { return false; } } +export default registerSchema(PartitionTableSchema); + diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/row_security_policies/static/js/row_security_policy.ui.js b/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/row_security_policies/static/js/row_security_policy.ui.js index d8428ac7a35..eb2557cfd5c 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/row_security_policies/static/js/row_security_policy.ui.js +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/row_security_policies/static/js/row_security_policy.ui.js @@ -9,9 +9,10 @@ import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; -export default class RowSecurityPolicySchema extends BaseUISchema { +class RowSecurityPolicySchema extends BaseUISchema { constructor(fieldOptions={}, initValues={}) { super({ name: undefined, @@ -130,3 +131,5 @@ export default class RowSecurityPolicySchema extends BaseUISchema { ]; } } +export default registerSchema(RowSecurityPolicySchema); + diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/rules/static/js/rule.ui.js b/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/rules/static/js/rule.ui.js index 22b8a794173..c49b57dec02 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/rules/static/js/rule.ui.js +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/rules/static/js/rule.ui.js @@ -9,9 +9,10 @@ import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; -export default class RuleSchema extends BaseUISchema { +class RuleSchema extends BaseUISchema { constructor(fieldOptions={}) { const schemaNode = fieldOptions?.nodeInfo['schema']; const schema = schemaNode?.label || ''; @@ -114,3 +115,5 @@ export default class RuleSchema extends BaseUISchema { ]; } } +export default registerSchema(RuleSchema); + diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/static/js/partition.utils.ui.js b/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/static/js/partition.utils.ui.js index 6c2bae78794..e6e11b05279 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/static/js/partition.utils.ui.js +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/static/js/partition.utils.ui.js @@ -163,6 +163,11 @@ export class PartitionsSchema extends BaseUISchema { {label: gettext('Attach'), value: true}, {label: gettext('Create'), value: false}, ], controlProps: {allowClear: false}, + // editable/readonly call obj.top.isNew(), which reads the parent + // table's idAttribute ('oid'). Declare it so incremental option + // walks still revisit this row when the parent transitions + // new -> saved. + deps: [['oid']], editable: function(state) { return obj.isNew(state) && !obj.top.isNew(); }, @@ -229,6 +234,10 @@ export class PartitionsSchema extends BaseUISchema { },{ id: 'is_default', label: gettext('Default'), type: 'switch', cell:'switch', width: 55, enableResizing: false, min_version: 110000, + // editable/readonly read parent partition_type + obj.isNew(state) + // (own row's oid). Declare parent partition_type + parent oid so + // incremental option walks pick this row up when either changes. + deps: [['partition_type'], ['oid']], editable: function(state) { return (obj.top && (obj.top.sessData.partition_type == 'range' || obj.top.sessData.partition_type == 'list') && obj.isNew(state) @@ -241,7 +250,13 @@ export class PartitionsSchema extends BaseUISchema { }, },{ id: 'values_from', label: gettext('From'), type:'text', cell: 'text', - deps: ['is_default'], + // editable/disabled read this.top.sessData.partition_type (parent + // schema) + this.isNew(state) (own row's oid) + state.is_default + // (same-row). Without declaring partition_type + oid, the + // incremental walker prunes sibling rows whose options would + // have changed under a full walk — see audit divergence on + // partitions.0.values_from. + deps: ['is_default', ['partition_type'], ['oid']], editable: function(state) { return obj.isEditable(state, 'range'); }, @@ -251,7 +266,7 @@ export class PartitionsSchema extends BaseUISchema { }, { id: 'values_to', label: gettext('To'), type:'text', cell: 'text', - deps: ['is_default'], + deps: ['is_default', ['partition_type'], ['oid']], editable: function(state) { return obj.isEditable(state, 'range'); }, @@ -260,7 +275,7 @@ export class PartitionsSchema extends BaseUISchema { }, },{ id: 'values_in', label: gettext('In'), type:'text', cell: 'text', - deps: ['is_default'], + deps: ['is_default', ['partition_type'], ['oid']], editable: function(state) { return obj.isEditable(state, 'list'); }, @@ -269,6 +284,7 @@ export class PartitionsSchema extends BaseUISchema { }, },{ id: 'values_modulus', label: gettext('Modulus'), type:'int', cell: 'int', + deps: [['partition_type'], ['oid'], 'is_default'], editable: function(state) { return obj.isEditable(state, 'hash'); }, @@ -277,6 +293,9 @@ export class PartitionsSchema extends BaseUISchema { }, },{ id: 'values_remainder', label: gettext('Remainder'), type:'int', cell: 'int', + // editable/disabled read parent partition_type + obj.isNew(state) + // (own row's oid); is_default is same-row. + deps: ['is_default', ['partition_type'], ['oid']], editable: function(state) { return obj.top && obj.top.sessData.partition_type == 'hash' && obj.isNew(state); }, @@ -324,7 +343,10 @@ export class PartitionsSchema extends BaseUISchema { schema: this.subPartitionsObj, editable: true, type: 'collection', group: 'Partition', mode: ['properties', 'create', 'edit'], - deps: ['is_sub_partitioned', 'sub_partition_type', ['typname']], + // canAddRow reads obj.top.sessData.columns (parent table's columns + // collection). Declare [['columns']] so any change inside columns + // re-evaluates canAddRow under incremental walks. + deps: ['is_sub_partitioned', 'sub_partition_type', ['typname'], ['columns']], canEdit: false, canDelete: true, canAdd: function(state) { return obj.isNew(state) && state.is_sub_partitioned; diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/static/js/table.ui.js b/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/static/js/table.ui.js index 8c678ba4b04..8794f65357b 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/static/js/table.ui.js +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/static/js/table.ui.js @@ -8,6 +8,7 @@ ////////////////////////////////////////////////////////////// import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; import SecLabelSchema from 'top/browser/server_groups/servers/static/js/sec_label.ui'; import _ from 'lodash'; import { isEmptyString } from 'sources/validators'; @@ -357,7 +358,7 @@ export class LikeSchema extends BaseUISchema { } } -export default class TableSchema extends BaseUISchema { +class TableSchema extends BaseUISchema { constructor(fieldOptions={}, nodeInfo={}, schemas={}, getPrivilegeRoleSchema=()=>{/*This is intentional (SonarQube)*/}, getColumns=()=>[], getCollations=()=>[], getOperatorClass=()=>[], getAttachTables=()=>[], initValues={}, inErd=false) { super({ @@ -411,6 +412,14 @@ export default class TableSchema extends BaseUISchema { this.vacuumSettingsSchema = this.schemas.vacuum_settings?.() || {}; this.partitionKeysObj = new PartitionKeysSchema([], getCollations, getOperatorClass); this.inErd = inErd; + + // Opt into SchemaView's incremental option evaluation. Safe for this + // schema after the column/partition deps audit landed in commits + // 91fcd6b09 + e80f9d7ee — all cross-row reads in column/partition row + // schemas declare their parent sources via `field.deps`. See + // web/regression/perf-bench/README.md "Known limitation" for the + // audit criteria. + this.incrementalOptions = true; } static getErdSupportedData(data) { @@ -596,79 +605,57 @@ export default class TableSchema extends BaseUISchema { } }, deferredDepChange: (state, source, topState, actionObj)=>{ - return new Promise((resolve)=>{ - // current table list and previous table list - let newColInherits = state.coll_inherits || []; - let oldColInherits = actionObj.oldState.coll_inherits || []; - - let tabName; - let tabColsResponse; - - // Add columns logic - // If new table is added in list - if(newColInherits.length > 1 && newColInherits.length > oldColInherits.length) { - // Find newly added table from current list - tabName = _.difference(newColInherits, oldColInherits); - tabColsResponse = obj.getColumns({tid: this.getTableOid(tabName[0])}); - } else if (newColInherits.length == 1) { - // First table added - tabColsResponse = obj.getColumns({tid: this.getTableOid(newColInherits[0])}); - } - - if(tabColsResponse) { - tabColsResponse.then((res)=>{ - resolve((tmpstate)=>{ - let finalCols = res.map((col)=>obj.columnsSchema.getNewData(col)); - let currentSelectedCols = []; - if (!_.isEmpty(tmpstate.columns)){ - currentSelectedCols = tmpstate.columns; - } - let colNameList = []; - tmpstate.columns.forEach((col=>{ - colNameList.push(col.name); - })); - for (let col of Object.values(finalCols)) { - if(!colNameList.includes(col.name)){ - currentSelectedCols.push(col); - } - } + const newColInherits = state.coll_inherits || []; + const oldColInherits = actionObj.oldState.coll_inherits || []; + const added = _.difference(newColInherits, oldColInherits); + const removed = _.difference(oldColInherits, newColInherits); - if (!_.isEmpty(currentSelectedCols)){ - finalCols = currentSelectedCols; - } - - obj.changeColumnOptions(finalCols); - return { - adding_inherit_cols: false, - columns: finalCols, - }; - }); - }); - } + // REMOVE takes precedence: any removed parent means stale + // columns to clean up. Covers pure shrink AND same-length + // swap — without this branch a swap would leave the removed + // parent's columns sitting in the grid. + if(removed.length > 0) { + const removeOid = this.getTableOid(removed[0]); + // Guard: if inheritedTableList is stale and the removed + // table can't be resolved to an OID, opt out. Filtering on + // `inheritedid != undefined` would silently drop local + // user-added columns (`null == undefined` in JS). + if(removeOid == null) return undefined; + return Promise.resolve((tmpstate)=>{ + const finalCols = (tmpstate.columns || []) + .filter((col)=>col.inheritedid != removeOid); + obj.changeColumnOptions(finalCols); + return { + adding_inherit_cols: false, + columns: finalCols, + }; + }); + } - // Remove columns logic - let removeOid; - if(newColInherits.length > 0 && newColInherits.length < oldColInherits.length) { - // Find deleted table from previous list - tabName = _.difference(oldColInherits, newColInherits); - removeOid = this.getTableOid(tabName[0]); - } else if (oldColInherits.length === 1 && newColInherits.length < 1) { - // We got last table from list - tabName = oldColInherits[0]; - removeOid = this.getTableOid(tabName); - } - if(removeOid) { - resolve((tmpstate)=>{ - let finalCols = tmpstate.columns; - _.remove(tmpstate.columns, (col)=>col.inheritedid==removeOid); + // Pure ADD: list grew without any removals. + if(added.length > 0) { + const fetchOid = this.getTableOid(added[0]); + if(fetchOid == null) return undefined; + return obj.getColumns({tid: fetchOid}).then((res)=>( + (tmpstate)=>{ + const fetched = res.map((col)=>obj.columnsSchema.getNewData(col)); + const existing = tmpstate.columns || []; + const existingNames = new Set(existing.map((c)=>c.name)); + const finalCols = [ + ...existing, + ...fetched.filter((c)=>!existingNames.has(c.name)), + ]; obj.changeColumnOptions(finalCols); return { adding_inherit_cols: false, - columns: finalCols + columns: finalCols, }; - }); - } - }); + } + )); + } + + // Lists are equivalent — no work to do. + return undefined; }, }, { @@ -784,49 +771,47 @@ export default class TableSchema extends BaseUISchema { obj.ofTypeTables = res; }, deferredDepChange: (state, source, topState, actionObj)=>{ - const setColumns = (resolve)=>{ - let finalCols = []; - if(!isEmptyString(state.typname)) { - let typeTable = _.find(obj.ofTypeTables||[], (t)=>t.label==state.typname); - finalCols = typeTable.oftype_columns; - } - resolve(() => { - obj.changeColumnOptions(finalCols); - return { - columns: finalCols, - primary_key: [], - foreign_key: [], - exclude_constraint: [], - unique_constraint: [], - partition_keys: [], - partitions: [], - }; - }); - }; + // No change — opt out of the deferred queue. + if(state.typname == actionObj.oldState.typname) return undefined; + + // finalCols depends only on closure-captured state and + // obj.ofTypeTables — not on tmpstate. Compute it once here so + // the schema-level side effect (changeColumnOptions) can run + // BEFORE resolve, matching the protocol's "side effects in the + // Promise body, callbacks return pure deltas" rule. + let finalCols = []; + if(!isEmptyString(state.typname)) { + // ofTypeTables can be empty or stale (loaded options not + // yet refreshed). Guard against an undefined lookup so the + // callback returns an empty column list instead of throwing + // into the deferred-queue drain. + const typeTable = _.find(obj.ofTypeTables||[], (t)=>t.label==state.typname); + finalCols = typeTable?.oftype_columns ?? []; + } + const deltaCb = ()=>({ + columns: finalCols, + primary_key: [], + foreign_key: [], + exclude_constraint: [], + unique_constraint: [], + partition_keys: [], + partitions: [], + }); if(!isEmptyString(state.typname) && isEmptyString(actionObj.oldState.typname)) { return new Promise((resolve)=>{ pgAdmin.Browser.notifier.confirm( gettext('Remove column definitions?'), gettext('Changing \'Of type\' will remove column definitions.'), - function () { - setColumns(resolve); + ()=>{ + obj.changeColumnOptions(finalCols); + resolve(deltaCb); }, - function() { - resolve(()=>{ - return { - typname: null, - }; - }); - } + ()=>resolve(()=>({typname: null})), ); }); - } else if(state.typname != actionObj.oldState.typname) { - return new Promise((resolve)=>{ - setColumns(resolve); - }); - } else { - return Promise.resolve(()=>{/*This is intentional (SonarQube)*/}); } + obj.changeColumnOptions(finalCols); + return Promise.resolve(deltaCb); }, }, { @@ -1084,3 +1069,5 @@ export default class TableSchema extends BaseUISchema { return false; } } +export default registerSchema(TableSchema); + diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/triggers/static/js/trigger.ui.js b/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/triggers/static/js/trigger.ui.js index cd4fc7ec158..020b00aba17 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/triggers/static/js/trigger.ui.js +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/triggers/static/js/trigger.ui.js @@ -9,6 +9,7 @@ import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; import { isEmptyString } from 'sources/validators'; export class EventSchema extends BaseUISchema { @@ -117,7 +118,7 @@ export class EventSchema extends BaseUISchema { } -export default class TriggerSchema extends BaseUISchema { +class TriggerSchema extends BaseUISchema { constructor(fieldOptions={}, initValues={}) { super({ name: undefined, @@ -474,4 +475,6 @@ export default class TriggerSchema extends BaseUISchema { } } } +export default registerSchema(TriggerSchema); + diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/types/static/js/type.ui.js b/web/pgadmin/browser/server_groups/servers/databases/schemas/types/static/js/type.ui.js index 9d2359bd2ee..36f0d4c8cf8 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/types/static/js/type.ui.js +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/types/static/js/type.ui.js @@ -9,6 +9,7 @@ import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; import SecLabelSchema from '../../../../../static/js/sec_label.ui'; import { getNodeAjaxOptions } from '../../../../../../../static/js/node_ajax'; @@ -1137,7 +1138,7 @@ class DataTypeSchema extends BaseUISchema { } } -export default class TypeSchema extends BaseUISchema { +class TypeSchema extends BaseUISchema { constructor(getPrivilegeRoleSchema, compositeSchema, rangeSchema, externalSchema, dataTypeSchema, fieldOptions = {}, initValues={}) { super({ name: null, @@ -1484,6 +1485,8 @@ export default class TypeSchema extends BaseUISchema { }]; } } +export default registerSchema(TypeSchema); + export { CompositeSchema, diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/views/static/js/mview.ui.js b/web/pgadmin/browser/server_groups/servers/databases/schemas/views/static/js/mview.ui.js index e31f202e30d..7470c7d67e2 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/views/static/js/mview.ui.js +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/views/static/js/mview.ui.js @@ -10,12 +10,13 @@ import _ from 'lodash'; import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; import SecLabelSchema from '../../../../../static/js/sec_label.ui'; import { isEmptyString } from 'sources/validators'; import { getPrivilegesForTableAndLikeObjects } from '../../../tables/static/js/table.ui'; -export default class MViewSchema extends BaseUISchema { +class MViewSchema extends BaseUISchema { constructor(getPrivilegeRoleSchema, getVacuumSettingsSchema, fieldOptions={}, initValues={}) { super({ spcname: undefined, @@ -189,3 +190,5 @@ export default class MViewSchema extends BaseUISchema { } } } +export default registerSchema(MViewSchema); + diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/views/static/js/view.ui.js b/web/pgadmin/browser/server_groups/servers/databases/schemas/views/static/js/view.ui.js index b65e34cb83f..4c6d0270ae6 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/views/static/js/view.ui.js +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/views/static/js/view.ui.js @@ -10,12 +10,13 @@ import _ from 'lodash'; import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; import SecLabelSchema from '../../../../../static/js/sec_label.ui'; import { isEmptyString } from 'sources/validators'; import { getPrivilegesForTableAndLikeObjects } from '../../../tables/static/js/table.ui'; -export default class ViewSchema extends BaseUISchema { +class ViewSchema extends BaseUISchema { constructor(getPrivilegeRoleSchema, nodeInfo, fieldOptions={}, initValues={}) { super({ owner: undefined, @@ -180,3 +181,5 @@ export default class ViewSchema extends BaseUISchema { } } } +export default registerSchema(ViewSchema); + diff --git a/web/pgadmin/browser/server_groups/servers/databases/static/js/database.ui.js b/web/pgadmin/browser/server_groups/servers/databases/static/js/database.ui.js index 62032f86d61..7e18fa9e865 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/static/js/database.ui.js +++ b/web/pgadmin/browser/server_groups/servers/databases/static/js/database.ui.js @@ -10,6 +10,7 @@ import _ from 'lodash'; import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; import SecLabelSchema from '../../../static/js/sec_label.ui'; import { getPrivilegesForTableAndLikeObjects } from '../../schemas/tables/static/js/table.ui'; @@ -50,7 +51,7 @@ export class DefaultPrivSchema extends BaseUISchema { } } -export default class DatabaseSchema extends BaseUISchema { +class DatabaseSchema extends BaseUISchema { constructor(getVariableSchema, getPrivilegeRoleSchema, fieldOptions={}, nodeInfo={}, initValues={}) { super({ name: undefined, @@ -332,3 +333,5 @@ export default class DatabaseSchema extends BaseUISchema { return false; } } +export default registerSchema(DatabaseSchema); + diff --git a/web/pgadmin/browser/server_groups/servers/databases/subscriptions/static/js/subscription.ui.js b/web/pgadmin/browser/server_groups/servers/databases/subscriptions/static/js/subscription.ui.js index 89651e932dd..aee3dbc3c1d 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/subscriptions/static/js/subscription.ui.js +++ b/web/pgadmin/browser/server_groups/servers/databases/subscriptions/static/js/subscription.ui.js @@ -8,6 +8,7 @@ ////////////////////////////////////////////////////////////// import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; import { isEmptyString } from 'sources/validators'; import _ from 'lodash'; @@ -17,7 +18,7 @@ function getDefaultStreaming(version) { return false; } -export default class SubscriptionSchema extends BaseUISchema{ +class SubscriptionSchema extends BaseUISchema{ constructor(fieldOptions={}, node_info={}, initValues={}) { super({ name: undefined, @@ -526,3 +527,5 @@ export default class SubscriptionSchema extends BaseUISchema{ } } +export default registerSchema(SubscriptionSchema); + diff --git a/web/pgadmin/browser/server_groups/servers/directories/static/js/directory.ui.js b/web/pgadmin/browser/server_groups/servers/directories/static/js/directory.ui.js index 0760c620c34..d11571a385c 100644 --- a/web/pgadmin/browser/server_groups/servers/directories/static/js/directory.ui.js +++ b/web/pgadmin/browser/server_groups/servers/directories/static/js/directory.ui.js @@ -9,9 +9,10 @@ import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; import { isEmptyString } from '../../../../../../static/js/validators'; -export default class DirectorySchema extends BaseUISchema { +class DirectorySchema extends BaseUISchema { constructor(getPrivilegeRoleSchema, treeNodeInfo, fieldOptions={}, initValues={}) { super({ diruser: undefined, @@ -84,3 +85,5 @@ export default class DirectorySchema extends BaseUISchema { return false; } } +export default registerSchema(DirectorySchema); + diff --git a/web/pgadmin/browser/server_groups/servers/pgagent/schedules/static/js/pga_schedule.ui.js b/web/pgadmin/browser/server_groups/servers/pgagent/schedules/static/js/pga_schedule.ui.js index e52e4c8f85d..ffd60b680cd 100644 --- a/web/pgadmin/browser/server_groups/servers/pgagent/schedules/static/js/pga_schedule.ui.js +++ b/web/pgadmin/browser/server_groups/servers/pgagent/schedules/static/js/pga_schedule.ui.js @@ -9,6 +9,7 @@ import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; import { isEmptyString } from 'sources/validators'; import moment from 'moment'; import { WEEKDAYS, MONTHS, HOURS, MINUTES, PGAGENT_MONTHDAYS } from '../../../../../../static/js/constants'; @@ -62,7 +63,7 @@ export class ExceptionsSchema extends BaseUISchema { } } -export default class PgaJobScheduleSchema extends BaseUISchema { +class PgaJobScheduleSchema extends BaseUISchema { constructor(fieldOptions={}, initValues={}) { super({ jscid: null, @@ -262,3 +263,5 @@ export default class PgaJobScheduleSchema extends BaseUISchema { } } } +export default registerSchema(PgaJobScheduleSchema); + diff --git a/web/pgadmin/browser/server_groups/servers/pgagent/static/js/pga_job.ui.js b/web/pgadmin/browser/server_groups/servers/pgagent/static/js/pga_job.ui.js index 8e502c5507b..ed4541219eb 100644 --- a/web/pgadmin/browser/server_groups/servers/pgagent/static/js/pga_job.ui.js +++ b/web/pgadmin/browser/server_groups/servers/pgagent/static/js/pga_job.ui.js @@ -9,9 +9,10 @@ import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; import PgaJobScheduleSchema from '../../schedules/static/js/pga_schedule.ui'; -export default class PgaJobSchema extends BaseUISchema { +class PgaJobSchema extends BaseUISchema { constructor(fieldOptions={}, getPgaJobStepSchema=()=>[], initValues={}) { super({ jobname: '', @@ -121,3 +122,5 @@ export default class PgaJobSchema extends BaseUISchema { ]; } } +export default registerSchema(PgaJobSchema); + diff --git a/web/pgadmin/browser/server_groups/servers/pgagent/steps/static/js/pga_jobstep.ui.js b/web/pgadmin/browser/server_groups/servers/pgagent/steps/static/js/pga_jobstep.ui.js index 9148fa23a9b..fe150568ea8 100644 --- a/web/pgadmin/browser/server_groups/servers/pgagent/steps/static/js/pga_jobstep.ui.js +++ b/web/pgadmin/browser/server_groups/servers/pgagent/steps/static/js/pga_jobstep.ui.js @@ -9,6 +9,7 @@ import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; import { getNodeListByName } from '../../../../../../static/js/node_ajax'; import { isEmptyString } from 'sources/validators'; @@ -25,7 +26,7 @@ export function getNodePgaJobStepSchema(treeNodeInfo, itemNodeData) { } ); } -export default class PgaJobStepSchema extends BaseUISchema { +class PgaJobStepSchema extends BaseUISchema { constructor(fieldOptions={}, initValues={}) { super({ jstid: null, @@ -227,3 +228,5 @@ export default class PgaJobStepSchema extends BaseUISchema { } } } +export default registerSchema(PgaJobStepSchema); + diff --git a/web/pgadmin/browser/server_groups/servers/pgd_replication_groups/pgd_replication_servers/static/js/pgd_replication_server_node.ui.js b/web/pgadmin/browser/server_groups/servers/pgd_replication_groups/pgd_replication_servers/static/js/pgd_replication_server_node.ui.js index 07c5a250e34..4f90a45416e 100644 --- a/web/pgadmin/browser/server_groups/servers/pgd_replication_groups/pgd_replication_servers/static/js/pgd_replication_server_node.ui.js +++ b/web/pgadmin/browser/server_groups/servers/pgd_replication_groups/pgd_replication_servers/static/js/pgd_replication_server_node.ui.js @@ -9,8 +9,9 @@ import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; -export default class PgdReplicationServerNodeSchema extends BaseUISchema { +class PgdReplicationServerNodeSchema extends BaseUISchema { get idAttribute() { return 'node_id'; } @@ -38,3 +39,5 @@ export default class PgdReplicationServerNodeSchema extends BaseUISchema { ]; } } +export default registerSchema(PgdReplicationServerNodeSchema); + diff --git a/web/pgadmin/browser/server_groups/servers/pgd_replication_groups/static/js/pgd_replication_group_node.ui.js b/web/pgadmin/browser/server_groups/servers/pgd_replication_groups/static/js/pgd_replication_group_node.ui.js index c654bdf0a6a..6e79758986d 100644 --- a/web/pgadmin/browser/server_groups/servers/pgd_replication_groups/static/js/pgd_replication_group_node.ui.js +++ b/web/pgadmin/browser/server_groups/servers/pgd_replication_groups/static/js/pgd_replication_group_node.ui.js @@ -9,8 +9,9 @@ import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; -export default class PgdReplicationGroupNodeSchema extends BaseUISchema { +class PgdReplicationGroupNodeSchema extends BaseUISchema { get idAttribute() { return 'node_group_id'; } @@ -41,3 +42,5 @@ export default class PgdReplicationGroupNodeSchema extends BaseUISchema { ]; } } +export default registerSchema(PgdReplicationGroupNodeSchema); + diff --git a/web/pgadmin/browser/server_groups/servers/replica_nodes/static/js/replica_node.ui.js b/web/pgadmin/browser/server_groups/servers/replica_nodes/static/js/replica_node.ui.js index fa381cb3b2a..178bafb496e 100644 --- a/web/pgadmin/browser/server_groups/servers/replica_nodes/static/js/replica_node.ui.js +++ b/web/pgadmin/browser/server_groups/servers/replica_nodes/static/js/replica_node.ui.js @@ -9,8 +9,9 @@ import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; -export default class ReplicaNodeSchema extends BaseUISchema { +class ReplicaNodeSchema extends BaseUISchema { get idAttribute() { return 'pid'; } @@ -81,3 +82,5 @@ export default class ReplicaNodeSchema extends BaseUISchema { ]; } } +export default registerSchema(ReplicaNodeSchema); + diff --git a/web/pgadmin/browser/server_groups/servers/resource_groups/static/js/resource_group.ui.js b/web/pgadmin/browser/server_groups/servers/resource_groups/static/js/resource_group.ui.js index cafb9397d08..5aa434459eb 100644 --- a/web/pgadmin/browser/server_groups/servers/resource_groups/static/js/resource_group.ui.js +++ b/web/pgadmin/browser/server_groups/servers/resource_groups/static/js/resource_group.ui.js @@ -9,9 +9,10 @@ import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; import { emptyValidator } from '../../../../../../static/js/validators'; -export default class ResourceGroupSchema extends BaseUISchema { +class ResourceGroupSchema extends BaseUISchema { constructor(initValues) { super({ oid: undefined, @@ -70,3 +71,5 @@ export default class ResourceGroupSchema extends BaseUISchema { return null; } } +export default registerSchema(ResourceGroupSchema); + diff --git a/web/pgadmin/browser/server_groups/servers/roles/static/js/role.ui.js b/web/pgadmin/browser/server_groups/servers/roles/static/js/role.ui.js index 68ff085dacd..8423185da98 100644 --- a/web/pgadmin/browser/server_groups/servers/roles/static/js/role.ui.js +++ b/web/pgadmin/browser/server_groups/servers/roles/static/js/role.ui.js @@ -9,10 +9,11 @@ import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; import SecLabelSchema from '../../../static/js/sec_label.ui'; -export default class RoleSchema extends BaseUISchema { +class RoleSchema extends BaseUISchema { constructor(getVariableSchema, getMembershipSchema,fieldOptions={}) { super({ oid: null, @@ -230,3 +231,5 @@ export default class RoleSchema extends BaseUISchema { ]; } } +export default registerSchema(RoleSchema); + diff --git a/web/pgadmin/browser/server_groups/servers/roles/static/js/roleReassign.js b/web/pgadmin/browser/server_groups/servers/roles/static/js/roleReassign.js index 2273dd68e1d..29de6703eca 100644 --- a/web/pgadmin/browser/server_groups/servers/roles/static/js/roleReassign.js +++ b/web/pgadmin/browser/server_groups/servers/roles/static/js/roleReassign.js @@ -9,13 +9,14 @@ import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; import url_for from 'sources/url_for'; import { getNodeListByName, generateNodeUrl } from '../../../../../static/js/node_ajax'; import pgBrowser from 'top/browser/static/js/browser'; import { isEmptyString } from 'sources/validators'; import pgAdmin from 'sources/pgadmin'; -export default class RoleReassign extends BaseUISchema{ +class RoleReassign extends BaseUISchema{ constructor(fieldOptions={}, initValues={}){ super({ role_op: 'reassign', @@ -177,6 +178,8 @@ export default class RoleReassign extends BaseUISchema{ return false; } } +export default registerSchema(RoleReassign); + function getUISchema(treeNodeInfo, itemNodeData ) { return new RoleReassign( diff --git a/web/pgadmin/browser/server_groups/servers/static/js/membership.ui.js b/web/pgadmin/browser/server_groups/servers/static/js/membership.ui.js index aad9f4fdea9..9edf1b16fbb 100644 --- a/web/pgadmin/browser/server_groups/servers/static/js/membership.ui.js +++ b/web/pgadmin/browser/server_groups/servers/static/js/membership.ui.js @@ -9,6 +9,7 @@ import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; import { getNodeListByName } from '../../../../static/js/node_ajax'; @@ -20,7 +21,7 @@ export function getMembershipSchema(nodeObj, treeNodeInfo, itemNodeData) { } -export default class MembershipSchema extends BaseUISchema { +class MembershipSchema extends BaseUISchema { constructor(roleMembersOptions, node_info={}) { super({ role: undefined, @@ -84,3 +85,5 @@ export default class MembershipSchema extends BaseUISchema { } +export default registerSchema(MembershipSchema); + diff --git a/web/pgadmin/browser/server_groups/servers/static/js/options.ui.js b/web/pgadmin/browser/server_groups/servers/static/js/options.ui.js index 39bd95ba067..34bdde8478a 100644 --- a/web/pgadmin/browser/server_groups/servers/static/js/options.ui.js +++ b/web/pgadmin/browser/server_groups/servers/static/js/options.ui.js @@ -9,8 +9,9 @@ import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; -export default class OptionsSchema extends BaseUISchema { +class OptionsSchema extends BaseUISchema { constructor(optionID='option', valueID='value') { super({ [optionID]: undefined, @@ -33,3 +34,5 @@ export default class OptionsSchema extends BaseUISchema { }]; } } +export default registerSchema(OptionsSchema); + diff --git a/web/pgadmin/browser/server_groups/servers/static/js/privilege.ui.js b/web/pgadmin/browser/server_groups/servers/static/js/privilege.ui.js index 534a6771389..1ff96b7603e 100644 --- a/web/pgadmin/browser/server_groups/servers/static/js/privilege.ui.js +++ b/web/pgadmin/browser/server_groups/servers/static/js/privilege.ui.js @@ -9,6 +9,7 @@ import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; import { getNodeListByName } from '../../../../static/js/node_ajax'; export function getNodePrivilegeRoleSchema(nodeObj, treeNodeInfo, itemNodeData, privileges) { @@ -23,7 +24,7 @@ export function getNodePrivilegeRoleSchema(nodeObj, treeNodeInfo, itemNodeData, ); } -export default class PrivilegeRoleSchema extends BaseUISchema { +class PrivilegeRoleSchema extends BaseUISchema { constructor(granteeOptions, grantorOptions, nodeInfo, supportedPrivs) { super({ grantee: undefined, @@ -86,3 +87,5 @@ export default class PrivilegeRoleSchema extends BaseUISchema { return false; } } +export default registerSchema(PrivilegeRoleSchema); + diff --git a/web/pgadmin/browser/server_groups/servers/static/js/sec_label.ui.js b/web/pgadmin/browser/server_groups/servers/static/js/sec_label.ui.js index ea6d209004d..97eaa22a3b4 100644 --- a/web/pgadmin/browser/server_groups/servers/static/js/sec_label.ui.js +++ b/web/pgadmin/browser/server_groups/servers/static/js/sec_label.ui.js @@ -9,8 +9,9 @@ import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; -export default class SecLabelSchema extends BaseUISchema { +class SecLabelSchema extends BaseUISchema { constructor() { super({ provider: undefined, @@ -29,3 +30,5 @@ export default class SecLabelSchema extends BaseUISchema { }]; } } +export default registerSchema(SecLabelSchema); + diff --git a/web/pgadmin/browser/server_groups/servers/static/js/server.ui.js b/web/pgadmin/browser/server_groups/servers/static/js/server.ui.js index 1a0e38e1d91..85c96b7ec8e 100644 --- a/web/pgadmin/browser/server_groups/servers/static/js/server.ui.js +++ b/web/pgadmin/browser/server_groups/servers/static/js/server.ui.js @@ -10,6 +10,7 @@ import gettext from 'sources/gettext'; import _ from 'lodash'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; import pgAdmin from 'sources/pgadmin'; import {default as supportedServers} from 'pgadmin.server.supported_servers'; import current_user from 'pgadmin.user_management.current_user'; @@ -174,7 +175,7 @@ export function getConnectionParameters() { return conParams; }; -export default class ServerSchema extends BaseUISchema { +class ServerSchema extends BaseUISchema { constructor(serverGroupOptions=[], userId=0, initValues={}) { super({ gid: undefined, @@ -698,3 +699,5 @@ export default class ServerSchema extends BaseUISchema { return false; } } +export default registerSchema(ServerSchema); + diff --git a/web/pgadmin/browser/server_groups/servers/static/js/vacuum.ui.js b/web/pgadmin/browser/server_groups/servers/static/js/vacuum.ui.js index 093353a1dce..6e5e8f42824 100644 --- a/web/pgadmin/browser/server_groups/servers/static/js/vacuum.ui.js +++ b/web/pgadmin/browser/server_groups/servers/static/js/vacuum.ui.js @@ -1,5 +1,6 @@ import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; import { getNodeAjaxOptions } from '../../../../static/js/node_ajax'; export function getNodeVacuumSettingsSchema(nodeObj, treeNodeInfo, itemNodeData) { @@ -46,7 +47,7 @@ export class VacuumTableSchema extends BaseUISchema { } } -export default class VacuumSettingsSchema extends BaseUISchema { +class VacuumSettingsSchema extends BaseUISchema { constructor(tableVars, toastTableVars, nodeInfo) { super({ vacuum_table: [], @@ -153,3 +154,5 @@ export default class VacuumSettingsSchema extends BaseUISchema { }]; } } +export default registerSchema(VacuumSettingsSchema); + diff --git a/web/pgadmin/browser/server_groups/servers/static/js/variable.ui.js b/web/pgadmin/browser/server_groups/servers/static/js/variable.ui.js index 84f1a665909..25cdf8de87e 100644 --- a/web/pgadmin/browser/server_groups/servers/static/js/variable.ui.js +++ b/web/pgadmin/browser/server_groups/servers/static/js/variable.ui.js @@ -10,6 +10,7 @@ import gettext from 'sources/gettext'; import _ from 'lodash'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; import { getNodeAjaxOptions, getNodeListByName } from '../../../../static/js/node_ajax'; import { isEmptyString } from '../../../../../static/js/validators'; @@ -44,7 +45,7 @@ export function getNodeVariableSchema(nodeObj, treeNodeInfo, itemNodeData, hasDa ); } -export default class VariableSchema extends BaseUISchema { +class VariableSchema extends BaseUISchema { constructor(vnameOptions, databaseOptions, roleOptions, keys) { super({ name: undefined, @@ -232,3 +233,5 @@ export default class VariableSchema extends BaseUISchema { } } +export default registerSchema(VariableSchema); + diff --git a/web/pgadmin/browser/server_groups/servers/tablespaces/static/js/tablespace.ui.js b/web/pgadmin/browser/server_groups/servers/tablespaces/static/js/tablespace.ui.js index bb1b9737c44..8e34863cdfd 100644 --- a/web/pgadmin/browser/server_groups/servers/tablespaces/static/js/tablespace.ui.js +++ b/web/pgadmin/browser/server_groups/servers/tablespaces/static/js/tablespace.ui.js @@ -9,10 +9,11 @@ import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; import SecLabelSchema from '../../../static/js/sec_label.ui'; import { isEmptyString } from '../../../../../../static/js/validators'; -export default class TablespaceSchema extends BaseUISchema { +class TablespaceSchema extends BaseUISchema { constructor(getVariableSchema, getPrivilegeRoleSchema, fieldOptions={}, initValues={}) { super({ name: undefined, @@ -100,3 +101,5 @@ export default class TablespaceSchema extends BaseUISchema { return null; } } +export default registerSchema(TablespaceSchema); + diff --git a/web/pgadmin/browser/server_groups/static/js/server_group.ui.js b/web/pgadmin/browser/server_groups/static/js/server_group.ui.js index 694510d6d60..f8e5b7687a6 100644 --- a/web/pgadmin/browser/server_groups/static/js/server_group.ui.js +++ b/web/pgadmin/browser/server_groups/static/js/server_group.ui.js @@ -9,9 +9,10 @@ import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; -export default class ServerGroupSchema extends BaseUISchema { +class ServerGroupSchema extends BaseUISchema { constructor() { super({ id: undefined, @@ -33,3 +34,5 @@ export default class ServerGroupSchema extends BaseUISchema { ]; } } +export default registerSchema(ServerGroupSchema); + diff --git a/web/pgadmin/dashboard/static/js/ActiveQuery.ui.js b/web/pgadmin/dashboard/static/js/ActiveQuery.ui.js index b256a420cd8..2322119a9b5 100644 --- a/web/pgadmin/dashboard/static/js/ActiveQuery.ui.js +++ b/web/pgadmin/dashboard/static/js/ActiveQuery.ui.js @@ -9,8 +9,9 @@ import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; -export default class ActiveQuery extends BaseUISchema { +class ActiveQuery extends BaseUISchema { constructor(initValues) { super({ ...initValues, @@ -61,3 +62,5 @@ export default class ActiveQuery extends BaseUISchema { } } +export default registerSchema(ActiveQuery); + diff --git a/web/pgadmin/dashboard/static/js/Replication/schema_ui/pgd_incoming.ui.js b/web/pgadmin/dashboard/static/js/Replication/schema_ui/pgd_incoming.ui.js index 3f88cbad5e9..90e2eeca205 100644 --- a/web/pgadmin/dashboard/static/js/Replication/schema_ui/pgd_incoming.ui.js +++ b/web/pgadmin/dashboard/static/js/Replication/schema_ui/pgd_incoming.ui.js @@ -9,8 +9,9 @@ import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; -export default class PGDIncomingSchema extends BaseUISchema { +class PGDIncomingSchema extends BaseUISchema { constructor(initValues) { super({ ...initValues, @@ -66,3 +67,5 @@ export default class PGDIncomingSchema extends BaseUISchema { ]; } } +export default registerSchema(PGDIncomingSchema); + diff --git a/web/pgadmin/dashboard/static/js/Replication/schema_ui/pgd_outgoing.ui.js b/web/pgadmin/dashboard/static/js/Replication/schema_ui/pgd_outgoing.ui.js index dce9ab83a38..0cf1091a7e5 100644 --- a/web/pgadmin/dashboard/static/js/Replication/schema_ui/pgd_outgoing.ui.js +++ b/web/pgadmin/dashboard/static/js/Replication/schema_ui/pgd_outgoing.ui.js @@ -9,8 +9,9 @@ import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; -export default class PGDOutgoingSchema extends BaseUISchema { +class PGDOutgoingSchema extends BaseUISchema { constructor(initValues) { super({ ...initValues, @@ -86,3 +87,5 @@ export default class PGDOutgoingSchema extends BaseUISchema { ]; } } +export default registerSchema(PGDOutgoingSchema); + diff --git a/web/pgadmin/dashboard/static/js/Replication/schema_ui/replication_slots.ui.js b/web/pgadmin/dashboard/static/js/Replication/schema_ui/replication_slots.ui.js index bc37fe6fb62..6bc739ff479 100644 --- a/web/pgadmin/dashboard/static/js/Replication/schema_ui/replication_slots.ui.js +++ b/web/pgadmin/dashboard/static/js/Replication/schema_ui/replication_slots.ui.js @@ -9,8 +9,9 @@ import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; -export default class ReplicationSlotsSchema extends BaseUISchema { +class ReplicationSlotsSchema extends BaseUISchema { constructor(initValues) { super({ ...initValues, @@ -50,3 +51,5 @@ export default class ReplicationSlotsSchema extends BaseUISchema { ]; } } +export default registerSchema(ReplicationSlotsSchema); + diff --git a/web/pgadmin/dashboard/static/js/Replication/schema_ui/replication_stats.ui.js b/web/pgadmin/dashboard/static/js/Replication/schema_ui/replication_stats.ui.js index 376c4783844..bb8f3363771 100644 --- a/web/pgadmin/dashboard/static/js/Replication/schema_ui/replication_stats.ui.js +++ b/web/pgadmin/dashboard/static/js/Replication/schema_ui/replication_stats.ui.js @@ -9,8 +9,9 @@ import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; -export default class ReplicationStatsSchema extends BaseUISchema { +class ReplicationStatsSchema extends BaseUISchema { constructor(initValues) { super({ ...initValues, @@ -78,3 +79,5 @@ export default class ReplicationStatsSchema extends BaseUISchema { ]; } } +export default registerSchema(ReplicationStatsSchema); + diff --git a/web/pgadmin/dashboard/static/js/ServerLog.ui.js b/web/pgadmin/dashboard/static/js/ServerLog.ui.js index 92570e0f6fd..4a9a44645a3 100644 --- a/web/pgadmin/dashboard/static/js/ServerLog.ui.js +++ b/web/pgadmin/dashboard/static/js/ServerLog.ui.js @@ -9,8 +9,9 @@ import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; -export default class ServerLog extends BaseUISchema { +class ServerLog extends BaseUISchema { constructor(initValues) { super({ ...initValues, @@ -49,3 +50,5 @@ export default class ServerLog extends BaseUISchema { } } +export default registerSchema(ServerLog); + diff --git a/web/pgadmin/misc/cloud/static/js/azure_schema.ui.js b/web/pgadmin/misc/cloud/static/js/azure_schema.ui.js index 89f79733121..c500d5dade7 100644 --- a/web/pgadmin/misc/cloud/static/js/azure_schema.ui.js +++ b/web/pgadmin/misc/cloud/static/js/azure_schema.ui.js @@ -142,21 +142,43 @@ class AzureCredSchema extends BaseUISchema { type: '', deps:['auth_btn'], deferredDepChange: (state, source)=>{ - return new Promise((resolve, reject)=>{ - if(source == 'auth_btn' && state.auth_type == 'interactive_browser_credential' && state.is_authenticating ) { - obj.fieldOptions.getAuthCode() - .then((res)=>{ - resolve(()=>{ - return { - is_authenticating: false, - auth_code: res.data.data.user_code, - }; - }); - }) - .catch((err)=>{ - reject(err instanceof Error ? err : Error(gettext('Something went wrong'))); - }); - } + // Opt out of the queue cleanly when the trigger doesn't match + // — returning undefined skips this listener entirely. The + // previous version returned a Promise that never resolved in + // this branch, leaving a permanent orphan in data.__deferred__. + // + // `source` is the path of the field that changed (an array). + // Compare against the last segment so the guard works for both + // a top-level field path (['auth_btn']) and a nested + // embedding (['some_parent', 'auth_btn']). + const trigger = Array.isArray(source) ? source[source.length - 1] : source; + if (trigger !== 'auth_btn' + || state.auth_type !== 'interactive_browser_credential' + || !state.is_authenticating) { + return undefined; + } + return new Promise((resolve)=>{ + obj.fieldOptions.getAuthCode() + .then((res)=>{ + resolve(()=>({ + is_authenticating: false, + auth_code: res.data.data.user_code, + })); + }) + .catch((err)=>{ + // Surface the failure to the user AND reset both + // is_authenticating (so the UI unblocks) and any stale + // auth_code from a prior successful attempt (otherwise + // the user sees "still authenticated" UI alongside the + // failure toast, which is misleading). + const msg = err?.response?.data?.errormsg + || err?.message + || gettext('Something went wrong'); + pgAdmin.Browser.notifier.error( + gettext('Azure authentication failed: ') + msg + ); + resolve(()=>({ is_authenticating: false, auth_code: null })); + }); }); }, }, diff --git a/web/pgadmin/preferences/static/js/components/binary_path.ui.js b/web/pgadmin/preferences/static/js/components/binary_path.ui.js index 1adfe6e2887..7cacc28e39c 100644 --- a/web/pgadmin/preferences/static/js/components/binary_path.ui.js +++ b/web/pgadmin/preferences/static/js/components/binary_path.ui.js @@ -11,6 +11,7 @@ import gettext from 'sources/gettext'; import _ from 'lodash'; import url_for from 'sources/url_for'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; import getApiInstance from '../../../../static/js/api_instance'; import pgAdmin from 'sources/pgadmin'; @@ -19,7 +20,7 @@ export function getBinaryPathSchema() { return new BinaryPathSchema(); } -export default class BinaryPathSchema extends BaseUISchema { +class BinaryPathSchema extends BaseUISchema { constructor() { super({ isDefault: false, @@ -80,3 +81,5 @@ export default class BinaryPathSchema extends BaseUISchema { ]; } } +export default registerSchema(BinaryPathSchema); + diff --git a/web/pgadmin/preferences/static/js/components/preferences.ui.js b/web/pgadmin/preferences/static/js/components/preferences.ui.js index 3fafd329444..0019af9c826 100644 --- a/web/pgadmin/preferences/static/js/components/preferences.ui.js +++ b/web/pgadmin/preferences/static/js/components/preferences.ui.js @@ -8,8 +8,9 @@ ////////////////////////////////////////////////////////////// import { BaseUISchema } from '../../../../static/js/SchemaView'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; -export default class PreferencesSchema extends BaseUISchema { +class PreferencesSchema extends BaseUISchema { constructor(initValues = {}, schemaFields = []) { super({ ...initValues @@ -30,3 +31,5 @@ export default class PreferencesSchema extends BaseUISchema { return this.schemaFields; } } +export default registerSchema(PreferencesSchema); + diff --git a/web/pgadmin/static/js/SchemaView/DataGridView/features/feature.js b/web/pgadmin/static/js/SchemaView/DataGridView/features/feature.js index 5c06ea42c3d..2bb34999ebc 100644 --- a/web/pgadmin/static/js/SchemaView/DataGridView/features/feature.js +++ b/web/pgadmin/static/js/SchemaView/DataGridView/features/feature.js @@ -67,7 +67,7 @@ function addToSortedList(_list, _item, _comparator = (a, b) => (a < b)) { _list.splice(idx, 0, _item); } -const featurePriorityCompare = (f1, f2) => (f1.priorty < f2.priority); +const featurePriorityCompare = (f1, f2) => (f1.priority < f2.priority); export function register(cls) { diff --git a/web/pgadmin/static/js/SchemaView/DataGridView/mappedCell.jsx b/web/pgadmin/static/js/SchemaView/DataGridView/mappedCell.jsx index 3966545d137..124608478a8 100644 --- a/web/pgadmin/static/js/SchemaView/DataGridView/mappedCell.jsx +++ b/web/pgadmin/static/js/SchemaView/DataGridView/mappedCell.jsx @@ -15,7 +15,6 @@ import { evalFunc } from 'sources/utils'; import { MappedCellControl } from '../MappedControl'; import { SCHEMA_STATE_ACTIONS, SchemaStateContext } from '../SchemaState'; -import { flatternObject } from '../common'; import { useFieldOptions, useFieldValue, useSchemaStateSubscriber } from '../hooks'; @@ -38,7 +37,15 @@ export function getMappedCell({field}) { colAccessPath, schemaState, subscriberManager ); let value = useFieldValue(colAccessPath, schemaState, subscriberManager); - let rowValue = useFieldValue(rowAccessPath, schemaState); + // The whole-row value is only consulted when `field.cell` is a + // function (passed to evalFunc below) or when the field has no id + // (the error branch swaps rowValue for row.original). For the common + // case — string `field.cell` with a valid id — we don't need to read + // it at all. Skipping the hook removes ~one _.get(data, rowAccessPath) + // per cell per render. + let rowValue = (_.isFunction(field.cell) && field.id) + ? schemaState.value(rowAccessPath) + : undefined; const rerenderCellOnDepChange = (...args) => { subscriberManager.current?.signal(...args); }; @@ -97,9 +104,23 @@ export function getMappedCell({field}) { props.cell = 'unknown'; } + // useMemo deps used to be `...flatternObject(colOptions)` — a recursive + // walk + sort over the full options object on every render. The options + // that actually drive cell rendering are a fixed, small set (the four + // registered dynamic options below), so list them explicitly. Anything + // else a cell needs to react to should come through `depVals` via + // `field.deps`. return useMemo( () => , - [...(depVals || []), ...flatternObject(colOptions), value, row.index] + [ + ...(depVals || []), + colOptions.disabled, + colOptions.visible, + colOptions.readonly, + colOptions.editable, + value, + row.index, + ] ); }; diff --git a/web/pgadmin/static/js/SchemaView/DataGridView/row.jsx b/web/pgadmin/static/js/SchemaView/DataGridView/row.jsx index 27b24ede15e..48a402f9522 100644 --- a/web/pgadmin/static/js/SchemaView/DataGridView/row.jsx +++ b/web/pgadmin/static/js/SchemaView/DataGridView/row.jsx @@ -58,7 +58,7 @@ export function DataGridRow({row, isResizing}) { diff --git a/web/pgadmin/static/js/SchemaView/DepListener.js b/web/pgadmin/static/js/SchemaView/DepListener.js index 9c271462269..2bd9d0ec1e7 100644 --- a/web/pgadmin/static/js/SchemaView/DepListener.js +++ b/web/pgadmin/static/js/SchemaView/DepListener.js @@ -8,26 +8,49 @@ ////////////////////////////////////////////////////////////// import _ from 'lodash'; +// Join a path array the same way the filter predicates need to compare: +// segments separated by '|', terminated with an extra '|' so a listener +// registered on ['shared'] doesn't false-match a currPath of +// ['shared_username']. Equivalent to `_.join(arr.concat(['']), '|')` but +// avoids the array allocation that was visible in the hot path. +const _joinPath = (arr) => arr.join('|') + '|'; export class DepListener { constructor() { this._depListeners = []; + // True iff at least one registered listener has a defCallback. Lets + // getDeferredDepChange short-circuit when there's no deferred work + // possible — common in synthetic schemas and the dominant case in + // typical dialogs. + this._hasDefCallback = false; } /* Will keep track of the dependent fields and there callbacks */ addDepListener(source, dest, callback, defCallback) { this._depListeners = this._depListeners || []; + // Defensive shallow copy of the source path. The cached _sourceKey + // is already a string snapshot and isn't affected by post- + // registration mutation, but `source` itself is passed to the + // callback at dispatch time — a caller that re-uses and mutates + // the array would silently corrupt later callback invocations. + const sourceCopy = Array.from(source); this._depListeners.push({ - source: source, + source: sourceCopy, dest: dest, callback: callback, - defCallback: defCallback + defCallback: defCallback, + // Pre-compute the source's joined form so the per-dispatch filters + // in getDepChange / getDeferredDepChange don't re-join + re-allocate + // for every listener on every keystroke. + _sourceKey: _joinPath(sourceCopy), }); + if (defCallback) this._hasDefCallback = true; } removeDepListener(dest) { this._depListeners = _.filter(this._depListeners, (l)=>!_.join(l.dest, '|').startsWith(_.join(dest, '|'))); + this._hasDefCallback = this._depListeners.some((l) => l.defCallback); } _getListenerData(state, listener, actionObj) { @@ -59,37 +82,36 @@ export class DepListener { /* If this comes from deferred change */ if(actionObj.listener?.callback) { state = this._getListenerData(state, actionObj.listener, actionObj); - } else { - // adding a extra item in path to avoid incorrect matching like shared and shared_username - let allListeners = _.filter(this._depListeners, (entry)=>_.join(currPath.concat(['']), '|').startsWith(_.join(entry.source.concat(['']), '|'))); - if(allListeners) { - for(const listener of allListeners) { - state = this._getListenerData(state, listener, actionObj); - } + return state; + } + // Compare against each listener using the pre-computed _sourceKey, + // which already encodes the trailing-'|' prefix-match protection. + const currKey = _joinPath(currPath); + for(const listener of this._depListeners) { + if(listener.callback && currKey.startsWith(listener._sourceKey)) { + state = this._getListenerData(state, listener, actionObj); } } return state; } getDeferredDepChange(currPath, state, actionObj) { - let deferredList = []; - let allListeners = _.filter(this._depListeners, (entry) => _.join( - currPath, '|' - ).startsWith(_.join(entry.source, '|'))); - - if(allListeners) { - for(const listener of allListeners) { - if(listener.defCallback) { - let thePromise = this._getDefListenerPromise(state, listener, actionObj); - if(thePromise) { - deferredList.push({ - action: actionObj, - promise: thePromise, - listener: listener, - }); - } - } + // Common case: nothing in the registry has a defCallback. Bail + // before touching any listener entries. + if(!this._hasDefCallback) return []; + const deferredList = []; + const currKey = _joinPath(currPath); + for(const listener of this._depListeners) { + if(!listener.defCallback) continue; + if(!currKey.startsWith(listener._sourceKey)) continue; + const thePromise = this._getDefListenerPromise(state, listener, actionObj); + if(thePromise) { + deferredList.push({ + action: actionObj, + promise: thePromise, + listener: listener, + }); } } return deferredList; diff --git a/web/pgadmin/static/js/SchemaView/README.md b/web/pgadmin/static/js/SchemaView/README.md new file mode 100644 index 00000000000..d9bcb8ce34d --- /dev/null +++ b/web/pgadmin/static/js/SchemaView/README.md @@ -0,0 +1,274 @@ +# SchemaView — developer guide + +## What this module does + +`SchemaView` is the framework behind every property/edit dialog in +pgAdmin. You hand it a `BaseUISchema` subclass; it renders a form, +runs validation on every keystroke, evaluates per-field options +(`disabled`, `visible`, `readonly`, `editable`) on every keystroke, +and dispatches user input back as immutable state mutations. + +The framework's two performance-critical walkers are: + +| Walker | What it computes | Run on every dispatch | +|---|---|---| +| **schemaOptionsEvalulator** (`options/registry.js`) | per-field options tree | yes | +| **validateSchema** (`SchemaState/common.js`) | per-field error map | yes | + +Both can run in either of two modes: + +- **Full walk** — recurses every field in every row in every + collection. Always correct. O(total fields). +- **Incremental walk** — prunes rows whose subtree the current + dispatch cannot affect. Falls back to keeping previous options for + pruned rows via structural sharing. O(visited fields). + +Incremental is **on by default** for every schema (the opt-out flag +is `incrementalOptions: false` on the schema instance, used by tests). + +## When the walker is correct vs. when it's not + +Incremental prunes a row when no path in `mustVisit` overlaps the +row's globalPath. `mustVisit` is the union of: + +1. `changedPath` — the path of the action being dispatched. +2. Every additional path batched into the same React render commit + (collected via `__pendingChangedPaths` in + `useSchemaState.sessDispatchWithListener`). +3. Every `field.deps` declaration's dest path whose source overlaps + any of the above. +4. Every path that has ever reported an error during this dialog's + lifetime (`_knownErrorPaths` in `SchemaState`, bounded LRU). + +**Cross-row reads that are NOT declared as `field.deps` are the only +correctness hazard.** A `disabled` / `editable` / `visible` / +`readonly` / `validate` closure that does: + +```js +editable(state) { + return obj.top.sessData.someOtherCollection.some((r) => r.foo); +} +``` + +…will produce stale results when the user mutates +`someOtherCollection` because the closure's row is pruned. + +### How to fix a cross-row read + +Add the source as an explicit `deps`: + +```js +{ + id: 'name', type: 'text', + deps: [['someOtherCollection']], // double-array = absolute path + editable: function(state) { ... }, +} +``` + +A `dep` entry is either: + +- A **string** `'sibling_field_id'` — resolved relative to the + current schema level (the closure's own row, then the row's + parent collection). +- An **array** `['absolute', 'path', 'segments']` — resolved as an + absolute path from the top of the schema. + +`listenDepChanges` (see `utils/listenDepChanges.js`) registers EVERY +declared `dep` as a `DepListener` entry — even when the field has no +`depChange` callback — so the walker's +`_collectDepDestsForPath` can resolve them into `mustVisit`. If you +forget the dep, the canary will tell you (see next section). + +## The canary + +The walker can run in two modes side-by-side and diff their +output. This is a build-time-gated debug feature. + +- **In canary builds** (`CANARY_BUILD=true yarn run bundle`): every + walker invocation runs BOTH the incremental and the full walk, + diffs the resulting options/errors trees, and: + - in tests with `window.__throw_on_canary_divergence__ = true`, + throws (fails the test loudly with the diff), + - in browser dev, logs to `console.error` with the divergence + paths, + - in production canary, ships to `window.__incremental_canary_endpoint__` + if configured (currently not wired). + +- **In normal production builds** (no `CANARY_BUILD` env var): + webpack's DefinePlugin substitutes + `process.env.__CANARY_BUILD__` with the literal `false`, and the + entire canary branch + its `require('./canary')` is dead-code + eliminated. `scripts/verify-canary-treeshake.sh` asserts this. + +## Tests that run against the canary + +Three layers: + +| Test | What it covers | Where | +|---|---|---| +| **registered_schemas_audit.spec.js** | Every registered schema (87) × 2 modes (create + edit) × scalar / cell / structure / batched dispatches. Driven by `auditSchema()`. | `regression/javascript/SchemaView/` | +| **audit_harness.spec.js** | Unit-level tests for the audit harness itself + synthetic schemas that intentionally diverge. | same | +| **audit-smoke.spec.js** (Playwright) | Register Server / Create Table / Create Function dialogs in a real browser. | `regression/perf-bench/` | + +To run them: + +```bash +# Jest (auto-injects __CANARY_BUILD__=true via setup-jest.js): +cd web && yarn run test:js-once --testPathPattern=registered_schemas_audit + +# Playwright (needs canary build + running pgAdmin): +cd web && CANARY_BUILD=true ./node_modules/.bin/webpack --config webpack.config.js +python pgAdmin4.py & +cd regression/perf-bench && ./node_modules/.bin/playwright test audit-smoke +``` + +## Adding a new schema + +1. Subclass `BaseUISchema`, define `baseFields`. +2. Export via `registerSchema(YourSchema)` — an ESLint rule will + error if you forget. This puts the class into the registry that + `registered_schemas_audit.spec.js` iterates over. +3. If your schema needs constructor args that the audit can't + synthesize, add an entry to `PER_SCHEMA_FIXTURES` in + `SchemaState/audit_harness.js`. Without it the audit reports + SKIP and your schema gets no synthetic coverage. +4. If your schema reads sibling state in any closure, declare the + source as `field.deps`. Run the audit (`yarn run test:js-once + --testPathPattern=registered_schemas_audit`) — divergences mean + you have a missing `deps`. + +## Dispatching schema changes + +The ONLY supported way to dispatch from React event handlers is via +the `dataDispatch` returned by `useSchemaState`. That function is +`sessDispatchWithListener` under the hood; it: + +- stamps every action with `__viaListener: true` (a sentinel the + reducer checks under canary builds), +- pushes `action.path` into `state.__pendingChangedPaths` so the + next `validate()` cycle knows what changed, +- forwards to React's reducer. + +Direct calls to `sessDispatch({ type: SET_VALUE, path: [...] })` +**bypass** the accumulator. The reducer's bypass guard +(`reducer.js`, canary-only) calls `console.error` if you do this, +which fails CI through setup-jest's afterEach assertion. + +### Action types + +The reducer recognizes the following action types +(`SCHEMA_STATE_ACTIONS` in `common.js`): + +| Type | Path-bearing? | Bypass-guarded? | Notes | +|---|---|---|---| +| `INIT` | no | no | resets sessData; safe to dispatch directly | +| `SET_VALUE` | yes | **yes** | scalar / cell mutations | +| `ADD_ROW` | yes | **yes** | collection append/prepend | +| `DELETE_ROW` | yes | **yes** | collection splice | +| `MOVE_ROW` | yes | **yes** | DataGridView drag-reorder | +| `BULK_UPDATE` | yes | **yes** | clear a column across all rows | +| `DEFERRED_DEPCHANGE` | yes | **yes** | drainDeferredQueue must use `sessDispatchWithListener` | +| `CLEAR_DEFERRED_QUEUE` | no | no | internal plumbing | +| `RERENDER` | unused | reserved | declared in the enum but no reducer case + no production dispatch site. If you start using it, add it to `PATH_BEARING_ACTIONS` in `reducer.js`. | + +## Reading canary divergence output + +When the canary detects a mismatch between the full and incremental +walks, it throws (in tests) or logs to `console.error` (in canary +dev builds). The diff shape: + +``` +Incremental walker divergence in TableSchema: + vacuum_table.0 — incremental=undefined full={"canEditRow":true,"canDeleteRow":true} + vacuum_table.0.label — incremental=undefined full={"disabled":false,"visible":true,"readonly":false,"editable":true} + vacuum_table.0.setting — incremental=undefined full={"disabled":false,"visible":true,"readonly":false,"editable":true} + vacuum_table.0.value — incremental=undefined full={"disabled":false,"visible":true,"readonly":false,"editable":false} + vacuum_table.1 — incremental=undefined full={...} + ... +``` + +How to read this: + +- **`incremental=X full=Y`**: the incremental walker returned `X` + for this path, the full walk returned `Y`. They differ; the full + walk is the source of truth. +- **`incremental=undefined`**: the incremental walker DIDN'T VISIT + this path — it inherited a `prev[path]` that was undefined. Most + common cause: the row was just created (via ADD_ROW or fixedRows + resolution) and the prev snapshot doesn't include it, AND the + current changedPath doesn't overlap the row's globalPath, so the + walker pruned it. +- **Many rows at the same level (e.g. `vacuum_table.0` through + `vacuum_table.N`) all `incremental=undefined`**: structural + divergence — the entire collection grew between prev and current + but the current dispatch's `mustVisit` doesn't reach that + collection. Look for sibling fixedRows / depChange / async data + fetch that resolves in the SAME React commit as the current + dispatch. + +Three production root-cause patterns surfaced by the canary so far, +all fixed: + +1. **Evaluator-only deps not registering listeners** + (`utils/listenDepChanges.js`). A field declared + `deps: [['sibling']]` but no `depChange` callback registered NO + listener — the walker couldn't tell which fields should ride + `mustVisit` when `sibling` changed. + +2. **Batched changedPath dropped** + (`hooks/useSchemaState.js`, `SchemaState/SchemaState.js`). + `state.__lastChangedPath` was a single scalar; React batching + overwrote the first dispatch's path with the second's. Fixed + via `__pendingChangedPaths` accumulator. + +3. **Drain bypassed the listener wrapper** + (`hooks/useSchemaState.js`). `drainDeferredQueue` dispatched raw + `sessDispatch`, dropping the path from the accumulator AND + tripping the bypass guard. + +When you see a new divergence, your first hypothesis should be one +of these three patterns. If none fits, check: + +- Does the diverging field's closure read a sibling without + declaring it as `field.deps`? +- Does the dispatch chain that produced the diverging state run + through `sessDispatchWithListener`? +- Is the schema instance being reused across dialog lifecycles + (carrying stale `top` / `state` references)? + +## Common pitfalls + +- **Async fixedRows on sibling collections**: if two collections in + the same schema both call `fixedRows: () => Promise<...>`, the + promises may resolve in the same microtask tick. React batches + the two `setUnpreparedData` dispatches; the accumulator catches + both paths. If you bypass the accumulator (see above) the second + collection's rows go stale. +- **Custom dispatches in feature classes**: any new `DataGridView` + feature that dispatches must use `dataDispatch`, not the raw + `dispatch` it sees in context. +- **Deferred dep changes**: must return a Promise that always + resolves (with a `(tmpstate) => deltaObj` callback) or returns + `undefined`. A Promise that never resolves leaks into + `data.__deferred__`. See the `listenDepChanges` JSDoc. + +## File map + +``` +SchemaView/ + base_schema.ui.js - BaseUISchema (extend this) + hooks/useSchemaState.js - the dispatch entry point + SchemaState/ + SchemaState.js - validate(), updateOptions(), DepListener integration + common.js - validateSchema, action types + reducer.js - sessDataReducer (bypass guard lives here) + audit_harness.js - the synthetic dispatch runner + schema_registry.js - registerSchema / getRegisteredSchemas + validation_canary.js - error-map diff canary + options/ + registry.js - schemaOptionsEvalulator + canary.js - options-tree diff canary + utils/listenDepChanges.js - field.deps → DepListener registry wiring + DataGridView/ + features/ - DataGrid extensions (fixedRows, reorder, ...) +``` diff --git a/web/pgadmin/static/js/SchemaView/SchemaState/SchemaState.js b/web/pgadmin/static/js/SchemaView/SchemaState/SchemaState.js index 0ed2d4e899a..147883c16c6 100644 --- a/web/pgadmin/static/js/SchemaView/SchemaState/SchemaState.js +++ b/web/pgadmin/static/js/SchemaView/SchemaState/SchemaState.js @@ -14,7 +14,8 @@ import gettext from 'sources/gettext'; import { prepareData } from '../common'; import { DepListener } from '../DepListener'; -import { FIELD_OPTIONS, schemaOptionsEvalulator } from '../options'; +import { FIELD_OPTIONS, pathOverlaps, schemaOptionsEvalulator } from '../options'; +import { count, measure } from '../perf'; import { SCHEMA_STATE_ACTIONS, @@ -34,6 +35,47 @@ export const LOADING_STATE = { const PATH_SEPARATOR = '/'; +// Soft cap on the _knownErrorPaths LRU. Empirically a complex form +// (TableSchema in edit mode w/ 100 columns, each with 4 sub-fields) +// touches ~400 error paths over a long session; 1024 leaves comfortable +// headroom while making the worst-case mustVisit traversal bounded. +const KNOWN_ERROR_PATHS_CAP = 1024; + +// `map.__capWarned` is set the first time eviction fires for a given +// tracker so the warn doesn't repeat for the same dialog. Per-Map +// (not module-level) so each new SchemaState resets the flag — a +// long-lived ERD session can still see the warn when a freshly +// opened sub-dialog hits the cap. +const addKnownErrorPath = (map, flat, path) => { + if (map.has(flat)) { + // Refresh recency: delete + re-insert so this entry moves to + // the end of the insertion-order traversal (used as LRU). + map.delete(flat); + } else if (map.size >= KNOWN_ERROR_PATHS_CAP) { + // Evict the oldest entry. JS Map iterates in insertion order. + const oldest = map.keys().next().value; + if (oldest !== undefined) map.delete(oldest); + // Telemetry: surface via the perf-counter infrastructure so the + // perf overlay shows total evictions per session, and emit a + // one-shot console.warn under canary builds so a developer who + // hits the cap actually notices. If a real session hits this + // repeatedly, the cap may need raising; without a signal there's + // no way to know. + count('SchemaState.knownErrorPaths.evictions'); + if (process.env.__CANARY_BUILD__ && !map.__capWarned) { + map.__capWarned = true; + + console.warn( + '[schemaview] _knownErrorPaths LRU cap ' + + `(${KNOWN_ERROR_PATHS_CAP}) hit; oldest error paths are ` + + 'being evicted. If the dialog is short-lived this is fine; ' + + 'if it persists across many edits, raise the cap.' + ); + } + } + map.set(flat, path); +}; + export class SchemaState extends DepListener { constructor( schema, getInitData, immutableData, onDataChange, viewHelperProps, @@ -74,29 +116,92 @@ export class SchemaState extends DepListener { // Pre-ready queue this.preReadyQueue = []; - this.optionStore = createStore({}); - this.dataStore = createStore({}); + this.optionStore = createStore({}, 'option'); + this.dataStore = createStore({}, 'data'); this.stateStore = createStore({ isNew: true, isDirty: false, isReady: false, isSaving: false, errors: {}, message: '', - }); + }, 'state'); // Memoize the path using flatPathGenerator this.__pathGenerator = flatPathGenerator(PATH_SEPARATOR); + // Tracks every path that has reported a validation error during + // this state's lifetime, keyed by flat path string. Used to build + // mustVisit so incremental validation never silently drops a row + // that was previously known to be invalid. Map (not Set) so we + // preserve the original path array for re-injection into + // mustVisit. Entries are kept across validates — even if a row + // clears its error, leaving it in the set means future dispatches + // re-check it cheaply, which catches re-errors without a full walk. + // + // Bounded by KNOWN_ERROR_PATHS_CAP so long-lived dialogs (ERD, + // schema diff, etc.) don't leak. JS Map preserves insertion order, + // so the oldest entry is keys().next().value — evict that when + // we'd exceed the cap. Eviction is safe: a dropped path either + // (a) is no longer dirty, in which case the next full walk picks + // it up anyway, or (b) is still dirty, in which case the user's + // next dispatch on that path re-adds it via the changedPath + // route. + this._knownErrorPaths = new Map(); + this._id = Date.now(); } - updateOptions() { - let options = _.cloneDeep(this.optionStore.getState()); + updateOptions(changedPath, depDestsArg) { + return measure('SchemaState.updateOptions', () => { + const prev = this.optionStore.getState(); + + // Caller (SchemaState.validate) may pre-compute depDests; otherwise + // we collect them here. Pull in any DepListener entries whose source + // overlaps the changedPath. Their dest paths must also be visited + // so cross-row declared deps still re-evaluate options. + const depDests = depDestsArg !== undefined + ? depDestsArg + : this._collectDepDestsForPath(changedPath); + + // Incremental is the default now. The schema instance can opt + // OUT by setting `incrementalOptions = false`; propagate that + // through viewHelperProps so the walker (which only reads vhp) + // sees it. Explicit viewHelperProps wins if the dialog opener + // already set the flag either way. + let vhp = this.viewHelperProps; + if (this.schema?.incrementalOptions === false + && vhp?.incrementalOptions !== false) { + vhp = { ...vhp, incrementalOptions: false }; + } else if (this.schema?.incrementalOptions === true + && vhp?.incrementalOptions !== true) { + // Back-compat: legacy schemas that explicitly opted IN keep + // working even if the default ever shifts back. + vhp = { ...vhp, incrementalOptions: true }; + } + + // Walker returns a NEW options tree built via structural sharing: + // unvisited collection rows keep their previous object references + // (so path-subscribers can short-circuit on Object.is downstream), + // visited subtrees are fresh objects. No more upfront cloneDeep of + // the whole tree. + const next = schemaOptionsEvalulator({ + schema: this.schema, data: this.data, prevOptions: prev, + viewHelperProps: vhp, + changedPath, depDests, + }); - schemaOptionsEvalulator({ - schema: this.schema, data: this.data, options: options, - viewHelperProps: this.viewHelperProps, + this.optionStore.setState(next); }); + } - this.optionStore.setState(options); + _collectDepDestsForPath(changedPath) { + if (!Array.isArray(changedPath)) return null; + const listeners = this._depListeners || []; + if (listeners.length === 0) return null; + const dests = []; + for (const entry of listeners) { + if (!entry?.source || !entry?.dest) continue; + if (pathOverlaps(entry.source, changedPath)) dests.push(entry.dest); + } + return dests; } setState(state, value) { @@ -104,6 +209,15 @@ export class SchemaState extends DepListener { } setError(err) { + // Mirror the assignment into _knownErrorPaths so any caller — + // including external code that constructs an error directly — feeds + // the multi-path tracker. Without this, callers that bypass the + // validate() callback (e.g. test fixtures that pre-seed an error) + // wouldn't get the row revisited under incremental mustVisit. + if (err && Array.isArray(err.name) && err.name.length > 0) { + const flat = err.name.map((p) => String(p)).join(PATH_SEPARATOR); + addKnownErrorPath(this._knownErrorPaths, flat, [...err.name]); + } this.setState('errors', err); } @@ -205,28 +319,143 @@ export class SchemaState extends DepListener { } validate(sessData) { - let state = this, - schema = state.schema; - - // If schema does not have the data or does not have any 'onDataChange' - // callback, there is no need to validate the current data. - if(!state.isReady) return; - - if( - !validateSchema(schema, sessData, (path, message) => { - message && state.setError({ - name: state.accessPath(path), message: _.escape(message) - }); - }) - ) state.setError({}); - - state.data = sessData; - state._changes = state.changes(); - state.updateOptions(); - state.onDataChange && state.onDataChange(state.isDirty, state._changes, state.errors); + return measure('SchemaState.validate', () => { + let state = this, + schema = state.schema; + + // If schema does not have the data or does not have any 'onDataChange' + // callback, there is no need to validate the current data. + if(!state.isReady) return; + + // Read+consume the changedPaths set by the dispatcher. React + // batches multiple dispatches into one validate cycle, so we + // accumulate every path that landed in this batch (see + // useSchemaState.sessDispatchWithListener). The first path is + // the "primary" changedPath threaded through updateOptions; any + // additional paths join depDests so the walker treats them as + // must-visit. On initial mount / INIT / external triggers, the + // array is empty and both validateSchema and updateOptions fall + // back to a full walk. + const pendingPaths = Array.isArray(state.__pendingChangedPaths) + ? state.__pendingChangedPaths : []; + state.__pendingChangedPaths = []; + // Back-compat: existing tests and any external callers may still + // set the legacy single-path field. Treat it as one pending path + // when the accumulator is empty. + if (pendingPaths.length === 0 && state.__lastChangedPath) { + pendingPaths.push(state.__lastChangedPath); + } + state.__lastChangedPath = undefined; + const changedPath = pendingPaths[0]; + const extraChangedPaths = pendingPaths.slice(1); + + // Build the must-visit list once and share it between validateSchema + // and updateOptions. Includes: + // - changedPath + // - DepListener dest paths whose source overlaps changedPath + // - EVERY path that has ever reported an error during this + // state's lifetime (state._knownErrorPaths). Without this, + // incremental validation could silently miss a row that was + // previously invalid: the per-validate short-circuit means + // only ONE error path was visible at a time, and the old + // `state.errors.name` tracker only held that one. A row that + // erroried but was eclipsed by an earlier short-circuit + // would never be re-validated until a changedPath happened + // to overlap it. + // Incremental walks are now the DEFAULT: any dispatch with a + // concrete changedPath gets the pruned walk. Opt-out paths + // remain available for the (rare) dialog that needs full-walk + // semantics; an explicit `false` on viewHelperProps, the schema + // instance, or the global window flag disables it. + const incremental = ( + Array.isArray(changedPath) + && state.viewHelperProps?.incrementalOptions !== false + && state.schema?.incrementalOptions !== false + && (typeof window === 'undefined' + || window.__INCREMENTAL_OPTIONS__ !== false) + ); + // Collect depDests for the primary changedPath, then fold any + // additional batched paths and THEIR depDests into the same + // list. The walker's mustVisit is a flat union — adding entries + // here keeps the entire batch correctly visited even though the + // walker still treats `changedPath` as the primary anchor. + const primaryDepDests = state._collectDepDestsForPath(changedPath); + let depDests = primaryDepDests; + if (extraChangedPaths.length > 0) { + depDests = Array.isArray(primaryDepDests) ? [...primaryDepDests] : []; + for (const extra of extraChangedPaths) { + depDests.push(extra); + const extraDeps = state._collectDepDestsForPath(extra); + if (Array.isArray(extraDeps)) { + for (const d of extraDeps) depDests.push(d); + } + } + } + // Known error paths ride mustVisit AND depDests. Pre-fix the + // options walker would prune a row that previously erroried, + // recomputing wrong options for any closure that branches on + // error state. mustVisit was already augmented below; mirror + // it into depDests so updateOptions sees the same union. + if (incremental && state._knownErrorPaths.size > 0) { + if (!Array.isArray(depDests)) depDests = []; + else if (depDests === primaryDepDests) depDests = [...primaryDepDests]; + for (const knownPath of state._knownErrorPaths.values()) { + depDests.push(knownPath); + } + } + let mustVisit = null; + if (incremental) { + mustVisit = [changedPath].concat(Array.isArray(depDests) ? depDests : []); + // depDests now already includes known error paths (above); + // no need to re-push here. + } + + // Capture every error reported across the validate walk into + // the long-lived tracker (Map keyed by flat-path string so + // duplicates collapse). The displayed error stays the FIRST + // one — calling state.setError multiple times would otherwise + // make the UI flicker through every error before settling on + // the last one. Combined with collectAll=true, the walker + // reports every error path it discovers; we record them all + // and surface the first to the UI. + let errorsSet = 0; + let firstError = null; + const hadError = validateSchema(schema, sessData, (path, message) => { + if (!message) return; + errorsSet++; + const flat = (path || []).map((p) => String(p)).join(PATH_SEPARATOR); + addKnownErrorPath(state._knownErrorPaths, flat, [...path]); + if (!firstError) firstError = { path, message }; + }, [], null, mustVisit, true); + count('SchemaState.validate.setErrorCalls', errorsSet); + if (firstError) { + measure('SchemaState.validate.setError', () => state.setError({ + name: state.accessPath(firstError.path), + message: _.escape(firstError.message), + })); + } else if (!hadError) { + measure('SchemaState.validate.clearError', + () => state.setError({})); + } + + measure('SchemaState.validate.dataAssign', + () => { state.data = sessData; }); + state._changes = state.changes(); + + state.updateOptions(changedPath, depDests); + + if (state.onDataChange) { + measure('SchemaState.validate.onDataChange', + () => state.onDataChange(state.isDirty, state._changes, state.errors)); + } + }); } changes(includeSkipChange=false) { + return measure('SchemaState.changes', () => this._changesImpl(includeSkipChange)); + } + + _changesImpl(includeSkipChange=false) { const state = this; const sessData = state.data; const schema = state.schema; diff --git a/web/pgadmin/static/js/SchemaView/SchemaState/audit_harness.js b/web/pgadmin/static/js/SchemaView/SchemaState/audit_harness.js new file mode 100644 index 00000000000..10406063a9a --- /dev/null +++ b/web/pgadmin/static/js/SchemaView/SchemaState/audit_harness.js @@ -0,0 +1,1556 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2026, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +// Per-schema audit utility. The harness spec loops over +// `getRegisteredSchemas()` and calls `auditSchema(SchemaClass)` for +// each one; this module exists so the loop logic stays a one-liner +// and the per-schema dispatch logic stays testable in isolation. +// +// What auditSchema does for a single SchemaClass: +// 1. Instantiate the schema. Try no-args first, then `({}, {})` as +// a fallback for the common `(fieldOptions, initValues)` shape. +// Schemas that need real constructor args are reported as +// skipped — a future fixture file can register stubs for them. +// 2. Build default sessData via `schema.getNewData({})`. +// 3. Establish a baseline by running the full walk once to produce +// `prevOptions`. This is what real SchemaState does on mount. +// 4. For each scalar field, dispatch a synthetic change: +// - mutate sessData at the field path +// - call the options + validation wrappers with the matching +// `changedPath` / `mustVisit` +// Both wrappers route through the canaries because we set +// `window.__INCREMENTAL_AUDIT__ = true` for the audit. Divergence +// throws (via `__throw_on_canary_divergence__`); the throw +// propagates so the calling test fails fast with the diff. +// +// Collections, nested fieldsets, and array mutations (ADD_ROW / +// DELETE_ROW) aren't covered here yet — scalar-field coverage is +// where the prototype's known limitation bites first, so it's the +// highest-value starting point. Expand once the scalar pass is +// clean across all registered schemas. + +import BaseUISchema from '../base_schema.ui'; +import { validateSchema } from './common'; +import { schemaOptionsEvalulator, pathOverlaps } from '../options/registry'; +import { _resetCanaryFireCount } from '../options/canary'; +import { _resetValidationCanaryFireCount } from './validation_canary'; + +// Walks the schema tree (including collection inner schemas, with row +// indices from `sessData`) and emits the source→dest pairs that +// `listenDepChanges` would register at React mount time. The audit +// runs synthetically — no React, no useEffect — so production's +// DepListener registry is empty when the canary fires. To audit the +// SAME paths real users hit, we reconstruct the registry here from +// `field.deps` declarations and use it to populate the walker's +// depDests for each dispatch. +const collectDepEntries = (schema, sessData) => { + const entries = []; + const walk = (sch, accessPath, dataAtLevel) => { + for (const field of sch?.fields || []) { + if (field.deps) { + const deps = Array.isArray(field.deps) ? field.deps : []; + const destAccessPath = accessPath.concat(field.id); + for (const dep of deps) { + // Same convention as listenDepChanges: + // string → relative to current parent (accessPath) + // array → absolute access path + const source = Array.isArray(dep) ? dep : accessPath.concat(dep); + entries.push({ source, dest: destAccessPath }); + } + } + // Recurse into nested schemas + collection inner schemas. + if (field.schema instanceof BaseUISchema) { + if (field.type === 'collection') { + const rows = dataAtLevel?.[field.id]; + if (Array.isArray(rows)) { + rows.forEach((row, idx) => { + walk(field.schema, accessPath.concat(field.id, idx), row); + }); + } + } else { + // nested-fieldset / inline-groups — same data level. + walk(field.schema, accessPath.concat(field.id), dataAtLevel); + } + } + } + }; + walk(schema, [], sessData); + return entries; +}; + +// For a given changedPath, return all dep dests whose source path +// overlaps. Mirrors SchemaState._collectDepDestsForPath but works off +// the pre-walked entries rather than DepListener._depListeners. +const collectDepDests = (entries, changedPath) => { + if (!Array.isArray(changedPath)) return null; + const dests = []; + for (const e of entries) { + if (pathOverlaps(e.source, changedPath)) dests.push(e.dest); + } + return dests; +}; + +// Minimal child-schema stub. Many production schemas accept a +// constructor argument like `getPrivilegeRoleSchema` / `getVariableSchema` +// that the host calls inside its `baseFields` getter to materialize a +// nested collection. Passing `null`/`undefined`/`{}` makes that call +// throw at field-resolution time and the audit skips the schema. A +// stub function that returns an empty BaseUISchema instance keeps the +// host alive — its fields resolve, and the audit's per-cell / per-row +// dispatcher covers everything except the stubbed sub-collection. +class StubChildSchema extends BaseUISchema { + get baseFields() { return [{id: '__stub_name__', type: 'text'}]; } +} +const stubFn = () => new StubChildSchema(); + +// A synthetic `nodeInfo` populated with everything host schemas +// commonly poke at: `server.version` (gates field visibility by PG +// version), `server.user.name`, `database/schema.id`, and `catalog` +// flag for `inCatalog()` checks. Several schemas access these via +// `this.nodeInfo.X` in evaluators or validators; without them the +// audit's dispatches throw on undefined-reads and SKIP. A +// far-future PG version (999999) keeps version-gated fields +// active so they enter audit coverage. +const richNodeInfo = { + server: { + version: 999999, + user: { name: 'audit_stub', is_superuser: true }, + db_info: {}, + server_type: 'pg', + }, + database: { id: 1, name: 'audit_db' }, + schema: { id: 1, name: 'public' }, + catalog: false, +}; +const stubSchemasObj = { + // For TableSchema-style hosts that expect a `schemas` arg with + // sub-schema factories (`schemas.constraints`, etc.). All return + // the same minimal StubChildSchema so audit dispatches can drill + // into nested collections without crashing. + constraints: stubFn, + columns: stubFn, +}; + +// Some schemas (ForeignTableSchema, several others) read nodeInfo +// off `this.fieldOptions.nodeInfo` rather than a positional arg. +// Bundling nodeInfo into a richer fieldOptions handles both +// patterns. Stub functions for the common `getXSchema` slots are +// also embedded so schemas that pluck them off fieldOptions work. +const richFieldOptions = { + nodeInfo: richNodeInfo, + getPrivilegeRoleSchema: stubFn, + getVariableSchema: stubFn, + getMembershipSchema: stubFn, + getColumns: () => [], + getCollations: () => [], + getOperatorClass: () => [], + // Common literal lists schemas accept as field-option entries. + cltypeOptions: [], + collspcnameOptions: [], + geometryTypes: [], +}; + +// LanguageSchema (and a few other database-child schemas) snake_case +// the nodeInfo arg as `node_info`, with a doubly-nested shape: +// `this.node_info.node_info.user.name`. Synthesize that shape so the +// constructor doesn't crash at field defaults. +const nestedNodeInfo = { + node_info: { + server: richNodeInfo.server, + version: 999999, + user: { name: 'audit_stub', is_superuser: true }, + database: richNodeInfo.database, + schema: richNodeInfo.schema, + }, + server: richNodeInfo.server, + version: 999999, + user: { name: 'audit_stub', is_superuser: true }, + database: richNodeInfo.database, + schema: richNodeInfo.schema, +}; + +// Per-schema fixture factories for hosts whose constructors are too +// specific for the generic attempt chain — e.g. BackupSchema's six +// stub-fn args, or LanguageSchema's doubly-nested node_info. Each +// factory returns a constructed instance; if it throws, the generic +// chain is tried as a fallback. +const PER_SCHEMA_FIXTURES = { + BackupSchema: (C) => new C( + stubFn, stubFn, stubFn, stubFn, stubFn, stubFn, + richFieldOptions, [], null, 'server', {} + ), + RestoreSchema: (C) => new C( + stubFn, stubFn, stubFn, stubFn, stubFn, stubFn, + richFieldOptions, [], null, 'server', {} + ), + ForeignTableSchema: (C) => new C( + stubFn, stubFn, () => [], richFieldOptions, {} + ), + FunctionSchema: (C) => new C( + stubFn, stubFn, richFieldOptions, nestedNodeInfo, 'function', {} + ), + TriggerFunctionSchema: (C) => new C( + stubFn, stubFn, richFieldOptions, {} + ), + LanguageSchema: (C) => new C( + stubFn, richFieldOptions, nestedNodeInfo, {} + ), + PublicationSchema: (C) => new C( + richFieldOptions, nestedNodeInfo, {} + ), + TriggerSchema: (C) => new C(richFieldOptions, {}), + RowSecurityPolicySchema: (C) => new C(richFieldOptions, {}), + TypeSchema: (C) => new C( + stubFn, stubFn, stubFn, stubFn, stubFn, richFieldOptions, {} + ), + VacuumSettingsSchema: (C) => { + // VacuumSettingsSchema's fields call `obj.top.isNew()` — it + // expects to be a child of a parent schema (TableSchema etc). + // Audited standalone, obj.top is null. Wire a stub parent. + const inst = new C([], [], richNodeInfo); + inst.top = Object.assign(new BaseUISchema(), { + isNew: () => true, + state: { data: {}, _sessData: {} }, + }); + return inst; + }, + ViewSchema: (C) => new C(stubFn, richNodeInfo, richFieldOptions, {}), +}; + +const tryInstantiate = (SchemaClass) => { + // Per-schema fixture first: hosts with constructors too specific + // for the generic chain (BackupSchema's 6 stub-fns, LanguageSchema's + // doubly-nested node_info, etc.) get a hand-written factory. + const fixture = PER_SCHEMA_FIXTURES[SchemaClass.name]; + if (fixture) { + try { + const instance = fixture(SchemaClass); + // Still gate on `.fields` resolution — the fixture only + // guarantees the constructor ran. + void instance.fields; + return { ok: true, instance }; + } catch { + // Fall through to generic attempts. + } + } + // Most schemas accept no args or `(fieldOptions, initValues)` + // with all defaults. Try the cheapest path first. Subsequent + // attempts layer in stubFn / richNodeInfo / stubSchemasObj for + // the documented constructor shapes in the codebase. Order is + // from "least synthetic baggage" to "most" — the first attempt + // that produces an instance whose .fields resolve wins. + const attempts = [ + () => new SchemaClass(), + () => new SchemaClass({}), + () => new SchemaClass({}, {}), + () => new SchemaClass({}, {}, {}), + // (fieldOptions[, ...]) hosts — rich fieldOptions carries + // nodeInfo + stub getters so a host that does + // `this.nodeInfo = this.fieldOptions.nodeInfo` works. + () => new SchemaClass(richFieldOptions), + () => new SchemaClass(richFieldOptions, {}), + () => new SchemaClass(richFieldOptions, richNodeInfo), + () => new SchemaClass(richFieldOptions, richNodeInfo, {}), + // (fieldOptions, nodeInfo[, ...]) hosts + () => new SchemaClass({}, richNodeInfo), + () => new SchemaClass({}, richNodeInfo, {}), + () => new SchemaClass({}, richNodeInfo, {}, {}), + // stubFn-first hosts: PGSchema, ViewSchema, etc. + () => new SchemaClass(stubFn), + () => new SchemaClass(stubFn, {}), + () => new SchemaClass(stubFn, richNodeInfo), + () => new SchemaClass(stubFn, richNodeInfo, {}), + () => new SchemaClass(stubFn, richNodeInfo, {}, {}), + () => new SchemaClass(stubFn, richNodeInfo, [], [], [], false), // ColumnSchema + // Hosts that need TWO function args: RoleSchema (getVariable, + // getMembership), TablespaceSchema (getVariable, getPrivilege). + () => new SchemaClass(stubFn, stubFn), + () => new SchemaClass(stubFn, stubFn, {}), + () => new SchemaClass(stubFn, stubFn, {}, {}), + () => new SchemaClass(stubFn, stubFn, richFieldOptions), // nodeInfo from fieldOptions + () => new SchemaClass(stubFn, stubFn, richFieldOptions, {}), + () => new SchemaClass(stubFn, stubFn, richNodeInfo), + () => new SchemaClass(stubFn, stubFn, richNodeInfo, {}), + // TableSchema-shape: (fieldOptions, nodeInfo, schemas, getPrivilege, ...) + () => new SchemaClass( + {}, richNodeInfo, stubSchemasObj, stubFn, () => [], () => [], () => [], + () => [], {}, false + ), + ]; + // An "instantiation success" means BOTH the constructor ran AND + // `schema.fields` resolved without throwing. Many schemas have + // constructors that quietly assign `undefined` to a stored arg + // and only blow up when their baseFields getter calls the missing + // function. If the cheaper constructor signature would succeed but + // .fields would throw, the next attempt with stubFn args might + // pass both gates — keep trying. + const failures = []; + for (let i = 0; i < attempts.length; i++) { + const attempt = attempts[i]; + let instance; + try { instance = attempt(); } + catch (e) { + failures.push(`#${i} ctor: ${e.message}`); + continue; + } + try { + // Accessing .fields triggers baseFields evaluation. If it + // throws, the constructor's args were insufficient even though + // `new` didn't fail. Try the next attempt. + void instance.fields; + return { ok: true, instance }; + } catch (e) { + failures.push(`#${i} fields: ${e.message}`); + } + } + // Report the LAST failure (richest attempt) rather than the first + // (no-args). The no-args message is almost always "X is not a + // function" which masks why the rich attempts didn't work either. + return { + ok: false, + reason: 'could not instantiate: ' + failures[failures.length - 1], + }; +}; + +// Variants used to surface closures that branch on input shape +// (empty / whitespace / unicode / long). Each pass cycles through +// the variants modulo the call count so subsequent dispatches on +// the same field hit different branches. +const TEXT_VARIANTS = [ + 'audit_mutated_a', + 'audit_mutated_b', + '', // empty — many validators reject this + ' ', // whitespace-only + 'éàç中', // unicode — éàç中 + 'a'.repeat(200), // long string +]; + +let _scalarMutationCounter = 0; + +// Returns a value that differs from `current` for the given field +// type. Audit only cares about triggering a dispatch — semantic +// validity isn't required, just that the walker sees a real change. +// For text-shaped fields, cycles through TEXT_VARIANTS so closures +// reading length / emptiness / non-ASCII content all get hit +// across the full audit pass. +const mutateScalar = (field, current) => { + switch (field.type) { + case 'switch': + case 'boolean': + case 'checkbox': + return !current; + case 'int': + case 'numeric': + return Number.isFinite(current) ? current + 1 : 1; + case 'text': + case 'multiline': + case 'sql': + case 'password': + default: { + const idx = (_scalarMutationCounter++) % TEXT_VARIANTS.length; + const candidate = TEXT_VARIANTS[idx]; + if (candidate === current) { + // Same value as current — pick the next one to guarantee a + // real change (otherwise the walker sees no-op dispatch). + return TEXT_VARIANTS[(idx + 1) % TEXT_VARIANTS.length]; + } + return candidate; + } + } +}; + +// Test-only entry point to reset the rotation between specs so +// dispatch ordering is deterministic per test. +export const _resetMutationCounter = () => { _scalarMutationCounter = 0; }; + +const SCALAR_TYPES = new Set([ + 'text', 'multiline', 'sql', 'password', + 'int', 'numeric', + 'switch', 'boolean', 'checkbox', + 'select', +]); + +const isScalarField = (f, schema) => + SCALAR_TYPES.has(f.type) + && f.id !== schema.idAttribute + && (f.mode == null || f.mode.includes('edit') || f.mode.includes('create')); + +// Seeds collection fields with SEED_ROWS rows of defaults each. +// Cross-row reads (the prototype's known limitation) only surface +// when the walker has multiple rows to choose between. Two rows +// catches row-0-vs-row-1 patterns; three is the minimum that +// exercises chained reads where row N's closure references row +// N-1 — common in DataGridView patterns like "constraint references +// the column at the previous index". +// +// Each row is given a unique sentinel value in its first scalar +// cell so cross-row reads see DISTINCT data per row (not three +// identical default rows). A closure that reads +// `top.sessData.rows[0].name` vs `rows[1].name` will produce +// different results, which is what surfaces divergence. +const SEED_ROWS = 3; + +const stampSentinel = (inner, row, idx) => { + // Pick the first scalar-typed cell in the inner schema and stamp + // a unique sentinel. Skips silently when the inner has no scalar + // cell (the row stays as-is — still gets seeded for ADD/DELETE + // coverage). + for (const cellField of inner?.fields || []) { + if (isScalarField(cellField, inner)) { + row[cellField.id] = `audit_row_${idx}`; + return; + } + } +}; + +const seedCollections = (schema, sessData) => { + for (const field of schema.fields || []) { + if (field.type !== 'collection' || !field.schema) continue; + const inner = field.schema; + const current = sessData[field.id]; + if (Array.isArray(current) && current.length >= SEED_ROWS) continue; + if (typeof inner.getNewData !== 'function') continue; + try { + const seeded = []; + for (let i = 0; i < SEED_ROWS; i++) { + const row = inner.getNewData({}); + stampSentinel(inner, row, i); + seeded.push(row); + } + sessData[field.id] = seeded; + } catch { + // Inner schema needs more setup than we can synthesize. + // Leave the field empty — collection-cell mutations for this + // field will be skipped below. + } + } +}; + +// Drives one dispatch: mutate sessData, run options + validation +// walks with the audit canaries on. The canaries throw on divergence +// (via __throw_on_canary_divergence__), which propagates up so the +// test fails fast with the diff. +// +// Real schemas read cross-row state via `this.top.sessData.X`, which +// resolves to `this.top._state.data.X`. The walker wires `this.top` +// onto nested schemas at evaluation time, but the `state` attachment +// is SchemaState's job; mimic it here by setting `schema.state` to a +// stub whose `.data` points at the row/sessData currently being +// evaluated. Without this, undeclared cross-row reads silently see +// `undefined` and the audit never observes the divergence. +// +// `knownErrorPaths` is the same multi-path tracker SchemaState +// maintains in production: every path that has ever reported an +// error is included in subsequent mustVisits so a previously-invalid +// row is always re-checked. Without this, the audit would flag +// pre-existing collection errors as "divergence" on every dispatch +// that doesn't touch the collection — the canary would be reporting +// a behavior that the production SchemaState wrapper compensates for. +// Constructs a stub for `schema.state` shaped close enough to a +// real SchemaState that closures reading top.state.X don't crash or +// silently take the wrong branch. The walker reads `state.data`; +// validators sometimes read `state.errors` and `state._knownErrorPaths` +// to decide whether to short-circuit. Synthetic state with empty +// errors + the live data pointer matches production's "no errors, +// fresh from initialise" baseline. +const buildStateStub = (sessData, knownErrorPaths) => ({ + data: sessData, + errors: {}, + isReady: true, + isNew: false, // edit-mode default; toggle per-call when needed + _knownErrorPaths: knownErrorPaths || new Map(), +}); + +const dispatchAndAudit = (schema, sessData, changedPath, newSessData, knownErrorPaths, mode = 'edit') => { + // Baseline full walk with the OLD sessData wired up. + schema.state = buildStateStub(sessData, knownErrorPaths); + schema.state.isNew = (mode !== 'edit'); + const prevOptions = schemaOptionsEvalulator({ + schema, data: sessData, viewHelperProps: { mode }, + prevOptions: null, + }); + + // Now wire the NEW sessData so closures inside the canary's two + // internal walks (full + incremental) read the post-mutation + // state. Reusing the same `state` object keeps the cached identity + // stable for any caller that captures it. + schema.state.data = newSessData; + + // Build the mustVisit that mirrors what SchemaState.validate would + // assemble: changedPath plus every known error path. + const mustVisit = [changedPath, ...knownErrorPaths.values()]; + + // Compute dep-dests from the schema's declared `field.deps`. This + // matches what listenDepChanges + DepListener._collectDepDestsForPath + // do in production: any field whose dep source overlaps the + // changedPath must stay in mustVisit so its row isn't pruned. + // Without this the audit would falsely flag every evaluator-only + // cross-row dep as a divergence. + const depEntries = collectDepEntries(schema, newSessData); + const fieldDepDests = collectDepDests(depEntries, changedPath) || []; + const allDepDests = [ + ...fieldDepDests, + ...Array.from(knownErrorPaths.values()), + ]; + + // Options walk — canary diffs incremental vs full. + schemaOptionsEvalulator({ + schema, data: newSessData, + viewHelperProps: { mode, incrementalOptions: true }, + prevOptions, changedPath, + depDests: allDepDests.length > 0 ? allDepDests : null, + }); + + // Validation walk — canary diffs incremental vs full error maps. + // Pass collectAll=true so the inner walks (full + incremental) + // gather complete error lists, and capture any newly-reported + // paths into the tracker so they're respected next dispatch. + validateSchema( + schema, newSessData, + (path) => { + const flat = path.map((p) => String(p)).join('\x00'); + if (!knownErrorPaths.has(flat)) knownErrorPaths.set(flat, [...path]); + }, + [], null, mustVisit, true + ); +}; + +// Audit fields nested inside `nested-fieldset`, `nested-tab`, or +// `inline-groups` group containers. These share the PARENT'S data +// level (sessData is flat across the container boundary) but live +// in the walker's nested branch — a different code path from +// top-level scalars. Bugs that manifest only when a scalar is +// reached via the nested branch would slip past auditScalars. +// +// Production users: publication.ui (FOR Events block), trigger.ui, +// table.ui (Like block), index.ui (With block), type.ui, sequence.ui +// (Owned By), pga_schedule.ui, and others. +// +// Recursive descent — production schemas chain group containers +// arbitrarily deep (e.g. nested-fieldset inside an inline-groups +// inside another nested-fieldset). MAX_NEST_DEPTH guards against +// pathological / cyclical schemas; 6 is generous for real shapes. +const NESTED_GROUP_TYPES = new Set([ + 'nested-fieldset', 'nested-tab', 'inline-groups', +]); +const MAX_NEST_DEPTH = 6; + +// Yields { fieldDef, ownerSchema } for every scalar field reachable +// through one or more nested-* group containers from `rootSchema`, +// excluding the root's own direct scalar fields (those are +// auditScalars' job). +const walkNestedScalars = function* (rootSchema, depth = 0) { + if (depth >= MAX_NEST_DEPTH) return; + for (const groupField of rootSchema?.fields || []) { + if (!NESTED_GROUP_TYPES.has(groupField.type)) continue; + if (!groupField.schema) continue; + if (!groupField.schema.top) { + groupField.schema.top = rootSchema.top || rootSchema; + } + for (const inner of groupField.schema.fields || []) { + if (isScalarField(inner, groupField.schema)) { + yield { fieldDef: inner, ownerSchema: groupField.schema }; + } + } + // Recurse: the group's schema may contain MORE nested groups. + yield* walkNestedScalars(groupField.schema, depth + 1); + } +}; + +const auditNestedFields = (schema, sessData, knownErrorPaths, mode = 'edit') => { + let n = 0; + for (const { fieldDef, ownerSchema } of walkNestedScalars(schema)) { + // Nested-* shares data with the root, so the field's path is + // FLAT at the root's level (NOT prefixed by any group field + // ids) — production dispatches read sessData[fieldDef.id], not + // sessData[group.id][...nested.id][fieldDef.id]. + const newValue = mutateScalar(fieldDef, sessData[fieldDef.id]); + const newSessData = { ...sessData, [fieldDef.id]: newValue }; + // Mirror production behavior: closures may read `obj.ownerSchema` + // / `obj.top` to find their root. Both are wired by walkNestedScalars + // above (top stamp + walker-time recursion). + void ownerSchema; // documentation only; helper passes it for clarity + dispatchAndAudit( + schema, sessData, [fieldDef.id], newSessData, knownErrorPaths, mode, + ); + n += 1; + } + return n; +}; + +const auditScalars = (schema, sessData, knownErrorPaths, mode = 'edit') => { + let n = 0; + for (const field of schema.fields || []) { + if (!isScalarField(field, schema)) continue; + const newValue = mutateScalar(field, sessData[field.id]); + const newSessData = { ...sessData, [field.id]: newValue }; + dispatchAndAudit( + schema, sessData, [field.id], newSessData, knownErrorPaths, mode + ); + n += 1; + } + return n; +}; + +const auditCollectionCells = (schema, sessData, knownErrorPaths, mode = 'edit') => { + let n = 0; + for (const field of schema.fields || []) { + if (field.type !== 'collection' || !field.schema) continue; + const rows = sessData[field.id]; + if (!Array.isArray(rows) || rows.length < 2) continue; + + // Mutate one scalar cell in each row (not just row 0) to make + // sure the walker is forced to choose between rows on subsequent + // dispatches. This is what triggers cross-row divergence. + for (let rowIdx = 0; rowIdx < rows.length; rowIdx++) { + for (const cellField of field.schema.fields || []) { + if (!isScalarField(cellField, field.schema)) continue; + const newValue = mutateScalar(cellField, rows[rowIdx][cellField.id]); + const newRows = rows.map((r, i) => + i === rowIdx ? { ...r, [cellField.id]: newValue } : r + ); + const newSessData = { ...sessData, [field.id]: newRows }; + dispatchAndAudit( + schema, sessData, [field.id, rowIdx, cellField.id], newSessData, + knownErrorPaths, mode + ); + n += 1; + } + } + } + return n; +}; + +// Structural dispatches — ADD_ROW and DELETE_ROW. These are the +// SchemaState actions that change a collection's length; in the +// reducer they set changedPath to the COLLECTION path (e.g. ['rows']) +// rather than a per-cell path. Within the collection itself this +// forces a full re-eval (every row's globalPath overlaps the +// collection path), so single-collection cross-row divergences +// can't manifest here. The remaining hazard is CROSS-collection +// reads: row N of collection B has a closure reading collection A, +// and ADD/DELETE on A leaves coll_B's rows pruned in incremental +// mode while the full walk re-computes them. This pass surfaces +// exactly that pattern. +const auditCollectionStructure = (schema, sessData, knownErrorPaths, mode = 'edit') => { + let n = 0; + for (const field of schema.fields || []) { + if (field.type !== 'collection' || !field.schema) continue; + const rows = sessData[field.id]; + if (!Array.isArray(rows)) continue; + const inner = field.schema; + + // ADD_ROW: append a default row at the collection level. + if (typeof inner.getNewData === 'function') { + let newRow; + let createOk = false; + try { + newRow = inner.getNewData({}); + createOk = true; + } catch { + // Inner schema needs setup we can't synthesize. Skip; the + // existing cell-mutation pass already covered as much of + // this collection as it could. + } + if (createOk) { + const newRows = [...rows, newRow]; + const newSessData = { ...sessData, [field.id]: newRows }; + dispatchAndAudit( + schema, sessData, [field.id], newSessData, knownErrorPaths, mode + ); + n += 1; + } + } + + // DELETE_ROW: drop the last row (only if there's anything to drop). + if (rows.length > 0) { + const newRows = rows.slice(0, -1); + const newSessData = { ...sessData, [field.id]: newRows }; + dispatchAndAudit( + schema, sessData, [field.id], newSessData, knownErrorPaths + ); + n += 1; + } + } + return n; +}; + +// Sequence pass (addresses A + B in the post-review punch list). +// +// Production accumulates prevOptions across the entire lifetime of a +// dialog. The existing audit passes each compute prevOptions FRESH +// from the seeded sessData, so they catch single-dispatch divergence +// but cannot catch compounding bugs where dispatch K's prev is +// dispatch K-1's stale output. The vacuum_table bug that started +// this session was exactly this shape. +// +// auditSequence drives a realistic multi-step user flow against a +// SINGLE persistent (sessData, prevOptions) tuple. Each step's +// prev = previous step's full-walk output, mirroring how +// SchemaState.updateOptions writes back to optionStore. If any step +// diverges the canary throws as usual. +// +// The script (~10 steps) covers the common interaction shapes: +// 1. type into a top-level scalar +// 2. add a row to the first collection +// 3. type into a cell of the new row +// 4. add another row to the same collection +// 5. type into row 0 cell of a DIFFERENT collection +// 6. move a row in collection 0 (drag-reorder) +// 7. type back into the original top scalar (cycles a variant) +// 8. delete the appended row +// 9. flip a switch in some row if any switch cell exists +// 10. type into a top scalar a third time (closes the cycle) +// +// Steps that don't apply to a given schema (no scalar, no collection, +// no switch) are silently skipped; the pass tolerates schemas with +// any subset of the shape. Returns the dispatch count. +const auditSequence = (schema, sessData, knownErrorPaths, mode = 'edit') => { + // Persistent walker context. `prev` evolves across dispatches; this + // is the half the per-pass functions can't simulate. + schema.state = buildStateStub(sessData, knownErrorPaths); + schema.state.isNew = (mode !== 'edit'); + let prev = schemaOptionsEvalulator({ + schema, data: sessData, + viewHelperProps: { mode }, + prevOptions: null, + }); + let cur = sessData; + + const fire = (changedPath, newSessData) => { + schema.state.data = newSessData; + const depEntries = collectDepEntries(schema, newSessData); + const fieldDepDests = collectDepDests(depEntries, changedPath) || []; + const allDepDests = [ + ...fieldDepDests, + ...Array.from(knownErrorPaths.values()), + ]; + const mustVisit = [changedPath, ...knownErrorPaths.values()]; + + // Capture the canary's full-walk output as the NEW prev so the + // NEXT step starts from the (correct) accumulated state — the + // shape production maintains in optionStore. + const next = schemaOptionsEvalulator({ + schema, data: newSessData, + viewHelperProps: { mode, incrementalOptions: true }, + prevOptions: prev, changedPath, + depDests: allDepDests.length > 0 ? allDepDests : null, + }); + + validateSchema( + schema, newSessData, + (path) => { + const flat = path.map((p) => String(p)).join('\x00'); + if (!knownErrorPaths.has(flat)) knownErrorPaths.set(flat, [...path]); + }, + [], null, mustVisit, true, + ); + + cur = newSessData; + prev = next; + }; + + let n = 0; + // Identify candidate field shapes once. + const topScalars = (schema.fields || []) + .filter((f) => isScalarField(f, schema)); + const collections = (schema.fields || []) + .filter((f) => f.type === 'collection' && f.schema + && typeof f.schema.getNewData === 'function'); + + // 1. type into a top scalar + if (topScalars[0]) { + const f = topScalars[0]; + fire([f.id], { ...cur, [f.id]: mutateScalar(f, cur[f.id]) }); + n++; + } + + // 2. ADD_ROW to collection 0 + if (collections[0]) { + try { + const newRow = collections[0].schema.getNewData({}); + const rows = [...(cur[collections[0].id] || []), newRow]; + fire([collections[0].id], { ...cur, [collections[0].id]: rows }); + n++; + + // 3. type into a cell of the newly-added row + const innerScalar = (collections[0].schema.fields || []) + .find((f) => isScalarField(f, collections[0].schema)); + if (innerScalar) { + const idx = rows.length - 1; + const updated = rows.map((r, i) => i === idx + ? { ...r, [innerScalar.id]: mutateScalar(innerScalar, r[innerScalar.id]) } + : r); + fire( + [collections[0].id, idx, innerScalar.id], + { ...cur, [collections[0].id]: updated }, + ); + n++; + } + + // 4. ADD_ROW again + const newRow2 = collections[0].schema.getNewData({}); + const rows2 = [...(cur[collections[0].id] || []), newRow2]; + fire([collections[0].id], { ...cur, [collections[0].id]: rows2 }); + n++; + } catch { /* collection setup mismatch — skip */ } + } + + // 5. type into row 0 of a DIFFERENT collection + if (collections[1]) { + const innerScalar = (collections[1].schema.fields || []) + .find((f) => isScalarField(f, collections[1].schema)); + const rows = cur[collections[1].id] || []; + if (innerScalar && rows.length > 0) { + const updated = rows.map((r, i) => i === 0 + ? { ...r, [innerScalar.id]: mutateScalar(innerScalar, r[innerScalar.id]) } + : r); + fire( + [collections[1].id, 0, innerScalar.id], + { ...cur, [collections[1].id]: updated }, + ); + n++; + } + } + + // 6. MOVE_ROW on collection 0 + if (collections[0]) { + const rows = cur[collections[0].id] || []; + if (rows.length >= 2) { + const reordered = [...rows.slice(1), rows[0]]; + fire([collections[0].id], { ...cur, [collections[0].id]: reordered }); + n++; + } + } + + // 7. type into top scalar 0 again + if (topScalars[0]) { + const f = topScalars[0]; + fire([f.id], { ...cur, [f.id]: mutateScalar(f, cur[f.id]) }); + n++; + } + + // 8. DELETE_ROW on collection 0 + if (collections[0]) { + const rows = cur[collections[0].id] || []; + if (rows.length > 0) { + const trimmed = rows.slice(0, -1); + fire([collections[0].id], { ...cur, [collections[0].id]: trimmed }); + n++; + } + } + + // 9. flip a switch cell in some collection that has one + for (const c of collections) { + const sw = (c.schema.fields || []).find( + (f) => ['switch', 'boolean', 'checkbox'].includes(f.type), + ); + const rows = cur[c.id] || []; + if (sw && rows.length > 0) { + const updated = rows.map((r, i) => i === 0 + ? { ...r, [sw.id]: !r[sw.id] } : r); + fire([c.id, 0, sw.id], { ...cur, [c.id]: updated }); + n++; + break; // one switch is enough; we just want the path covered + } + } + + // 10. type into top scalar 0 once more (cycles through variants) + if (topScalars[0]) { + const f = topScalars[0]; + fire([f.id], { ...cur, [f.id]: mutateScalar(f, cur[f.id]) }); + n++; + } + + return n; +}; + +// MOVE_ROW dispatches: simulate the DataGridView drag-to-reorder +// action. Real reducer at SCHEMA_STATE_ACTIONS.MOVE_ROW splices a +// row out at oldIndex and re-inserts it at newIndex; the action's +// changedPath is the collection path (same shape as ADD/DELETE). +// Audit shape: swap rows 0 and N-1 for each collection with at +// least 2 seeded rows. +const auditMoveRow = (schema, sessData, knownErrorPaths, mode = 'edit') => { + let n = 0; + for (const field of schema.fields || []) { + if (field.type !== 'collection' || !field.schema) continue; + const rows = sessData[field.id]; + if (!Array.isArray(rows) || rows.length < 2) continue; + // Reorder: move row 0 to the end. The reducer's MOVE_ROW + // splices in-place; the audit reproduces the resulting array. + const newRows = [...rows.slice(1), rows[0]]; + const newSessData = { ...sessData, [field.id]: newRows }; + dispatchAndAudit( + schema, sessData, [field.id], newSessData, knownErrorPaths, mode + ); + n += 1; + } + return n; +}; + +// BULK_UPDATE dispatches: the reducer toggles `row[action.id] = false` +// on every row of the collection. Production callers use this to +// reset toggles like 'used' across a whole collection (e.g. the +// "uncheck all" in security label grids). Audit shape: for every +// collection whose inner schema has at least one switch / boolean +// scalar cell, simulate the bulk-clear. +const auditBulkUpdate = (schema, sessData, knownErrorPaths, mode = 'edit') => { + let n = 0; + for (const field of schema.fields || []) { + if (field.type !== 'collection' || !field.schema) continue; + const rows = sessData[field.id]; + if (!Array.isArray(rows) || rows.length === 0) continue; + // Find the first switch/boolean cell — that's what BULK_UPDATE + // would target in production. + const boolCell = (field.schema.fields || []).find( + (f) => ['switch', 'boolean', 'checkbox'].includes(f.type) + ); + if (!boolCell) continue; + const newRows = rows.map((r) => ({ ...r, [boolCell.id]: false })); + const newSessData = { ...sessData, [field.id]: newRows }; + dispatchAndAudit( + schema, sessData, [field.id], newSessData, knownErrorPaths, mode + ); + n += 1; + } + return n; +}; + +// Batched-dispatch pass. Mirrors what useSchemaState's +// __pendingChangedPaths accumulator hands to validate when React +// batches multiple dispatches into one render commit. +// +// Generates batches of size 2..MAX_BATCH_SIZE so the pass covers +// the production cases that surfaced this whole bug class: +// 2 paths — two sibling fixedRows promises resolving in one +// microtask (the vacuum_table/vacuum_toast pattern). +// 3+ — multiple async loads in larger schemas (Function +// arguments + Parameters + Privileges all landing in +// one React commit; Index columns + with + storage_props). +// +// Path candidates per schema: +// - every top-level scalar +// - every collection root (covers ADD_ROW shape) +// - one collection-cell path per collection (first row, first +// scalar cell — covers SET_VALUE inside a row). +// +// Batches: enumerate every k-combination for k in 2..MAX_BATCH_SIZE. +// Exhaustive over candidates; bounded by combinatorial explosion via +// MAX_CANDIDATES + MAX_BATCHES_PER_SCHEMA. +const MAX_BATCH_SIZE = 4; // 2, 3, 4 — production fixedRows +// landings rarely batch >4 at once. +const MAX_CANDIDATES = 8; // top-N source candidates per schema +const MAX_BATCHES_PER_SCHEMA = 60; // generous cap to keep the +// full audit under ~30s +// across 87 schemas × 2 modes. + +const collectBatchCombos = (schema, sessData) => { + const candidates = []; + + for (const field of schema.fields || []) { + if (isScalarField(field, schema)) { + candidates.push([field.id]); + } else if (field.type === 'collection' && field.schema) { + candidates.push([field.id]); + const rows = sessData[field.id]; + if (Array.isArray(rows) && rows.length > 0) { + // Push the FIRST TWO scalar cells of row 0 — this covers + // the same-row, different-cells batching pattern (e.g. + // user types in name + a depChange fires on type in the + // same React commit). + let pushed = 0; + for (const cellField of field.schema.fields || []) { + if (!isScalarField(cellField, field.schema)) continue; + candidates.push([field.id, 0, cellField.id]); + pushed++; + if (pushed >= 2) break; + } + // Also push the SAME cell from row 1 — covers same-cell- + // different-row pairing (two sibling fixedRows landing in + // the same tick, both populating row 0). + if (rows.length > 1) { + for (const cellField of field.schema.fields || []) { + if (isScalarField(cellField, field.schema)) { + candidates.push([field.id, 1, cellField.id]); + break; + } + } + } + } + } + if (candidates.length >= MAX_CANDIDATES) break; + } + + // Enumerate k-combinations for k in 2..MAX_BATCH_SIZE. Lexicographic + // index iteration is fine since we cap the output size at + // MAX_BATCHES_PER_SCHEMA anyway. + const combos = []; + const recurse = (start, current, k) => { + if (combos.length >= MAX_BATCHES_PER_SCHEMA) return; + if (current.length === k) { + combos.push([...current]); + return; + } + for (let i = start; i < candidates.length; i++) { + current.push(candidates[i]); + recurse(i + 1, current, k); + current.pop(); + if (combos.length >= MAX_BATCHES_PER_SCHEMA) return; + } + }; + for (let k = 2; k <= MAX_BATCH_SIZE; k++) { + recurse(0, [], k); + if (combos.length >= MAX_BATCHES_PER_SCHEMA) break; + } + return combos; +}; + +// Apply one mutation to sessData given a single path. Returns the +// mutated sessData (shallow-cloned at top) or null if the mutation +// shape isn't supported (e.g. collection without getNewData). +const applyMutation = (schema, sessData, path) => { + if (path.length === 1) { + const field = (schema.fields || []).find((f) => f.id === path[0]); + if (field && isScalarField(field, schema)) { + return { ...sessData, + [path[0]]: mutateScalar(field, sessData[path[0]]) }; + } + if (field?.type === 'collection' + && typeof field.schema?.getNewData === 'function') { + try { + return { ...sessData, + [path[0]]: [...(sessData[path[0]] || []), field.schema.getNewData({})] }; + } catch { return null; } + } + return null; + } + if (path.length === 3) { + const [collId, rowIdx, cellId] = path; + const field = (schema.fields || []).find((f) => f.id === collId); + if (!field) return null; + const cellField = (field.schema?.fields || []).find((f) => f.id === cellId); + if (!cellField) return null; + const rows = sessData[collId] || []; + return { ...sessData, + [collId]: rows.map((r, i) => i === rowIdx + ? { ...r, [cellId]: mutateScalar(cellField, r?.[cellId]) } + : r) }; + } + return null; +}; + +// For each k-combination, rotate through ALL k positions so each +// path gets a turn as `primary` (the changedPath threaded through +// updateOptions). Production's __pendingChangedPaths.shift() makes +// the first-pushed path primary, but which dispatch fires first +// across a React batch isn't determinable from source. Every +// rotation covers a distinct "this path was primary" scenario. +// +// Full K! permutations of the extras would be ideal but blows the +// runtime budget at k=4 (24 perms × 60 combos × 87 schemas × 3 +// modes). K rotations is the sweet spot: every PATH gets primary +// coverage; the extras retain their natural order from +// candidate-emission. Catches the production-realistic +// "primary=A, extras=[B,C]" vs "primary=B, extras=[A,C]" vs +// "primary=C, extras=[A,B]" pattern set. +const MAX_ROTATIONS_PER_COMBO = Number.POSITIVE_INFINITY; + +const auditBatched = (schema, sessData, knownErrorPaths, mode = 'edit') => { + let n = 0; + const combos = collectBatchCombos(schema, sessData); + for (const batch of combos) { + // Apply all k mutations in one go (the same way the production + // accumulator collects k batched paths into one validate cycle). + let newSessData = sessData; + let appliedAll = true; + for (const p of batch) { + const next = applyMutation(schema, newSessData, p); + if (next === null) { appliedAll = false; break; } + newSessData = next; + } + if (!appliedAll) continue; + + // Try the first MAX_ROTATIONS_PER_COMBO rotations of the batch. + // Rotation r means: primary = batch[r], extras = the rest in + // their natural order. + const rotations = Math.min(batch.length, MAX_ROTATIONS_PER_COMBO); + for (let r = 0; r < rotations; r++) { + const primary = batch[r]; + const extras = batch.filter((_, i) => i !== r); + + schema.state = buildStateStub(sessData, knownErrorPaths); + schema.state.isNew = (mode !== 'edit'); + const prevOptions = schemaOptionsEvalulator({ + schema, data: sessData, + viewHelperProps: { mode }, + prevOptions: null, + }); + schema.state.data = newSessData; + + // Mirror SchemaState.validate's accumulator shape: primary path + // is changedPath; each additional path rides depDests AS IS, AND + // its own depDests join too. The walker's mustVisit becomes the + // union of all k paths + every path's depDests + known error + // paths — exactly what production now builds. + const depEntries = collectDepEntries(schema, newSessData); + const primaryDepDests = collectDepDests(depEntries, primary) || []; + const allDepDests = [...primaryDepDests]; + for (const extra of extras) { + allDepDests.push(extra); + const extraDeps = collectDepDests(depEntries, extra) || []; + for (const d of extraDeps) allDepDests.push(d); + } + for (const v of knownErrorPaths.values()) allDepDests.push(v); + + const mustVisit = [primary, ...extras, ...knownErrorPaths.values()]; + + schemaOptionsEvalulator({ + schema, data: newSessData, + viewHelperProps: { mode, incrementalOptions: true }, + prevOptions, changedPath: primary, + depDests: allDepDests, + }); + validateSchema( + schema, newSessData, + (path) => { + const flat = path.map((p) => String(p)).join('\x00'); + if (!knownErrorPaths.has(flat)) knownErrorPaths.set(flat, [...path]); + }, + [], null, mustVisit, true + ); + n += 1; + } // end rotation loop + } + return n; +}; + +// Distinguishes a canary divergence (which the audit MUST report) +// from any other exception that's a harness limitation — e.g. the +// schema's `baseFields` getter calls a function that depends on +// production-only constructor args, or accesses `this.nodeInfo.server` +// when nodeInfo wasn't supplied. We treat the latter as SKIPs so the +// harness focuses on real divergences. +const isDivergenceError = (e) => + e instanceof Error + && /(Incremental walker divergence|Incremental validator divergence)/ + .test(e.message); + +// Property-based fuzzing entry point. Given a SchemaClass, a mode, +// and a list of path-INDICES (each index selects into the schema's +// candidate-path list at audit time), runs ONE batched dispatch +// against the canary. +// +// Returns: { ok: true, candidates: N } on no-divergence, +// { ok: true, candidates: N, skipped: true, reason } on +// harness skip (schema needs constructor args we can't +// synthesize / no usable paths for the requested +// indices), OR +// { ok: false, error: '...', message: '...' } on +// divergence — fast-check shrinks the input to a +// minimal reproducer when this fires. +// +// `pathIndices`: array of non-negative integers; each is taken +// modulo the candidate list length, so any input array of length +// >= 2 maps to a valid k-combination. Duplicates are deduped +// silently so fast-check's shrinker can collapse same-path inputs +// without us special-casing. +export const fuzzBatchAgainst = (SchemaClass, mode, pathIndices) => { + const inst = tryInstantiate(SchemaClass); + if (!inst.ok) return { ok: true, skipped: true, reason: inst.reason, candidates: 0 }; + const schema = inst.instance; + + let sessData; + try { + if (Array.isArray(schema.fields)) { + sessData = (typeof schema.getNewData === 'function') + ? schema.getNewData({}) : {}; + } else { + sessData = {}; + } + seedCollections(schema, sessData); + } catch (e) { + return { ok: true, skipped: true, + reason: `setup: ${e.message.split('\n')[0]}`, candidates: 0 }; + } + + // Edit-mode seed (mirror auditSchema). + if (mode === 'edit') { + const idAttr = schema.idAttribute || 'id'; + if (sessData[idAttr] === undefined || sessData[idAttr] === '') { + sessData[idAttr] = 9999; + } + } + + // For the fuzzer we want flat candidate paths (not the + // k-combinations collectBatchCombos returns). Pull from the + // schema's individual paths the same way collectBatchCombos's + // internal candidate list does, bounded by MAX_CANDIDATES. + const flat = []; + for (const field of schema.fields || []) { + if (isScalarField(field, schema)) { + flat.push([field.id]); + } else if (field.type === 'collection' && field.schema) { + flat.push([field.id]); + const rows = sessData[field.id]; + if (Array.isArray(rows) && rows.length > 0) { + for (const cellField of field.schema.fields || []) { + if (isScalarField(cellField, field.schema)) { + flat.push([field.id, 0, cellField.id]); + break; + } + } + } + } + if (flat.length >= MAX_CANDIDATES) break; + } + if (flat.length < 2) { + return { ok: true, skipped: true, + reason: 'fewer than 2 candidate paths', + candidates: flat.length }; + } + + // Map indices → paths, dedupe by stringified path. Need at least + // 2 distinct paths to form a batch. + const seen = new Set(); + const batch = []; + for (const i of pathIndices) { + const p = flat[((i % flat.length) + flat.length) % flat.length]; + const key = p.join('\x00'); + if (seen.has(key)) continue; + seen.add(key); + batch.push(p); + } + if (batch.length < 2) { + return { ok: true, skipped: true, + reason: 'all path indices deduped to <2 distinct', + candidates: flat.length }; + } + + // Apply mutations in order. + let newSessData = sessData; + for (const p of batch) { + const next = applyMutation(schema, newSessData, p); + if (next === null) { + return { ok: true, skipped: true, + reason: `applyMutation failed for ${JSON.stringify(p)}`, + candidates: flat.length }; + } + newSessData = next; + } + + const [primary, ...extras] = batch; + schema.state = { data: sessData, errors: {}, isReady: true, + isNew: (mode !== 'edit'), _knownErrorPaths: new Map() }; + + const knownErrorPaths = new Map(); + const setupAudit = () => { + window.__INCREMENTAL_AUDIT__ = true; + window.__throw_on_canary_divergence__ = true; + window.__incremental_canary_max_per_session__ + = Number.POSITIVE_INFINITY; + _resetCanaryFireCount(); + _resetValidationCanaryFireCount(); + }; + const teardownAudit = () => { + delete window.__INCREMENTAL_AUDIT__; + delete window.__throw_on_canary_divergence__; + delete window.__incremental_canary_max_per_session__; + }; + + // Mirror production's mount-time validate: SchemaState.validate + // runs a FULL walk on mount which populates _knownErrorPaths + // BEFORE any user dispatch. Without this prep, the fuzzer's + // first incremental walk prunes paths that production wouldn't + // (because production has them in mustVisit via the error + // tracker) — false-positive divergence on schemas with pre- + // existing validation errors. + try { + validateSchema( + schema, sessData, + (p) => { + if (!Array.isArray(p)) return; + const k = p.map((s) => String(s)).join('\x00'); + if (!knownErrorPaths.has(k)) knownErrorPaths.set(k, [...p]); + }, + [], null, null, true, + ); + } catch (e) { + // Initial discovery failure — fuzzer treats as harness skip. + // Include the underlying message so a shrunk fast-check + // counterexample names WHY the schema's initial validate + // failed, not just THAT it failed. + return { ok: true, skipped: true, + reason: `initial validate threw: ${e?.message?.split('\n')[0] || e}`, + candidates: flat.length }; + } + + setupAudit(); + try { + const prevOptions = schemaOptionsEvalulator({ + schema, data: sessData, + viewHelperProps: { mode }, + prevOptions: null, + }); + schema.state.data = newSessData; + + const depEntries = collectDepEntries(schema, newSessData); + const primaryDepDests = collectDepDests(depEntries, primary) || []; + const allDepDests = [...primaryDepDests]; + for (const extra of extras) { + allDepDests.push(extra); + const extraDeps = collectDepDests(depEntries, extra) || []; + for (const d of extraDeps) allDepDests.push(d); + } + // Known error paths ride mustVisit AND depDests — matches what + // SchemaState.validate assembles in production. + for (const v of knownErrorPaths.values()) allDepDests.push(v); + const mustVisit = [primary, ...extras, ...knownErrorPaths.values()]; + + schemaOptionsEvalulator({ + schema, data: newSessData, + viewHelperProps: { mode, incrementalOptions: true }, + prevOptions, changedPath: primary, + depDests: allDepDests, + }); + validateSchema( + schema, newSessData, + (path) => { + const flat2 = path.map((p) => String(p)).join('\x00'); + if (!knownErrorPaths.has(flat2)) knownErrorPaths.set(flat2, [...path]); + }, + [], null, mustVisit, true, + ); + return { ok: true, candidates: flat.length }; + } catch (e) { + if (isDivergenceError(e)) { + return { ok: false, error: 'divergence', + message: e.message.split('\n').slice(0, 8).join('\n'), + batch }; + } + // Harness limitation — schema's closure crashed on something + // we can't synthesize. Surface as skipped. + return { ok: true, skipped: true, + reason: `dispatch error: ${e.message.split('\n')[0]}`, + candidates: flat.length }; + } finally { + teardownAudit(); + // Suppress any console.error the inner walks pushed; the + // dispatchAndAudit/audit_harness path normally does this via + // its consoleErrorSpy bracket. Replicate here for parity. + if (typeof console !== 'undefined' + && typeof console.error?.mockClear === 'function') { + console.error.mockClear(); + } + } +}; + +// `mode` selects the viewHelperProps.mode the audit walks in. The +// walker's `isModeSupportedByField` filters fields by `field.mode`, +// so create-only and edit-only fields exercise different code paths. +// Default is 'edit' to preserve historical behavior; the spec runs +// both passes per schema. +export const auditSchema = (SchemaClass, { mode = 'edit' } = {}) => { + const inst = tryInstantiate(SchemaClass); + if (!inst.ok) { + return { skipped: true, skipReason: inst.reason, dispatches: 0 }; + } + const schema = inst.instance; + + // `schema.fields` (or `baseFields`) often references constructor + // args that audit-time `new SchemaClass()` doesn't provide. Try + // accessing fields + getting default data; failures here mean the + // schema needs a real production fixture — skip cleanly. + let sessData; + try { + // Force `fields` resolution to surface baseFields errors early. + if (Array.isArray(schema.fields)) { + sessData = (typeof schema.getNewData === 'function') + ? schema.getNewData({}) + : {}; + } else { + sessData = {}; + } + } catch (e) { + return { + skipped: true, + skipReason: `schema setup failed: ${e.message.split('\n')[0]}`, + dispatches: 0, + }; + } + + // Edit-mode seed: real edit dialogs load an existing record with + // identifier + populated fields. Schemas detect "is this a new + // record?" via `obj.isNew(state)` which checks the idAttribute + // (default 'id', commonly 'oid' in pgAdmin). Without an + // idAttribute value, `isNew()` returns true even in 'edit' mode + // and closures that branch on it take the create-mode path — + // which is what we just tested in the create pass. Seed a + // sentinel that flips isNew() to false and populates likely + // string fields with non-default values so edit-mode-specific + // closures (e.g. comparing current vs initial values to detect + // user edits) actually exercise. + if (mode === 'edit') { + const idAttr = schema.idAttribute || 'id'; + if (sessData[idAttr] === undefined || sessData[idAttr] === '') { + sessData[idAttr] = 9999; // sentinel "already-exists" oid + } + // Stamp scalar text fields with non-empty values so + // change-detection closures see an existing baseline. We do + // NOT touch ids, switches, or other-typed fields — only + // pure-text/multiline/sql defaults get a sentinel. + for (const field of schema.fields || []) { + if (field.id === idAttr) continue; + if (!['text', 'multiline', 'sql'].includes(field.type)) continue; + if (sessData[field.id] === '' || sessData[field.id] === undefined) { + sessData[field.id] = `audit_seed_${field.id}`; + } + } + } + try { seedCollections(schema, sessData); } + catch (e) { + return { + skipped: true, + skipReason: `collection seeding failed: ${e.message.split('\n')[0]}`, + dispatches: 0, + }; + } + + // Audit mode: route the wrappers through the canaries, and make + // divergence throw so jest catches it. setup-jest.js sets + // NODE_ENV=test which the canary's defaultReport requires for the + // throw branch. + window.__INCREMENTAL_AUDIT__ = true; + window.__throw_on_canary_divergence__ = true; + // Disable the throttle for audit — every dispatch must be checked, + // not just the first few per session. Infinity > DEFAULT_MAX_CANARY_FIRES + // means the cap is never hit. Also reset the counters so prior tests + // in the same jest worker don't leak fire-count state into this audit. + window.__incremental_canary_max_per_session__ = Number.POSITIVE_INFINITY; + _resetCanaryFireCount(); + _resetValidationCanaryFireCount(); + + // Suppress console.error during the audit. Real divergences throw + // via the canary (window.__throw_on_canary_divergence__) and so + // bypass the console path entirely. The console.error calls that + // DO happen during audit come from in-schema fallback handlers — + // e.g. utils.sprintf catching TypeError when its format string is + // undefined because audit-time sessData didn't seed the field the + // closure expected. Those are harness-limitation noise, not + // walker bugs; let them pass without tripping setup-jest's + // afterEach `expect(console.error).not.toHaveBeenCalled()` check. + const consoleErrorSpy = (typeof console !== 'undefined' + && typeof console.error?.mockImplementation === 'function') + ? console.error : null; + let originalImpl; + if (consoleErrorSpy) { + originalImpl = consoleErrorSpy.getMockImplementation(); + consoleErrorSpy.mockImplementation(() => {}); + } + + // Tracker that mirrors SchemaState._knownErrorPaths. Populated by + // an initial full validation walk (so pre-existing errors enter the + // tracker before the first incremental dispatch), and updated after + // each subsequent walk. This is what makes the audit's per-dispatch + // mustVisit faithfully reproduce production safety semantics. + const knownErrorPaths = new Map(); + try { + validateSchema( + schema, sessData, + (path) => { + if (!Array.isArray(path)) return; + const flat = path.map((p) => String(p)).join('\x00'); + if (!knownErrorPaths.has(flat)) knownErrorPaths.set(flat, [...path]); + }, + [], null, null, true + ); + } catch { + // Initial discovery failure (schema needs args we can't supply). + // The dispatch loop will catch the same error and report skip. + } + + let dispatches = 0; + let skipReason = null; + // Aggregate harness-limitation skip reasons across passes so a + // closure crash in ONE pass doesn't abort the other 7. Real + // divergences still propagate (isDivergenceError check below). + const passSkips = []; + const runPass = (label, fn) => { + try { return fn(); } + catch (e) { + if (isDivergenceError(e)) throw e; + passSkips.push(`${label}: ${e.message.split('\n')[0]}`); + return 0; + } + }; + try { + try { + dispatches += runPass('scalars', + () => auditScalars(schema, sessData, knownErrorPaths, mode)); + // Nested-* group containers share the parent's data level but + // live in the walker's nested branch — different code path. + dispatches += runPass('nested', + () => auditNestedFields(schema, sessData, knownErrorPaths, mode)); + dispatches += runPass('coll-cells', + () => auditCollectionCells(schema, sessData, knownErrorPaths, mode)); + dispatches += runPass('coll-structure', + () => auditCollectionStructure(schema, sessData, knownErrorPaths, mode)); + // MOVE_ROW + BULK_UPDATE passes — exercise the two + // path-bearing action types the create/edit dispatch passes + // don't cover. Production drives both via DataGridView (drag- + // reorder and bulk-toggle); the walker handles them the same + // way it handles ADD/DELETE (changedPath = collection root) + // but the AUDIT didn't dispatch them. + dispatches += runPass('move-row', + () => auditMoveRow(schema, sessData, knownErrorPaths, mode)); + dispatches += runPass('bulk-update', + () => auditBulkUpdate(schema, sessData, knownErrorPaths, mode)); + // Batched-dispatch pass — checks the post-fix accumulator handles + // multi-path validates the same way single-path ones do. + dispatches += runPass('batched', + () => auditBatched(schema, sessData, knownErrorPaths, mode)); + // Sequence pass — multi-step user flow with PERSISTED prev + // across all 10 dispatches. Catches compounding bugs where + // dispatch K's prev is dispatch K-1's stale output. + dispatches += runPass('sequence', + () => auditSequence(schema, sessData, knownErrorPaths, mode)); + // If only SOME passes hit harness limits, the audit still + // succeeded on the rest. Report skip ONLY when zero + // dispatches landed AND we have skip reasons — that's the + // "schema unaudited" case. Partial audits proceed silently + // (we have coverage from the passes that ran). + if (dispatches === 0 && passSkips.length > 0) { + skipReason = `all passes skipped: ${passSkips.join('; ')}`; + } + } catch (e) { + // Re-throw real divergences so jest catches them as test + // failures. Anything else not caught by runPass (e.g. an + // exception thrown synchronously OUTSIDE a pass body) is a + // harness limitation. Report as SKIP. + if (isDivergenceError(e)) throw e; + skipReason = `dispatch error: ${e.message.split('\n')[0]}`; + } + } finally { + delete window.__INCREMENTAL_AUDIT__; + delete window.__throw_on_canary_divergence__; + delete window.__incremental_canary_max_per_session__; + if (consoleErrorSpy) { + // Clear the captured calls (harness-noise that we suppressed) + // and restore the prior implementation. mockClear keeps the + // spy attached (setup-jest's afterEach still needs it as a + // mock); only the .mock.calls history is wiped. + consoleErrorSpy.mockClear(); + if (originalImpl) consoleErrorSpy.mockImplementation(originalImpl); + else consoleErrorSpy.mockImplementation(undefined); + } + } + + if (skipReason) return { skipped: true, skipReason, dispatches }; + return { skipped: false, dispatches }; +}; diff --git a/web/pgadmin/static/js/SchemaView/SchemaState/common.js b/web/pgadmin/static/js/SchemaView/SchemaState/common.js index 0b507e843e0..fda678371e5 100644 --- a/web/pgadmin/static/js/SchemaView/SchemaState/common.js +++ b/web/pgadmin/static/js/SchemaView/SchemaState/common.js @@ -19,6 +19,8 @@ import { import BaseUISchema from '../base_schema.ui'; import { isModeSupportedByField, isObjectEqual, isValueEqual } from '../common'; +import { pathOverlaps } from '../options'; +import { measure } from '../perf'; export const SCHEMA_STATE_ACTIONS = { @@ -27,6 +29,14 @@ export const SCHEMA_STATE_ACTIONS = { ADD_ROW: 'add_row', DELETE_ROW: 'delete_row', MOVE_ROW: 'move_row', + // RERENDER is reserved but currently UNUSED. No `case` in the + // reducer (so it falls through to the default __changeId++ which + // forces a re-validate without state change), and no production + // call site dispatches it. If a future caller starts using it, + // they MUST add it to the reducer's PATH_BEARING_ACTIONS set in + // reducer.js so the bypass guard catches accidental raw + // sessDispatch usage — leaving it out lets dispatches slip past + // the __pendingChangedPaths accumulator silently. RERENDER: 'rerender', CLEAR_DEFERRED_QUEUE: 'clear_deferred_queue', DEFERRED_DEPCHANGE: 'deferred_depchange', @@ -116,6 +126,15 @@ export function getCollectionDiffInEditMode( export function getSchemaDataDiff( topSchema, initData, sessData, mode, keepCid, stringify=false, includeSkipChange=true +) { + return measure('getSchemaDataDiff', () => _getSchemaDataDiffImpl( + topSchema, initData, sessData, mode, keepCid, stringify, includeSkipChange + )); +} + +function _getSchemaDataDiffImpl( + topSchema, initData, sessData, mode, keepCid, + stringify, includeSkipChange ) { const isEditMode = mode === 'edit'; @@ -246,27 +265,88 @@ export function getSchemaDataDiff( return res; } +// Decide whether `checkUniqueCol` needs to re-run for this collection +// given the incremental must-visit set. +// +// Run if ANY mustVisit path either: +// - sits at or above the collection's path (structural change, or a +// pre-existing uniqueness error that was added to mustVisit by +// SchemaState.validate), OR +// - points at a direct row-field whose id is in `field.uniqueCol` +// (a change to a uniqueness-participating value). +// +// A deep path inside a nested sub-collection (length > currPath+2) does +// NOT trigger the outer collection's uniqueCol — the value reachable at +// that path can't be a member of the outer row schema's fields. +function shouldRunUniqueCol(field, currPath, mustVisit) { + if (!Array.isArray(mustVisit)) return true; // full walk + if (!Array.isArray(field.uniqueCol) || field.uniqueCol.length === 0) + return false; + return mustVisit.some((p) => { + if (!Array.isArray(p)) return false; + if (p.length <= currPath.length) { + // p is a prefix of (or equal to) currPath -> structural or above. + for (let i = 0; i < p.length; i++) { + if (String(p[i]) !== String(currPath[i])) return false; + } + return true; + } + // p deeper than currPath. Must be an immediate row.field path. + for (let i = 0; i < currPath.length; i++) { + if (String(p[i]) !== String(currPath[i])) return false; + } + if (p.length !== currPath.length + 2) return false; + return field.uniqueCol.includes(p[p.length - 1]); + }); +} + export function validateCollectionSchema( - field, sessData, accessPath, setError + field, sessData, accessPath, setError, mustVisit=null, collectAll=false ) { const rows = sessData[field.id] || []; const currPath = accessPath.concat(field.id); + let anyError = false; if(rows.length > field.maxCount) { setError(currPath, gettext('Maximum %s \'%s\' allowed',field.maxCount, field.label)); - return true; + if (!collectAll) return true; + anyError = true; } // Loop through data. for(const [rownum, row] of rows.entries()) { - if(validateSchema( - field.schema, row, setError, currPath.concat(rownum), field.label - )) { - return true; + const rowGlobalPath = currPath.concat(rownum); + // Incremental prune: skip rows whose path doesn't overlap any + // must-visit path. `mustVisit=null` means full walk. + if (Array.isArray(mustVisit) + && !mustVisit.some((p) => pathOverlaps(rowGlobalPath, p))) { + continue; + } + let rowHadError = false; + try { + rowHadError = validateSchema( + field.schema, row, setError, rowGlobalPath, field.label, mustVisit, collectAll + ); + } catch (e) { + // collectAll mode is used for discovery (audit + multi-path + // tracker); a single row's validate() throwing should not abort + // iteration over siblings — we want to learn about every + // erroring row in one pass. Legacy callers (collectAll=false) + // see the original throw-propagating behavior. + if (!collectAll) throw e; + rowHadError = true; + } + if (rowHadError) { + if (!collectAll) return true; + anyError = true; } } - // Validate duplicate rows. + // Validate duplicate rows. Skip the O(N) scan when the change can't + // affect uniqueness (typing a non-uniqueCol field, or a deep change + // inside a nested sub-collection). + if (!shouldRunUniqueCol(field, currPath, mustVisit)) return anyError; + const dupInd = checkUniqueCol(rows, field.uniqueCol); if(dupInd > 0) { @@ -285,13 +365,77 @@ export function validateCollectionSchema( return true; } - return false; + return anyError; } +let __validateDepth = 0; +// `collectAll` (default false for back-compat): when true, the walker +// does NOT short-circuit at the first error — it iterates every +// field and every row, calling setError for each one. The return +// value is still hadError (boolean) but reflects "any error +// anywhere" rather than "first error". +// +// Used by: +// - SchemaState.validate, so the multi-path mustVisit tracker +// learns about ALL error paths from the initial full walk +// (not just the first short-circuit point). +// - validation_canary's two inner walks, so the diff reflects +// true coverage of missed errors instead of short-circuit +// timing artifacts. export function validateSchema( - schema, sessData, setError, accessPath=[], collLabel=null + schema, sessData, setError, accessPath=[], collLabel=null, mustVisit=null, + collectAll=false +) { + // Only measure the outermost entry. The impl recurses through itself for + // nested schemas, and we don't want to double-count. + if (__validateDepth === 0) { + __validateDepth++; + try { + // Canary gate (build-time eliminated in production). DefinePlugin + // substitutes `process.env.__CANARY_BUILD__` at build time: + // - production (no CANARY_BUILD env): becomes `false`, the + // entire branch (including the require'd canary module) is + // dead-code-eliminated and tree-shaken. + // - canary build (CANARY_BUILD=true): becomes `true`, branch + // kept; runtime gate is window.__INCREMENTAL_AUDIT__. + // - test env: setup-jest.js sets CANARY_BUILD=true so the + // audit path is testable. + // Depth was just incremented above; the canary's two inner + // validateSchema calls enter at depth>0 and skip the gate. + if ( + process.env.__CANARY_BUILD__ + && typeof window !== 'undefined' + && window.__INCREMENTAL_AUDIT__ + ) { + const { runValidationCanary } = require('./validation_canary'); + return measure('validateSchema', () => runValidationCanary({ + schema, sessData, setError, accessPath, collLabel, mustVisit, + collectAll, + })); + } + return measure('validateSchema', () => _validateSchemaImpl( + schema, sessData, setError, accessPath, collLabel, mustVisit, collectAll + )); + } finally { + __validateDepth--; + } + } + return _validateSchemaImpl( + schema, sessData, setError, accessPath, collLabel, mustVisit, collectAll + ); +} + +function _validateSchemaImpl( + schema, sessData, setError, accessPath=[], collLabel=null, mustVisit=null, + collectAll=false ) { sessData = sessData || {}; + let anyError = false; + // Short-circuit helper. Returns true if the caller should keep going + // even after a hit. When collectAll is false (legacy), we exit at + // the first error (preserving the historical signature). When it's + // true, we accumulate `anyError` and continue. + const stopHere = () => !collectAll; for(const field of schema.fields) { // Skip id validation @@ -304,12 +448,19 @@ export function validateSchema( // A collection is an array. if(field.type === 'collection') { - if (validateCollectionSchema(field, sessData, accessPath, setError)) - return true; + if (validateCollectionSchema( + field, sessData, accessPath, setError, mustVisit, collectAll + )) { + if (stopHere()) return true; + anyError = true; + } } // A nested schema ? Recurse - else if(validateSchema(field.schema, sessData, setError, accessPath)) { - return true; + else if(validateSchema( + field.schema, sessData, setError, accessPath, null, mustVisit, collectAll + )) { + if (stopHere()) return true; + anyError = true; } } else { // Normal field, default validations. @@ -330,29 +481,37 @@ export function validateSchema( collLabel && gettext('%s in %s', field.label, collLabel) ) || field.noEmptyLabel || field.label; - if (setErrorOnMessage(emptyValidator(label, value))) - return true; + if (setErrorOnMessage(emptyValidator(label, value))) { + if (stopHere()) return true; + anyError = true; + continue; + } } if(field.type === 'int') { if (setErrorOnMessage( integerValidator(field.label, value) || minMaxValidator(field.label, value, field.min, field.max) - )) - return true; + )) { + if (stopHere()) return true; + anyError = true; + } } else if(field.type === 'numeric') { if (setErrorOnMessage( numberValidator(field.label, value) || minMaxValidator(field.label, value, field.min, field.max) - )) - return true; + )) { + if (stopHere()) return true; + anyError = true; + } } } } - return schema.validate( + const userValidatorHadError = schema.validate( sessData, (id, message) => setError(accessPath.concat(id), message) ); + return userValidatorHadError || anyError; } export const getDepChange = (currPath, newState, oldState, action) => { diff --git a/web/pgadmin/static/js/SchemaView/SchemaState/index.js b/web/pgadmin/static/js/SchemaView/SchemaState/index.js index 6b5fc3b6f03..43c7244305d 100644 --- a/web/pgadmin/static/js/SchemaView/SchemaState/index.js +++ b/web/pgadmin/static/js/SchemaView/SchemaState/index.js @@ -11,6 +11,7 @@ import { SchemaState } from './SchemaState'; import { SchemaStateContext } from './context'; import { SCHEMA_STATE_ACTIONS } from './common'; import { sessDataReducer } from './reducer'; +import { registerSchema, getRegisteredSchemas } from './schema_registry'; export { @@ -18,4 +19,6 @@ export { SchemaState, SchemaStateContext, sessDataReducer, + registerSchema, + getRegisteredSchemas, }; diff --git a/web/pgadmin/static/js/SchemaView/SchemaState/reducer.js b/web/pgadmin/static/js/SchemaView/SchemaState/reducer.js index 4892d217427..063c439fb39 100644 --- a/web/pgadmin/static/js/SchemaView/SchemaState/reducer.js +++ b/web/pgadmin/static/js/SchemaView/SchemaState/reducer.js @@ -8,6 +8,8 @@ ////////////////////////////////////////////////////////////// import _ from 'lodash'; + +import { logAction, record } from '../perf'; import { SCHEMA_STATE_ACTIONS, getDepChange, } from './common'; @@ -44,8 +46,59 @@ const getDeferredDepChange = (currPath, newState, oldState, action) => { * The path for key1 is '[key1]'. * The state starts with path '[]'. */ +// Action types that carry a `path` and must therefore be dispatched +// through useSchemaState.sessDispatchWithListener (so the +// __pendingChangedPaths accumulator catches them). INIT and +// CLEAR_DEFERRED_QUEUE are exempt — INIT resets everything (so a +// full walk is the right next move anyway), and CLEAR_DEFERRED_QUEUE +// is internal plumbing. +const PATH_BEARING_ACTIONS = new Set([ + SCHEMA_STATE_ACTIONS.SET_VALUE, + SCHEMA_STATE_ACTIONS.ADD_ROW, + SCHEMA_STATE_ACTIONS.DELETE_ROW, + SCHEMA_STATE_ACTIONS.MOVE_ROW, + SCHEMA_STATE_ACTIONS.BULK_UPDATE, + SCHEMA_STATE_ACTIONS.DEFERRED_DEPCHANGE, +]); + export const sessDataReducer = (state, action) => { + const reducerStart = performance.now(); + const label = `reducer.${action.type}`; + + // Bypass detection (canary builds only — substituted to literal + // `false` in production, so the whole `if` tree-shakes out). + // If a path-bearing action arrives without the __viaListener + // sentinel, somebody added a sessDispatch call outside + // sessDispatchWithListener — they need to switch to the listener + // path so changedPath joins the accumulator, otherwise the + // incremental walker silently falls back to a full walk for that + // dispatch and any pending paths are processed without it. + if (process.env.__CANARY_BUILD__ + && PATH_BEARING_ACTIONS.has(action.type) + && !action.__viaListener) { + // console.error (not warn) so setup-jest's afterEach assertion + // `expect(console.error).not.toHaveBeenCalled()` fails the suite + // when a bypass slips in. A warning would be drowned in a noisy + // CI log; an error breaks the test, which is the whole point of + // the guard. In production this is dead-code-eliminated via the + // `process.env.__CANARY_BUILD__` gate. + + console.error( + `[schemaview] dispatcher bypass: action type "${action.type}" ` + + 'reached the reducer without going through ' + + 'sessDispatchWithListener. The incremental walker will run as ' + + 'a full walk for this commit; if multiple paths batch, the ' + + 'accumulator will miss this one. Route the dispatch through ' + + '`dataDispatch` (the listener-wrapped one returned by ' + + 'useSchemaState) instead of a raw sessDispatch.', + { path: action.path, type: action.type } + ); + } + + const cloneStart = performance.now(); let data = _.cloneDeep(state); + record('reducer.cloneDeep', performance.now() - cloneStart); + let rows, cid, deferredList; data.__deferred__ = data.__deferred__ || []; @@ -67,7 +120,14 @@ export const sessDataReducer = (state, action) => { deferredList = getDeferredDepChange( action.path, _.cloneDeep(data), state, action ); - data.__deferred__ = deferredList || []; + // APPEND rather than replace — multiple SET_VALUEs in the same + // React batch each contribute their deferred promises to the queue. + // Replacing the array would lose still-pending promises from the + // previous action. The drain useEffect in useSchemaState then + // processes the full list and `CLEAR_DEFERRED_QUEUE` empties it. + if (deferredList && deferredList.length > 0) { + data.__deferred__ = (data.__deferred__ || []).concat(deferredList); + } break; case SCHEMA_STATE_ACTIONS.ADD_ROW: @@ -120,6 +180,10 @@ export const sessDataReducer = (state, action) => { data.__changeId = (data.__changeId || 0) + 1; + const totalDt = performance.now() - reducerStart; + record(label, totalDt); + logAction(action.type, totalDt, { path: action.path }); + return data; }; diff --git a/web/pgadmin/static/js/SchemaView/SchemaState/schema_registry.js b/web/pgadmin/static/js/SchemaView/SchemaState/schema_registry.js new file mode 100644 index 00000000000..36e7df54afd --- /dev/null +++ b/web/pgadmin/static/js/SchemaView/SchemaState/schema_registry.js @@ -0,0 +1,58 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2026, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +// Schema registry. Per design D10, each default-exported BaseUISchema +// subclass wraps its export in `registerSchema()`: +// +// class TableSchema extends BaseUISchema { ... } +// export default registerSchema(TableSchema); +// +// The audit harness consumes `getRegisteredSchemas()` to enumerate +// schemas without grep / AST walks / import-list maintenance. An +// ESLint rule (see eslint-rules/) enforces the wrapping at lint time. +// +// The registry has no side effects beyond the Map mutation — it is +// NOT a hook into validation, dispatch, or rendering. It is purely an +// enumeration mechanism for tooling. + +const _registry = new Map(); + +// Records `SchemaClass` in the registry keyed by `SchemaClass.name`, +// then returns the class unchanged so it can be the value of an +// `export default registerSchema(...)` expression. +// +// Throws on non-class arguments or anonymous classes — both would +// silently corrupt the registry. Failing at module load surfaces the +// mistake immediately rather than at audit time when the harness +// notices a missing entry. +export const registerSchema = (SchemaClass) => { + if (typeof SchemaClass !== 'function') { + throw new TypeError( + 'registerSchema: argument must be a class (got ' + + (SchemaClass === null ? 'null' : typeof SchemaClass) + ')' + ); + } + if (!SchemaClass.name) { + throw new TypeError( + 'registerSchema: anonymous classes cannot be registered — give ' + + 'the class a name so the audit harness can identify it.' + ); + } + _registry.set(SchemaClass.name, SchemaClass); + return SchemaClass; +}; + +// Returns a defensive snapshot of the registry. Callers (audit harness, +// CI scripts) can iterate freely without mutating the source-of-truth. +export const getRegisteredSchemas = () => new Map(_registry); + +// Test-only helper. Module-scoped state would otherwise leak between +// specs that register fixture schemas with the same name. Not surfaced +// in the SchemaState index — only the spec imports it directly. +export const _resetRegistry = () => { _registry.clear(); }; diff --git a/web/pgadmin/static/js/SchemaView/SchemaState/store.js b/web/pgadmin/static/js/SchemaView/SchemaState/store.js index 991d9593c8e..8ce88937c3a 100644 --- a/web/pgadmin/static/js/SchemaView/SchemaState/store.js +++ b/web/pgadmin/static/js/SchemaView/SchemaState/store.js @@ -1,10 +1,11 @@ import _ from 'lodash'; import { isValueEqual } from '../common'; +import { measure, record } from '../perf'; import { flatPathGenerator } from './common'; -export const createStore = (initialState) => { +export const createStore = (initialState, storeName = 'store') => { let state = initialState; const listeners = new Set(); @@ -14,17 +15,21 @@ export const createStore = (initialState) => { // Exposed functions // Don't attempt to manipulate the state directly. const getState = () => state; - const setState = (nextState) => { + const setState = (nextState) => measure(`store.${storeName}.setState`, () => { const prevState = state; state = _.clone(nextState); - if (isValueEqual(state, prevState)) return; + const topEqStart = performance.now(); + const topEq = isValueEqual(state, prevState); + record(`store.${storeName}.topEqualityCheck`, performance.now() - topEqStart); + if (topEq) return; listeners.forEach((listener) => { listener(); }); const changeMemo = new Map(); + let fanout = 0; pathListeners.forEach((pathListener) => { const [ path, listener ] = pathListener; @@ -46,10 +51,14 @@ export const createStore = (initialState) => { const [isSame, pathNextValue, pathPrevValue] = changeMemo.get(flatPath); if (!isSame) { + fanout++; listener(pathNextValue, pathPrevValue); } }); - }; + + record(`store.${storeName}.subscribers`, pathListeners.size); + record(`store.${storeName}.fanout`, fanout); + }); const get = (path = []) => (_.get(state, path)); const set = (arg) => { let nextState = _.isFunction(arg) ? arg(_.cloneDeep(state)) : arg; diff --git a/web/pgadmin/static/js/SchemaView/SchemaState/validation_canary.js b/web/pgadmin/static/js/SchemaView/SchemaState/validation_canary.js new file mode 100644 index 00000000000..5b33385d0db --- /dev/null +++ b/web/pgadmin/static/js/SchemaView/SchemaState/validation_canary.js @@ -0,0 +1,305 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2026, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +// Divergence canary for the incremental validateSchema walker. +// +// Mirrors the options canary (SchemaView/options/canary.js). Runs +// validateSchema twice — once with the actual `mustVisit` (incremental +// mode), once with `mustVisit = null` (full walk) — and diffs the two +// error maps produced via setError. Any path where the messages differ +// (or that's present in one walk but not the other) is a divergence; +// it points at a row whose validator would have set/cleared an error +// under the full walk but was silently pruned by the incremental walk. +// +// The walker is functional-with-side-effects: the setError callback +// accumulates results into an external map. Each canary walk uses its +// OWN capturing setError so the two runs don't pollute each other or +// the caller's real setError. After comparison, the canary replays the +// FULL walk's errors to the caller's setError — that's the +// authoritative result. +// +// Build-time gating: see common.js's validateSchema wrapper for the +// `process.env.__CANARY_BUILD__` substitution. In production builds +// (without CANARY_BUILD=true) the conditional is dead-code-eliminated +// and the import of this module is tree-shaken — zero canary code in +// the production bundle. + +import { validateSchema } from './common'; + +// Walk-throttle: production sampling pays the cost of a second full +// walk per validation pass. In a sampled session this would noticeably +// degrade keystroke latency. After MAX_CANARY_FIRES per session, just +// run the full walk and skip the comparison. Callers that pass an +// explicit onDivergence callback bypass the throttle (tests). +let _canaryFireCount = 0; +const DEFAULT_MAX_CANARY_FIRES = 5; + +const getMaxCanaryFires = () => { + // `typeof === 'number'` instead of Number.isFinite so the audit + // harness can pass Infinity to disable the throttle. See the + // matching note in options/canary.js. + if (typeof window !== 'undefined' + && typeof window.__incremental_canary_max_per_session__ === 'number' + && window.__incremental_canary_max_per_session__ >= 0) { + return window.__incremental_canary_max_per_session__; + } + return DEFAULT_MAX_CANARY_FIRES; +}; + +// Errors are accumulated as a list of {path: [...], message: string}. +// validateSchema short-circuits on the first row that sets an error, +// but a single validate() call can set multiple errors before +// returning, so the list may have multiple entries per walk. +// +// '\x00' separates path segments for map keys — neither identifiers +// nor row indices can contain it, so collisions are impossible. +const KEY_SEP = '\x00'; +const pathKey = (path) => path.map((p) => String(p)).join(KEY_SEP); + +const diffErrors = (incrementalList, fullList) => { + const diffs = []; + const incMap = new Map(); + const fullMap = new Map(); + for (const e of incrementalList) incMap.set(pathKey(e.path), e); + for (const e of fullList) fullMap.set(pathKey(e.path), e); + + const allKeys = new Set([...incMap.keys(), ...fullMap.keys()]); + for (const k of allKeys) { + const inc = incMap.get(k); + const full = fullMap.get(k); + if (!inc || !full || inc.message !== full.message) { + diffs.push({ + path: (inc || full).path, + incremental: inc?.message, + full: full?.message, + }); + } + } + return diffs; +}; + +// Per design D6: each entry is {fieldPath, reason, addedAt?, expiresAt?} +// where fieldPath segments may include '*' wildcards. Filters out +// known false positives whose host schema has been audited and is +// known-safe. +const applyAllowlist = (diffs, allowlist) => { + if (!allowlist || allowlist.length === 0) return diffs; + return diffs.filter((d) => !allowlist.some((entry) => { + const ap = entry.fieldPath; + if (!Array.isArray(ap) || ap.length !== d.path.length) return false; + return ap.every((seg, i) => seg === '*' || seg === String(d.path[i])); + })); +}; + +// Treat unparseable `expiresAt` as expired — the design's 90-day TTL +// enforcement in CI depends on identifying expired entries; silently +// keeping a typo'd date as "no expiry" would defeat that. +const isExpired = (entry) => { + if (!entry.expiresAt) return false; + const t = Date.parse(entry.expiresAt); + if (Number.isNaN(t)) return true; + return t < Date.now(); +}; + +const formatDivergence = (schema, diffs) => { + const schemaName = schema?.constructor?.name || 'UnknownSchema'; + const sorted = [...diffs].sort((a, b) => + a.path.join('.').localeCompare(b.path.join('.')) + ); + const lines = sorted.slice(0, 20).map((d) => ( + ` ${d.path.join('.')} — incremental=${JSON.stringify(d.incremental)} ` + + `full=${JSON.stringify(d.full)}` + )); + const extra = sorted.length > 20 ? `\n ... ${sorted.length - 20} more` : ''; + return ( + `Incremental validator divergence in ${schemaName}:\n${lines.join('\n')}${extra}` + ); +}; + +// Mutually exclusive routing — production endpoint, test-throw flag, +// dev console. Avoids double-logging that would trip setup-jest's +// `expect(console.error).not.toHaveBeenCalled()` afterEach. +const defaultReport = (report) => { + if (typeof window !== 'undefined' + && typeof navigator !== 'undefined' + && navigator.sendBeacon + && window.__incremental_canary_endpoint__) { + try { + navigator.sendBeacon( + window.__incremental_canary_endpoint__, + JSON.stringify({ + tag: 'canary:incremental-divergence', + subsystem: 'validator', + schema: report.schemaName, + paths: report.diffs.map((d) => d.path.join('.')), + }), + ); + } catch { + // sendBeacon throws synchronously on payload-too-large; swallow. + } + return; + } + + if (typeof process !== 'undefined' + && process?.env?.NODE_ENV === 'test' + && typeof window !== 'undefined' + && window.__throw_on_canary_divergence__) { + throw new Error(report.message); + } + + if (typeof console !== 'undefined') { + console.error(report.message); + } +}; + +// Test-only — not re-exported via any index. +export const _resetValidationCanaryFireCount = () => { _canaryFireCount = 0; }; + +// Public entry point. +// +// Params (same shape as validateSchema + onDivergence hook): +// +// schema, sessData, setError, accessPath, collLabel, mustVisit +// — passed through to validateSchema for the inner walks. +// +// onDivergence (optional) — callback fired with {schemaName, diffs, +// message} for each divergence report. If provided, the throttle is +// bypassed (tests). If not provided, defaultReport handles routing. +// +// Returns: the full-walk hadError result. Caller's setError receives +// the FULL walk's errors — incremental's errors are discarded after +// the diff. That keeps callers' state machine deterministic regardless +// of whether incremental and full agreed. +// +// On depth: this is only invoked from validateSchema at __validateDepth +// === 1 (the wrapper increments before calling). The two inner walks +// re-enter validateSchema, which at depth > 0 just runs _impl directly +// — no recursion through the canary, no double measure(). +export const runValidationCanary = ({ + schema, sessData, setError, + accessPath = [], collLabel = null, mustVisit = null, + collectAll = false, onDivergence = null, +}) => { + // Stamp an entry-count on window so canary-build tests can assert the + // walker actually executed (not just that __INCREMENTAL_AUDIT__ was set + // by the harness — which doesn't prove the dialog mounted or that a + // production build was used). Whole runValidationCanary module is + // tree-shaken in non-CANARY_BUILD bundles, so this is zero cost in + // production. + if (typeof window !== 'undefined') { + window.__canary_entry_count__ = (window.__canary_entry_count__ || 0) + 1; + } + // Both inner walks force collectAll=true so the diff reflects + // ACTUAL missed errors rather than which row each walk happened to + // short-circuit on. With short-circuit enabled, full + incremental + // would each produce a one-element error map at potentially + // different paths — the canary would report that as divergence even + // when both walks recognize the schema as invalid. collectAll gives + // both walks complete error coverage so the diff is meaningful. + // + // The caller's `collectAll` flag controls how the caller's setError + // is invoked at the end (single-error vs all-errors). The inner + // walks ALWAYS use collectAll=true regardless. + const fullErrors = []; + const fullCapture = (path, message) => { + if (!message) return; + fullErrors.push({ path: [...path], message }); + }; + // If the inner walk throws (a schema's validate() or evaluator + // hits a runtime error mid-iteration), we still want to surface + // whatever errors WERE collected before the throw. Without this + // try-catch, the audit's initial discovery walk loses every + // already-collected path because the throw aborts the canary's + // own forwarding loop further down. + let fullHadError = false; + let fullWalkThrew = null; + try { + fullHadError = validateSchema( + schema, sessData, fullCapture, accessPath, collLabel, null, true + ); + } catch (e) { + fullWalkThrew = e; + } + + // Forward whatever the full walk discovered (even if it threw + // mid-walk) so callers get their setError invoked. After + // forwarding, re-throw any captured exception below so the audit's + // dispatch loop can categorize it. + const forwardFull = () => { + if (collectAll) { + for (const e of fullErrors) setError(e.path, e.message); + } else if (fullErrors.length > 0) { + setError(fullErrors[0].path, fullErrors[0].message); + } + }; + // When mustVisit is null, both walks produce identical output — + // short-circuit. (V3 identity.) + if (mustVisit === null) { + forwardFull(); + if (fullWalkThrew) throw fullWalkThrew; + return fullHadError; + } + + // Throttle: in production sampling, cap canary fires per session to + // avoid paying the second-walk cost on every keystroke. Tests that + // supply onDivergence bypass the throttle. + if (!onDivergence && _canaryFireCount >= getMaxCanaryFires()) { + forwardFull(); + if (fullWalkThrew) throw fullWalkThrew; + return fullHadError; + } + _canaryFireCount += 1; + + // Incremental walk — same inputs, but with the actual mustVisit + // array. Captures errors into a separate list. Also collectAll=true. + const incrementalErrors = []; + const incCapture = (path, message) => { + if (!message) return; + incrementalErrors.push({ path: [...path], message }); + }; + let incrementalThrew = null; + try { + validateSchema( + schema, sessData, incCapture, accessPath, collLabel, mustVisit, true + ); + } catch (e) { + incrementalThrew = e; + } + + // Diff and report. + const allowlist = (schema?.constructor?.canaryAllowedValidationDivergences || []) + .filter((e) => !isExpired(e)); + const allDiffs = diffErrors(incrementalErrors, fullErrors); + const diffs = applyAllowlist(allDiffs, allowlist); + + if (diffs.length > 0) { + const schemaName = schema?.constructor?.name || 'UnknownSchema'; + const message = formatDivergence(schema, diffs); + const report = { schemaName, diffs, message }; + if (onDivergence) { + onDivergence(report); + } else { + defaultReport(report); + } + } + + // Propagate the full walk's errors to the caller. When the caller + // is collectAll-aware (SchemaState's tracker), forward all of them + // so every path is recorded for mustVisit. Otherwise preserve the + // legacy single-error contract. + forwardFull(); + + // Re-throw any exception captured from either walk so the caller + // (SchemaState or the audit harness) can surface it. Divergence + // wins over walk-failure when both happen — the canary's job is + // first to report divergence. + if (fullWalkThrew) throw fullWalkThrew; + if (incrementalThrew) throw incrementalThrew; + return fullHadError; +}; diff --git a/web/pgadmin/static/js/SchemaView/bench-fixture.js b/web/pgadmin/static/js/SchemaView/bench-fixture.js new file mode 100644 index 00000000000..37b0de2495a --- /dev/null +++ b/web/pgadmin/static/js/SchemaView/bench-fixture.js @@ -0,0 +1,151 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2026, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +// Synthetic SchemaView -> DataGridView -> SchemaView -> DataGridView fixture +// for stress testing. Models the real-world worst case in pgAdmin: a Table +// dialog with up to 1000 columns, each column carrying nested indexes. +// +// Usage from the browser console (or via Playwright): +// __PERF_SCHEMA__ = true +// __mountBenchFixture(1000, 3) // 1000 outer rows × 3 inner rows each +// ... interact ... +// __perfDump() +// +// The fixture uses pgAdmin's existing `pgadmin:utility:show` event so the +// dialog runs inside the real provider tree (theme, PgAdmin context, docker). + +import BaseUISchema from './base_schema.ui'; + +class BenchInnerSchema extends BaseUISchema { + constructor() { + super({ key: '', value: '', enabled: false }); + } + get baseFields() { + return [ + { id: 'key', label: 'Index name', type: 'text', cell: 'text' }, + { id: 'value', label: 'Expression', type: 'text', cell: 'text' }, + { id: 'enabled', label: 'Unique', type: 'switch', cell: 'switch' }, + ]; + } +} + +class BenchOuterSchema extends BaseUISchema { + constructor() { + super({ name: '', type: 'text', notnull: false, indexes: [] }); + } + get baseFields() { + return [ + { id: 'name', label: 'Column name', type: 'text', cell: 'text' }, + // `type` declares a same-row dep on `name` to exercise the + // DepListener-driven incremental walk. A no-op depChange is enough + // — its presence registers the source/dest in _depListeners. + { + id: 'type', label: 'Data type', type: 'text', cell: 'text', + deps: ['name'], + depChange: () => ({}), + }, + { id: 'notnull', label: 'Not NULL', type: 'switch', cell: 'switch' }, + { + id: 'indexes', + label: 'Indexes', + type: 'collection', + schema: new BenchInnerSchema(), + canAdd: true, + canEdit: true, + canDelete: true, + mode: ['edit', 'create'], + }, + ]; + } +} + +class BenchTopSchema extends BaseUISchema { + constructor(outerRows, innerRows) { + super(generateInitial(outerRows, innerRows)); + } + get baseFields() { + return [ + { id: 'name', label: 'Table name', type: 'text', mode: ['create','edit'] }, + { + id: 'columns', + label: 'Columns', + type: 'collection', + schema: new BenchOuterSchema(), + // Only these fields appear as cells in the outer grid. The `indexes` + // collection is reachable via the row's expanded edit form. + columns: ['name', 'type', 'notnull'], + canAdd: true, + canEdit: true, + canDelete: true, + expandEditOnAdd: true, + mode: ['edit', 'create'], + }, + ]; + } +} + +function generateInitial(N, M) { + const columns = new Array(N); + for (let i = 0; i < N; i++) { + const indexes = new Array(M); + for (let j = 0; j < M; j++) { + indexes[j] = { + key: `idx_${i}_${j}`, + value: `expr_${j}`, + enabled: false, + }; + } + columns[i] = { + name: `col_${i}`, + type: 'text', + notnull: false, + indexes, + }; + } + return { name: 'bench_table', columns }; +} + +function mountBenchFixture(outerRows = 1000, innerRows = 3) { + const N = parseInt(outerRows, 10) || 1000; + const M = parseInt(innerRows, 10) || 3; + // Bench harness is a dev-invoked module; the mount-time log + // confirms the right shape ran. Single line-level disable + // rather than a file-level enable so accidental console.log + // in non-diagnostic code still trips lint. + // eslint-disable-next-line no-console + console.log(`[bench-fixture] mounting ${N} outer × ${M} inner rows`); + + const schema = new BenchTopSchema(N, M); + + // pgAdmin is provided as a webpack global via ProvidePlugin + // and declared in the eslint `globals` config. + if (!pgAdmin?.Browser?.Events) { + throw new Error('pgAdmin.Browser.Events not available — is the app loaded?'); + } + + pgAdmin.Browser.Events.trigger( + 'pgadmin:utility:show', + null, + `Bench (${N} cols × ${M} idx)`, + { + schema, + actionType: 'create', + urlBase: '#bench-fixture', + extraData: {}, + onSave: () => { /* no-op for bench */ }, + }, + 1200, + 800, + ); + return { N, M }; +} + +if (typeof window !== 'undefined') { + window.__mountBenchFixture = mountBenchFixture; +} diff --git a/web/pgadmin/static/js/SchemaView/hooks/useFieldError.js b/web/pgadmin/static/js/SchemaView/hooks/useFieldError.js index beb756a1345..4f111c64f00 100644 --- a/web/pgadmin/static/js/SchemaView/hooks/useFieldError.js +++ b/web/pgadmin/static/js/SchemaView/hooks/useFieldError.js @@ -38,7 +38,8 @@ export const useFieldError = (path, schemaState, subscriberManager) => { return subscriberManager.current?.add( schemaState, ['errors'], 'states', checkPathError ); - }); + // Pin deps; see useFieldValue for the rationale. + }, [path, schemaState, subscriberManager]); const errors = schemaState?.errors || {}; const error = ( diff --git a/web/pgadmin/static/js/SchemaView/hooks/useFieldOptions.js b/web/pgadmin/static/js/SchemaView/hooks/useFieldOptions.js index 878640ed6e7..4d31bc1638b 100644 --- a/web/pgadmin/static/js/SchemaView/hooks/useFieldOptions.js +++ b/web/pgadmin/static/js/SchemaView/hooks/useFieldOptions.js @@ -16,7 +16,8 @@ export const useFieldOptions = (path, schemaState, subscriberManager) => { if (!schemaState || !subscriberManager?.current) return; return subscriberManager.current?.add(schemaState, path, 'options'); - }); + // Pin deps; see useFieldValue for the rationale. + }, [path, schemaState, subscriberManager]); return schemaState?.options(path) || {visible: true}; }; diff --git a/web/pgadmin/static/js/SchemaView/hooks/useFieldValue.js b/web/pgadmin/static/js/SchemaView/hooks/useFieldValue.js index 13d6faf8e10..b15aa23a050 100644 --- a/web/pgadmin/static/js/SchemaView/hooks/useFieldValue.js +++ b/web/pgadmin/static/js/SchemaView/hooks/useFieldValue.js @@ -16,7 +16,10 @@ export const useFieldValue = (path, schemaState, subscriberManager) => { if (!schemaState || !subscriberManager?.current) return; return subscriberManager.current?.add(schemaState, path, 'value'); - }); + // Pin deps so the subscription is only re-added when something + // observable changes. Path is compared by reference — callers that + // want stability across renders must memoize it. + }, [path, schemaState, subscriberManager]); return schemaState?.value(path); }; diff --git a/web/pgadmin/static/js/SchemaView/hooks/useSchemaState.js b/web/pgadmin/static/js/SchemaView/hooks/useSchemaState.js index 2bd8a0194a7..ad6b427c729 100644 --- a/web/pgadmin/static/js/SchemaView/hooks/useSchemaState.js +++ b/web/pgadmin/static/js/SchemaView/hooks/useSchemaState.js @@ -9,6 +9,8 @@ import { useEffect, useReducer } from 'react'; import _ from 'lodash'; +import gettext from 'sources/gettext'; +import pgAdmin from 'sources/pgadmin'; import { prepareData } from '../common'; import { @@ -17,6 +19,72 @@ import { sessDataReducer, } from '../SchemaState'; +/** + * Drain a list of deferred-dep queue items. + * + * Each item is `{action, promise, listener}` produced by + * `DepListener.getDeferredDepChange`. We wait for each promise to settle + * and then dispatch a DEFERRED_DEPCHANGE action carrying the resolved + * callback. Two protocol guards: + * + * 1. The resolved value MUST be a function (the callback that returns + * the data delta). If it's anything else, we warn and skip — this + * catches the common protocol mistake of resolving with a data + * object directly. + * 2. Rejected promises surface to the user through + * `pgAdmin.Browser.notifier.error` so a backend failure can't + * leave the dialog in a half-applied state with no feedback. + * Schemas that want graceful in-place recovery should resolve + * with a reset callback rather than rejecting. + * + * Exported so it can be unit-tested without rendering a full SchemaView. + */ +export const drainDeferredQueue = (items, dispatch) => { + items.forEach((item) => { + Promise.resolve(item.promise).then( + (resFunc) => { + if (typeof resFunc !== 'function') { + // Protocol violation: the schema author resolved with + // something other than a (tmpstate) => deltaObj callback. + // Loud console.error so it trips dev/QA test suites; not a + // notifier toast because this is a code bug, not a runtime + // failure the end user can act on. + console.error( + 'deferredDepChange promise must resolve to a callback function; ' + + 'got %o. The dispatch is skipped — see useSchemaState ' + + 'drainDeferredQueue for the protocol.', + resFunc, + ); + return; + } + dispatch({ + type: SCHEMA_STATE_ACTIONS.DEFERRED_DEPCHANGE, + path: item.action.path, + depChange: item.action.depChange, + listener: { + ...item.listener, + callback: resFunc, + }, + }); + }, + (err) => { + const msg = err?.message || String(err) || 'unknown error'; + const userMsg = gettext('Dependent update failed: ') + msg; + const notifier = pgAdmin?.Browser?.notifier; + if (typeof notifier?.error === 'function') { + notifier.error(userMsg); + } else { + // Notifier unavailable (very early init, isolated test + // harness, etc.). Surface to console.error rather than + // silently dropping the rejection — the latter is the bug + // this drainer exists to prevent. + console.error('deferredDepChange:', userMsg, err); + } + }, + ); + }); +}; + export const useSchemaState = ({ schema, getInitData, immutableData, onDataChange, viewHelperProps, @@ -49,7 +117,32 @@ export const useSchemaState = ({ ...action, depChange: (...args) => state.getDepChange(...args), deferredDepChange: (...args) => state.getDeferredDepChange(...args), + // Sentinel for the reducer's path-action guard: any path-bearing + // action that reaches the reducer WITHOUT this flag was + // dispatched directly via sessDispatch, bypassing the + // changedPath accumulator. Under canary builds the reducer + // logs that as a warning so the bypass shows up in CI. + __viaListener: true, }; + /* + * Remember which paths these actions target so the upcoming validate + * cycle can prune its options walk (incremental mode). React batches + * multiple dispatches into one validate (one __changeId tick), so a + * single scalar would lose all but the last path — leading the + * incremental walker to prune rows that actually did change. + * Accumulate into an array; SchemaState.validate consumes it. + * + * Real triggering case: two `setUnpreparedData` calls from sibling + * fixedRows promises resolving in the same microtask tick (e.g. + * VacuumSettingsSchema's vacuum_table + vacuum_toast). One validate + * runs with both paths' data already in sessData. + */ + if (action.path) { + if (!Array.isArray(state.__pendingChangedPaths)) { + state.__pendingChangedPaths = []; + } + state.__pendingChangedPaths.push(action.path); + } /* * All the session changes coming before init should be queued up. * They will be processed later when form is ready. @@ -117,20 +210,22 @@ export const useSchemaState = ({ type: SCHEMA_STATE_ACTIONS.CLEAR_DEFERRED_QUEUE, }); - items.forEach((item) => { - item.promise.then((resFunc) => { - sessDispatch({ - type: SCHEMA_STATE_ACTIONS.DEFERRED_DEPCHANGE, - path: item.action.path, - depChange: item.action.depChange, - listener: { - ...item.listener, - callback: resFunc, - }, - }); - }); - }); - }, [sessData.__deferred__?.length]); + // Route the drain's DEFERRED_DEPCHANGE dispatches through the + // listener wrapper so they (a) carry the __viaListener sentinel + // the reducer's bypass guard expects and (b) push their paths + // into __pendingChangedPaths so the next validate's mustVisit + // includes them. Pre-fix, the drain called sessDispatch directly + // — every deferred resolve tripped the bypass guard and + // silently skipped the accumulator. + drainDeferredQueue(items, sessDispatchWithListener); + // Depend on the array reference rather than its length. With React + // automatic batching the queue's length can round-trip through 0 in + // the same commit (CLEAR followed by a fresh APPEND), and a + // length-based dep would compare equal across renders and miss the + // second drain. The reducer creates a new __deferred__ array on + // every dispatch, so ref-equality changes whenever the queue does; + // the early `length == 0` return keeps the no-op case free. + }, [sessData.__deferred__]); state.reload = reload; state.reset = resetData; diff --git a/web/pgadmin/static/js/SchemaView/hooks/useSchemaStateSubscriber.js b/web/pgadmin/static/js/SchemaView/hooks/useSchemaStateSubscriber.js index 2dc898511a7..8cc93db590f 100644 --- a/web/pgadmin/static/js/SchemaView/hooks/useSchemaStateSubscriber.js +++ b/web/pgadmin/static/js/SchemaView/hooks/useSchemaStateSubscriber.js @@ -14,7 +14,7 @@ import React from 'react'; // A class to handle the ScheamState subscription for a control to avoid // rendering multiple times. // -class SubscriberManager { +export class SubscriberManager { constructor(refreshKeyCallback) { this.mounted = true; @@ -48,23 +48,26 @@ class SubscriberManager { } signal() { - // Do nothing - if already work is in progress. + // Re-entrancy / batching guard: if a signal already fired in this + // tick, drop subsequent ones. The next render's mount() flips the + // flag back on for the next batch. if (!this.mounted) return; this.mounted = false; - this.release(); + // Note: we do NOT tear down existing subscriptions here. The + // subscribing hooks (useFieldValue / useFieldOptions / useFieldError) + // pin their useEffect deps and won't re-subscribe on the next render, + // so the existing subscriptions must persist across signals. They are + // released only on component unmount via release() below. this.callback(Date.now()); } - release () { + release() { + // Called when the owning component unmounts. Tear down synchronously + // — the component is going away, so there's nothing to defer for. const unsubscribers = this.unsubscribers; this.unsubscribers = new Set(); - this.mounted = true; - - setTimeout(() => { - Set.prototype.forEach.call( - unsubscribers, (unsubscriber) => unsubscriber() - ); - }, 0); + this.mounted = false; + unsubscribers.forEach((unsubscriber) => unsubscriber()); } mount() { diff --git a/web/pgadmin/static/js/SchemaView/options/canary.js b/web/pgadmin/static/js/SchemaView/options/canary.js new file mode 100644 index 00000000000..320c1e2bd7d --- /dev/null +++ b/web/pgadmin/static/js/SchemaView/options/canary.js @@ -0,0 +1,293 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2026, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +// Divergence canary for the incremental schemaOptionsEvalulator. +// +// Runs the walker twice — once with the actual `changedPath` (incremental +// mode), once with `changedPath = null` (full walk) — and diffs the two +// result objects. Any mismatch points at a row whose options changed under +// the full walk but were silently pruned by the incremental walk; that's +// the "undeclared cross-row closure read" pattern the prototype's known +// limitation warns about. +// +// The walker (`schemaOptionsEvalulator`) is FUNCTIONAL — both calls +// receive the same `prevOptions` baseline and return independent new +// option trees. No cloning required; no shared mutation possible. +// +// This module is build-time-gated by the `process.env.__CANARY_BUILD__` +// substitution in registry.js's `schemaOptionsEvalulator` wrapper. In +// production builds (without CANARY_BUILD=true) the conditional is +// dead-code-eliminated and the import of this module is tree-shaken — +// resulting in zero canary code in the production bundle. + +import _ from 'lodash'; +import { FIELD_OPTIONS } from './common'; +import { schemaOptionsEvalulator } from './registry'; + +// --------------------------------------------------------------------- +// Walk-throttle (H3): production sampling pays the cost of a second +// full walk per dispatch. In a sampled session that lasts thousands of +// keystrokes, this would noticeably degrade user experience. +// Throttle the canary itself: after MAX_CANARY_FIRES per session, just +// run the full walk and skip the comparison. Callers that pass an +// explicit onDivergence callback bypass the throttle (tests). +// --------------------------------------------------------------------- +let _canaryFireCount = 0; +const DEFAULT_MAX_CANARY_FIRES = 5; + +const getMaxCanaryFires = () => { + // `typeof === 'number'` instead of Number.isFinite so the audit + // harness can pass Infinity to disable the throttle entirely. + // isFinite rejects Infinity and silently falls back to the default + // cap (5), throttling audit runs and hiding divergences past the + // 5th dispatch. + if (typeof window !== 'undefined' + && typeof window.__incremental_canary_max_per_session__ === 'number' + && window.__incremental_canary_max_per_session__ >= 0) { + return window.__incremental_canary_max_per_session__; + } + return DEFAULT_MAX_CANARY_FIRES; +}; + +// Walk both option trees recursively, collecting any field whose +// FIELD_OPTIONS subtree differs. +// +// Implementation note (M1): the walker stores collection rows as an +// object indexed by numeric-string keys (e.g. `{0: rowOpts, 1: rowOpts, +// __fieldOptions: collOpts}`), NOT as a real array. We rely on +// `Object.keys` returning the row-index strings. If the walker ever +// shifts to real arrays for rows, this diff function would silently +// recurse into array's `length` and miss row comparisons — add a guard +// here at that point. +const diffOptions = (incremental, full, prefix = []) => { + const diffs = []; + + // FIELD_OPTIONS holds the evaluated option dict for a leaf field / + // collection / row at this level. Compare directly when present. + const incHas = incremental + && Object.prototype.hasOwnProperty.call(incremental, FIELD_OPTIONS); + const fullHas = full + && Object.prototype.hasOwnProperty.call(full, FIELD_OPTIONS); + if (incHas || fullHas) { + if (!_.isEqual(incremental?.[FIELD_OPTIONS], full?.[FIELD_OPTIONS])) { + diffs.push({ + path: [...prefix], + incremental: incremental?.[FIELD_OPTIONS], + full: full?.[FIELD_OPTIONS], + }); + } + } + + // Recurse into nested keys (per-field option dicts, or per-row dicts + // for collections — indexed by row number). + const keys = new Set([ + ...Object.keys(incremental || {}), + ...Object.keys(full || {}), + ]); + keys.delete(FIELD_OPTIONS); + for (const k of keys) { + const incChild = incremental?.[k]; + const fullChild = full?.[k]; + // Skip non-object children — they're not part of the recursive + // option tree (rare; possible if a schema adds non-standard keys). + const incIsObj = incChild && typeof incChild === 'object'; + const fullIsObj = fullChild && typeof fullChild === 'object'; + if (!incIsObj && !fullIsObj) continue; + diffs.push(...diffOptions(incChild, fullChild, [...prefix, k])); + } + return diffs; +}; + +// Filter diffs against the schema's allowlist (per design D6). Each +// entry is `{fieldPath, reason, addedAt?, expiresAt?}` where fieldPath +// segments may include '*' wildcards. +const applyAllowlist = (diffs, allowlist) => { + if (!allowlist || allowlist.length === 0) return diffs; + return diffs.filter((d) => !allowlist.some((entry) => { + const ap = entry.fieldPath; + if (!Array.isArray(ap) || ap.length !== d.path.length) return false; + return ap.every((seg, i) => seg === '*' || seg === String(d.path[i])); + })); +}; + +// (M2) Treat unparseable `expiresAt` as expired. The design's 90-day TTL +// constraint depends on CI being able to identify expired entries — +// silently keeping a typo'd date as "no expiry" would defeat that. The +// CI script that enforces the cap should also surface NaN-expired +// entries as malformed config. +const isExpired = (entry) => { + if (!entry.expiresAt) return false; + const t = Date.parse(entry.expiresAt); + if (Number.isNaN(t)) return true; // malformed → treat as expired + return t < Date.now(); +}; + +const formatDivergence = (schema, diffs) => { + const schemaName = schema?.constructor?.name || 'UnknownSchema'; + // (M3) Sort by path for stable, readable output across runs. + const sorted = [...diffs].sort((a, b) => + a.path.join('.').localeCompare(b.path.join('.')) + ); + const lines = sorted.slice(0, 20).map((d) => ( + ` ${d.path.join('.')} — incremental=${JSON.stringify(d.incremental)} ` + + `full=${JSON.stringify(d.full)}` + )); + const extra = sorted.length > 20 ? `\n ... ${sorted.length - 20} more` : ''; + return ( + `Incremental walker divergence in ${schemaName}:\n${lines.join('\n')}${extra}` + ); +}; + +// (H2) Default reporter routes to ONE mode at a time. Branches are +// mutually exclusive — no double-logging that would trip +// `setup-jest.js`'s "expect(console.error).not.toHaveBeenCalled()" +// afterEach assertion. +// +// Routing priority: +// 1. Production: if endpoint configured, send beacon and return. +// 2. Test + throw flag: throw and return. +// 3. Otherwise (dev): console.error. +const defaultReport = (report) => { + // Production sampling path: configured endpoint + browser sendBeacon. + if (typeof window !== 'undefined' + && typeof navigator !== 'undefined' + && navigator.sendBeacon + && window.__incremental_canary_endpoint__) { + try { + navigator.sendBeacon( + window.__incremental_canary_endpoint__, + JSON.stringify({ + tag: 'canary:incremental-divergence', + schema: report.schemaName, + paths: report.diffs.map((d) => d.path.join('.')), + }), + ); + } catch { + // sendBeacon throws synchronously on payload-too-large; swallow. + } + return; + } + + // Test environment with throw flag: throw and bail (no console). + if (typeof process !== 'undefined' + && process?.env?.NODE_ENV === 'test' + && typeof window !== 'undefined' + && window.__throw_on_canary_divergence__) { + throw new Error(report.message); + } + + // Dev (or test without throw flag): browser console only. + if (typeof console !== 'undefined') { + console.error(report.message); + } +}; + +// Test-only entry point to reset the throttle counter between tests. +// Not exported via the public `options/index.js`; consumers shouldn't +// touch it. +export const _resetCanaryFireCount = () => { _canaryFireCount = 0; }; + +// Public entry point. +// +// Params (same shape as schemaOptionsEvalulator + onDivergence hook): +// +// schema, data, viewHelperProps, prevOptions, parentOptions, +// accessPath, inGrid, changedPath, globalPath, depDests +// — passed through to schemaOptionsEvalulator for both walks. +// +// onDivergence (optional) — callback fired with {schemaName, diffs, +// message} for each divergence report. If provided, the throttle +// (H3) is bypassed since tests need every divergence observed. If +// not provided, defaultReport handles routing (production / +// test-throw / dev). +// +// Returns: the full-walk result. Callers should use this as the +// authoritative options tree. +// +// (L1) `viewHelperProps` is treated as a flat config object. The +// canary spreads it shallowly to add `incrementalOptions: true` for the +// incremental walk. If pgAdmin ever introduces nested mutable state +// inside viewHelperProps (currently {mode, inCatalog, serverInfo} — +// all read-only), revisit this spread. +// +// (L2) Recursion: when the OUTER canary runs the walks, they recurse +// into nested-fieldset / collection branches via the public +// `schemaOptionsEvalulator` wrapper. The recursive calls inherit +// `viewHelperProps` (including the forced `incrementalOptions: true`) +// and `changedPath` — so deeper levels behave consistently. V1-V4 test +// 2-level schemas; deeper nesting is exercised by real production +// schemas during Phase 1 audit. +// +// (L3) `globalPath: []` is the convention for top-level callers. The +// walker uses globalPath internally to track absolute paths through +// recursion. Callers that pass a non-empty initial globalPath are +// asserting that this options subtree lives at that location in the +// global tree; the canary accepts the assertion without validation. +export const runOptionsCanary = ({ + schema, data, viewHelperProps, prevOptions = null, parentOptions = null, + accessPath = [], inGrid = false, + changedPath = null, globalPath = [], depDests = null, + onDivergence = null, +}) => { + // Always run the full walk. This is the authoritative result the + // caller receives. + const fullResult = schemaOptionsEvalulator({ + schema, data, viewHelperProps, prevOptions, parentOptions, + accessPath, inGrid, + changedPath: null, globalPath, depDests: null, + }); + + // When changedPath is null (initial mount / INIT / no-path dispatch), + // both walks produce identical output — short-circuit. V3 idempotency. + if (changedPath === null) { + return fullResult; + } + + // (H3) Throttle: in production sampling, cap canary fires per session + // to avoid paying the second-walk cost on every keystroke. Tests that + // supply an explicit onDivergence callback bypass the throttle. + if (!onDivergence && _canaryFireCount >= getMaxCanaryFires()) { + return fullResult; + } + _canaryFireCount += 1; + + // Incremental walk — same baseline, different changedPath. Force + // incremental mode for THIS walk regardless of caller's + // viewHelperProps; without the force, the walker doesn't prune and + // both walks behave identically (canary becomes a no-op). + const incrementalViewHelperProps = { + ...(viewHelperProps || {}), + incrementalOptions: true, + }; + const incrementalResult = schemaOptionsEvalulator({ + schema, data, viewHelperProps: incrementalViewHelperProps, + prevOptions, parentOptions, + accessPath, inGrid, + changedPath, globalPath, depDests, + }); + + // Diff and report. + const allowlist = (schema?.constructor?.canaryAllowedDivergences || []) + .filter((e) => !isExpired(e)); + const allDiffs = diffOptions(incrementalResult, fullResult); + const diffs = applyAllowlist(allDiffs, allowlist); + + if (diffs.length > 0) { + const schemaName = schema?.constructor?.name || 'UnknownSchema'; + const message = formatDivergence(schema, diffs); + const report = { schemaName, diffs, message }; + if (onDivergence) { + onDivergence(report); + } else { + defaultReport(report); + } + } + + return fullResult; +}; diff --git a/web/pgadmin/static/js/SchemaView/options/index.js b/web/pgadmin/static/js/SchemaView/options/index.js index 07e73ba4351..d1de1d57030 100644 --- a/web/pgadmin/static/js/SchemaView/options/index.js +++ b/web/pgadmin/static/js/SchemaView/options/index.js @@ -21,7 +21,8 @@ import { } from '../common'; import { evaluateFieldOptions, - evaluateFieldsOption, + evaluateFieldsOption, + pathOverlaps, registerOptionEvaluator, schemaOptionsEvalulator, } from './registry'; @@ -31,8 +32,9 @@ export { booleanEvaluator, canAddOrDelete, evaluateFieldOptions, - evaluateFieldsOption, + evaluateFieldsOption, evalIfNotDisabled, + pathOverlaps, registerOptionEvaluator, schemaOptionsEvalulator, }; diff --git a/web/pgadmin/static/js/SchemaView/options/registry.js b/web/pgadmin/static/js/SchemaView/options/registry.js index 972ad23329c..3ee8bdda9e9 100644 --- a/web/pgadmin/static/js/SchemaView/options/registry.js +++ b/web/pgadmin/static/js/SchemaView/options/registry.js @@ -9,6 +9,7 @@ import _ from 'lodash'; import { isModeSupportedByField } from '../common'; +import { measure } from '../perf'; import { FIELD_OPTIONS, booleanEvaluator } from './common'; @@ -56,14 +57,144 @@ export function evaluateFieldOptions({ }); } -export function schemaOptionsEvalulator({ - schema, data, accessPath=[], viewHelperProps, options, parentOptions=null, - inGrid=false +// Returns true when one path is a prefix of (or equal to) the other. +// We compare stringified keys so numeric indices match either way (lodash +// stores them as numbers, dispatchers sometimes hand back strings). +export function pathOverlaps(currentPath, changedPath) { + const shorter = currentPath.length < changedPath.length + ? currentPath : changedPath; + const longer = shorter === currentPath ? changedPath : currentPath; + for (let i = 0; i < shorter.length; i++) { + if (String(shorter[i]) !== String(longer[i])) return false; + } + return true; +} + +let __evalDepth = 0; +export function schemaOptionsEvalulator(opts) { + // Canary gate (build-time eliminated in production). The DefinePlugin + // substitutes `process.env.__CANARY_BUILD__` at build time: + // - production build (no CANARY_BUILD env): becomes `false`, the + // entire branch (including the imported runOptionsCanary) is + // dead-code-eliminated, the import is tree-shaken. + // - canary build (CANARY_BUILD=true): becomes `true`, branch kept. + // - test env: process.env is read at runtime; setup-jest.js sets + // CANARY_BUILD=true so the audit path is testable. + // Only the OUTERMOST call routes to the canary — recursive calls + // from nested-fieldset / collection branches go through this function + // again but at __evalDepth > 0, so they skip the canary check and run + // normally. This avoids exponential cost on nested schemas. + if ( + process.env.__CANARY_BUILD__ + && __evalDepth === 0 + && typeof window !== 'undefined' + && window.__INCREMENTAL_AUDIT__ + ) { + // Increment depth BEFORE calling the canary, so the canary's two + // inner walks (which call back into this function) see + // __evalDepth > 0 and skip the audit branch + the measure + // wrapper. Without this guard the canary would recurse infinitely. + // + // require() inside the conditional rather than a top-level import: + // webpack's static analyzer can tree-shake the canary module when + // process.env.__CANARY_BUILD__ is substituted to literal `false` at + // build time. A top-level `import` would pull canary.js into the + // bundle unconditionally because the symbol is referenced from + // dead-but-statically-visible code; require() inside a dead branch + // gets eliminated wholesale. + __evalDepth++; + try { + const { runOptionsCanary } = require('./canary'); + return measure( + 'schemaOptionsEvalulator', () => runOptionsCanary(opts) + ); + } finally { + __evalDepth--; + } + } + + // Measure only the outermost call; this function recurses through itself + // for nested schemas and collection rows. + if (__evalDepth === 0) { + __evalDepth++; + try { + return measure('schemaOptionsEvalulator', () => _schemaOptionsEvalulatorImpl(opts)); + } finally { + __evalDepth--; + } + } + return _schemaOptionsEvalulatorImpl(opts); +} + +// Walker is now FUNCTIONAL — it returns a new options object for this +// schema level instead of mutating an input. Unvisited collection rows +// keep their previous object reference (structural sharing); visited +// subtrees get fresh objects. The caller (SchemaState.updateOptions) +// passes `prevOptions` and uses the returned value as the new state. +// +// `options` (legacy) is accepted as an alias for `prevOptions` so +// existing callers / external consumers that still pass `options` get +// the previous-walk semantics they were used to — but we no longer +// mutate that object. +function _schemaOptionsEvalulatorImpl({ + schema, data, accessPath=[], viewHelperProps, + options=null, prevOptions=null, + parentOptions=null, inGrid=false, + // Incremental option evaluation: when set, skip walking collection + // rows whose path does not overlap the changed path. Initial mount and + // any caller that doesn't pass these args keeps the full-walk + // behaviour. `globalPath` mirrors the data tree so we can compare + // against `changedPath`; `accessPath` continues to navigate the + // options tree. `depDests` carries the dest paths of any DepListener + // entry whose source overlaps `changedPath` — they must also be + // visited so cross-row declared deps stay correct. + changedPath=null, globalPath=[], depDests=null, }) { + // Incremental mode is opt-in. It's enabled either per-dialog (via + // viewHelperProps.incrementalOptions) or globally via the + // window.__INCREMENTAL_OPTIONS__ toggle (handy for benchmarks / + // canarying without rebuilding the dialog plumbing). + // + // KNOWN LIMITATION — leave incremental off until the host schema has + // Default-on: any dispatch with a concrete changedPath uses the + // incremental walk. The audit harness (registered_schemas_audit.spec.js) + // is the production gate — it ratchets KNOWN_DIVERGING toward zero, + // and we flipped the default once it reached empty. Dialogs/schemas + // that genuinely need full-walk semantics can opt out by setting + // `incrementalOptions: false` on viewHelperProps or the schema + // instance; the global `window.__INCREMENTAL_OPTIONS__ = false` + // escape hatch disables it everywhere for emergency rollback. + // + // What incremental pruning means: rows are skipped by + // `pathOverlaps(rowGlobalPath, p)` for every `p` in `mustVisit` + // (changedPath + dest paths of DepListener entries whose source + // overlaps changedPath). Cross-row deps declared via `field.deps` + // are handled correctly — they register as DepListener entries and + // join mustVisit. Schemas with UNDECLARED cross-row reads would + // silently miss the affected rows; the audit harness catches this + // pattern before it ships. + const incremental = ( + Array.isArray(changedPath) + && viewHelperProps?.incrementalOptions !== false + && (typeof window === 'undefined' + || window.__INCREMENTAL_OPTIONS__ !== false) + ); + + const mustVisit = incremental + ? [changedPath].concat(Array.isArray(depDests) ? depDests : []) + : null; + + // `prev` is the read-only previous options snapshot at this level. + // We start `out` as a shallow clone so untouched keys (set by + // sibling fields in this loop, or pre-existing entries for unvisited + // collections we haven't written yet) keep their references. + const prev = prevOptions || options || {}; + const out = { ...prev }; + schema?.fields?.forEach((field) => { - // We could have multiple entries of same `field.id` for each mode, hence - - // we should process the options only if the current field is support for - // the given mode. + // We could have multiple entries of same `field.id` for each mode, + // hence — we should process the options only if the current field is + // supported for the given mode. if (!isModeSupportedByField(field, viewHelperProps)) return; switch (field.type) { @@ -74,14 +205,19 @@ export function schemaOptionsEvalulator({ if (!field.schema) return; if (!field.schema.top) field.schema.top = schema.top || schema; - const path = field.id ? [...accessPath, field.id] : accessPath; - - schemaOptionsEvalulator({ - schema: field.schema, data, path, viewHelperProps, options, - parentOptions + // nested-* groups share their parent's data level. Recurse and + // merge the returned dict into `out` (nested fields take + // priority over siblings already accumulated). + const nested = schemaOptionsEvalulator({ + schema: field.schema, data, + accessPath: field.id ? [...accessPath, field.id] : accessPath, + viewHelperProps, prevOptions: out, + parentOptions, changedPath, globalPath, depDests, }); + // `nested` already contains everything we had in `out` (it was + // seeded as prevOptions) plus the nested fields' contributions. + Object.assign(out, nested); } - break; case 'collection': @@ -89,64 +225,94 @@ export function schemaOptionsEvalulator({ if (!field.schema) return; if (!field.schema.top) field.schema.top = schema.top || schema; - const fieldPath = [...accessPath, field.id]; - const fieldOptionsPath = [...fieldPath, FIELD_OPTIONS]; - const fieldOptions = _.get(options, fieldOptionsPath, {}); - const rows = data[field.id]; + const fieldGlobalPath = [...globalPath, field.id]; + // Per-collection slot in prev → shallow clone so unvisited rows + // retain their reference. + const prevColl = (prev[field.id] && typeof prev[field.id] === 'object') + ? prev[field.id] : {}; + const nextColl = { ...prevColl }; + // Field-level options (canAdd, canEdit, etc.) — always fresh. + const fieldOptions = {}; evaluateFieldOptions({ schema, value: data, viewHelperProps, field, options: fieldOptions, parentOptions, }); + nextColl[FIELD_OPTIONS] = fieldOptions; - _.set(options, fieldOptionsPath, fieldOptions); - + const rows = data[field.id]; rows?.forEach((row, idx) => { - const schemaPath = [...fieldPath, idx]; - const schemaOptions = _.get(options, schemaPath, {}); + const rowGlobalPath = [...fieldGlobalPath, idx]; - _.set(options, schemaPath, schemaOptions); + // Incremental prune: skip rows whose subtree the change cannot + // affect. A row matters when ANY must-visit path either reaches + // INTO it (typing a cell inside this row, or a declared dep + // points into it) or sits ABOVE it (a structural change at or + // above the collection — e.g. ADD_ROW with + // `changedPath = ['columns']`). + // + // Guard: only prune when prevColl[idx] HAS a prior result we + // can inherit. If undefined (typical for the first dispatch + // after an Edit-mode mount populated data.columns but the + // initial-walk options haven't been propagated as prevOptions + // yet, OR for any race between the React state-store write + // and the next dispatch), we MUST walk the row — otherwise + // nextColl[idx] stays undefined and downstream consumers + // (DataGridView features.onRow, canEditRow / canDeleteRow + // checks) see missing options for legitimately-present rows. + if (incremental + && prevColl[idx] !== undefined + && !mustVisit.some((p) => pathOverlaps(rowGlobalPath, p))) { + // nextColl[idx] already === prevColl[idx] via spread; we + // intentionally do NOTHING so the reference is preserved. + return; + } - schemaOptionsEvalulator({ + // Visited row: walk the row schema (returns new sub-options). + const subOpts = schemaOptionsEvalulator({ schema: field.schema, data: row, accessPath: [], - viewHelperProps, options: schemaOptions, - parentOptions: fieldOptions, inGrid: true + viewHelperProps, prevOptions: prevColl[idx], + parentOptions: fieldOptions, inGrid: true, + changedPath, globalPath: rowGlobalPath, depDests, }); - const rowPath = [...schemaPath, FIELD_OPTIONS]; - const rowOptions = _.get(options, rowPath, {}); - _.set(options, rowPath, rowOptions); - + // Per-row options (canEditRow, etc.). + const rowFieldOptions = {}; evaluateFieldOption({ option: 'row', schema: field.schema, value: row, viewHelperProps, - field, options: rowOptions, parentOptions: fieldOptions + field, options: rowFieldOptions, parentOptions: fieldOptions, }); + + nextColl[idx] = { ...subOpts, [FIELD_OPTIONS]: rowFieldOptions }; }); + out[field.id] = nextColl; } break; default: { - const fieldPath = [...accessPath, field.id]; - const fieldOptionsPath = [...fieldPath, FIELD_OPTIONS]; - const fieldOptions = _.get(options, fieldOptionsPath, {}); - + // Leaf field: compute fresh fieldOptions; the per-leaf slot is + // a new object every time we visit (walker always evaluates + // top-level leaves). For leaves inside an UNVISITED row, we + // never get here — the collection branch above keeps the row's + // entire reference. + const fieldOptions = {}; evaluateFieldOptions({ - schema, value: data, viewHelperProps, field, options: fieldOptions, - parentOptions, + schema, value: data, viewHelperProps, field, + options: fieldOptions, parentOptions, }); - if (inGrid) { evaluateFieldOption({ option: 'cell', schema, value: data, viewHelperProps, field, options: fieldOptions, parentOptions, }); } - - _.set(options, fieldOptionsPath, fieldOptions); + out[field.id] = { [FIELD_OPTIONS]: fieldOptions }; } break; } }); + + return out; } diff --git a/web/pgadmin/static/js/SchemaView/perf.js b/web/pgadmin/static/js/SchemaView/perf.js new file mode 100644 index 00000000000..7ebdcc244b1 --- /dev/null +++ b/web/pgadmin/static/js/SchemaView/perf.js @@ -0,0 +1,128 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2026, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +// SchemaView profiling helper. +// +// All instrumentation is gated on `window.__PERF_SCHEMA__`. When the flag is +// false (default), `measure(name, fn)` just returns `fn()` with one boolean +// check of overhead. +// +// Usage from the browser console: +// __PERF_SCHEMA__ = true // turn on +// ... interact with a dialog ... +// __perfDump() // console.table summary +// __perfReset() // clear counters +// +// Or via Playwright: read the buffers via `window.__perfSnapshot()`. + +const enabled = () => ( + typeof window !== 'undefined' && window.__PERF_SCHEMA__ === true +); + +const stats = new Map(); +const counts = new Map(); +const actionsLog = []; +const MAX_ACTION_LOG = 500; + +export function measure(name, fn) { + if (!enabled()) return fn(); + + const t0 = performance.now(); + try { + return fn(); + } finally { + const dt = performance.now() - t0; + let s = stats.get(name); + if (!s) { + s = { count: 0, total: 0, max: 0 }; + stats.set(name, s); + } + s.count++; + s.total += dt; + if (dt > s.max) s.max = dt; + } +} + +export function record(name, dt) { + if (!enabled()) return; + let s = stats.get(name); + if (!s) { + s = { count: 0, total: 0, max: 0 }; + stats.set(name, s); + } + s.count++; + s.total += dt; + if (dt > s.max) s.max = dt; +} + +// Pure counter (not a duration). Use for "how many times did X happen per +// keystroke" metrics. Kept in a separate map so they don't pollute the +// timing table. +export function count(name, n = 1) { + if (!enabled()) return; + counts.set(name, (counts.get(name) || 0) + n); +} + +export function logAction(actionType, dt, extra = {}) { + if (!enabled()) return; + if (actionsLog.length >= MAX_ACTION_LOG) actionsLog.shift(); + actionsLog.push({ + t: +performance.now().toFixed(2), + actionType, + dt: +dt.toFixed(3), + ...extra, + }); +} + +export function snapshot() { + const rows = [...stats.entries()].map(([name, s]) => ({ + name, + count: s.count, + total_ms: +s.total.toFixed(2), + avg_ms: +(s.total / s.count).toFixed(3), + max_ms: +s.max.toFixed(3), + })).sort((a, b) => b.total_ms - a.total_ms); + const countRows = [...counts.entries()].map(([name, c]) => ({ name, total: c })) + .sort((a, b) => b.total - a.total); + return { stats: rows, counts: countRows, actions: actionsLog.slice() }; +} + +// console.table/log are the right tools for the dev-invoked +// diagnostic dump below — they print structured data to devtools +// in a form the no-console rule's preferred warn/error/trace can't +// match. Scoped to this function via block-level disable so the +// rest of the file stays under the rule. +export function dump() { + const snap = snapshot(); + /* eslint-disable no-console */ + console.table(snap.stats); + console.log('Counters:'); + console.table(snap.counts); + console.log(`Last ${Math.min(snap.actions.length, 25)} actions:`); + console.table(snap.actions.slice(-25)); + /* eslint-enable no-console */ + return snap; +} + +export function reset() { + stats.clear(); + counts.clear(); + actionsLog.length = 0; +} + +if (typeof window !== 'undefined') { + window.__perfDump = dump; + window.__perfReset = reset; + window.__perfSnapshot = snapshot; +} + +// Side-effect import to register window.__mountBenchFixture. Kept at the +// bottom so it can pull in BaseUISchema after perf has set its globals. +import './bench-fixture'; + diff --git a/web/pgadmin/static/js/SchemaView/utils/listenDepChanges.js b/web/pgadmin/static/js/SchemaView/utils/listenDepChanges.js index ed59c1e9dfe..38da6f3f18e 100644 --- a/web/pgadmin/static/js/SchemaView/utils/listenDepChanges.js +++ b/web/pgadmin/static/js/SchemaView/utils/listenDepChanges.js @@ -13,6 +13,52 @@ import _ from 'lodash'; import { evalFunc } from 'sources/utils'; +/** + * Wires a field's `depChange` and `deferredDepChange` callbacks into the + * SchemaState dependency tracker so they fire when the field's own value + * or any of its declared `deps` change. + * + * ## `depChange(state, source, topState, actionObj) => deltaObj | undefined` + * + * Synchronous. Return a partial-state object to merge into the field's + * local data; `undefined` means "no change". Runs inline during the + * reducer dispatch, so it must be cheap and side-effect free. + * + * ## `deferredDepChange(state, source, topState, actionObj) => Promise | undefined` + * + * Asynchronous follow-up. Use this for work that needs a confirmation + * dialog, a network round-trip, or any other Promise-bound result. + * + * The contract: + * + * 1. Return **`undefined`** to opt out — nothing is queued, no + * Promise is constructed. Use this when the trigger doesn't apply + * (wrong `source`, no actual change, nothing to do). + * 2. Otherwise return a Promise that **always settles**: + * - On success, resolve with a callback `(tmpstate) => deltaObj`. + * The callback is invoked at drain time against the latest + * state and must return a delta object only — it must NOT + * mutate `tmpstate` or any captured input state. + * - On failure, prefer resolving with a recovery callback that + * resets any "in-progress" flag and surface the error via + * `pgAdmin.Browser.notifier.error(...)` from inside the + * Promise body. Rejecting is permitted as a safety net — the + * drainer routes rejections to `notifier.error` so the user + * sees a generic message — but per-schema recovery gives a + * better UX than a generic toast. + * 3. Side effects (notifier dialogs, schema-level mutations like + * `setOperClassOptions`) belong **inside the Promise body before + * resolving** — not inside the returned callback. Exception: when + * the side effect's input legitimately depends on `tmpstate` + * (drain-time state, e.g. merging fetched columns into whatever + * the user has typed since the deferred work was queued), the + * side effect may live in the callback. Treat the exception as + * a smell: prefer to compute the result before resolve when you + * can. + * + * A Promise that never resolves leaks into `data.__deferred__` forever + * and is the bug pattern this protocol exists to prevent. + */ export const listenDepChanges = ( accessPath, field, schemaState, setRefreshKey ) => { @@ -41,11 +87,22 @@ export const listenDepChanges = ( // the exact accesspath. let source = _.isArray(dep) ? dep : parentPath.concat(dep); - if (field.depChange || field.deferredDepChange) { - schemaState.addDepListener( - source, accessPath, field.depChange, field.deferredDepChange - ); - } + // Register a dep listener for EVERY declared `dep`, even when + // the field has no `depChange` callback. The listener body is + // only fired when `.callback` is set (see DepListener.getDepChange), + // so the no-callback registration is a pure record. Why we + // need it: the incremental option walker's + // `_collectDepDestsForPath` enumerates the listener registry + // to know which dest rows must stay in `mustVisit` when a + // source path changes. Without registering a listener for + // evaluator-only deps (fields whose `editable`/`disabled`/ + // `visible`/`readonly` closures read a cross-row source via + // `obj.top.sessData.X`), the walker prunes those rows and + // their options go stale — the canary catches this on + // VacuumSettingsSchema's vacuum_table.*.value.editable. + schemaState.addDepListener( + source, accessPath, field.depChange, field.deferredDepChange + ); if (setRefreshKey) schemaState.subscribe( diff --git a/web/pgadmin/tools/backup/static/js/backup.ui.js b/web/pgadmin/tools/backup/static/js/backup.ui.js index 9460b5b6a82..9c84a66faf4 100644 --- a/web/pgadmin/tools/backup/static/js/backup.ui.js +++ b/web/pgadmin/tools/backup/static/js/backup.ui.js @@ -8,6 +8,7 @@ ////////////////////////////////////////////////////////////// import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; import { isEmptyString } from 'sources/validators'; export class SectionSchema extends BaseUISchema { @@ -482,7 +483,7 @@ export function getExcludePatternsSchema() { return new ExcludePatternsSchema(); } -export default class BackupSchema extends BaseUISchema { +class BackupSchema extends BaseUISchema { constructor(sectionSchema, typeObjSchema, saveOptSchema, disabledOptionSchema, miscellaneousSchema, excludePatternsSchema, fieldOptions = {}, treeNodeInfo=[], pgBrowser=null, backupType='server', objects={}) { super({ file: undefined, @@ -787,3 +788,5 @@ export default class BackupSchema extends BaseUISchema { } } } +export default registerSchema(BackupSchema); + diff --git a/web/pgadmin/tools/backup/static/js/backupGlobal.ui.js b/web/pgadmin/tools/backup/static/js/backupGlobal.ui.js index 6c05cf3437b..f38fbeb61b2 100644 --- a/web/pgadmin/tools/backup/static/js/backupGlobal.ui.js +++ b/web/pgadmin/tools/backup/static/js/backupGlobal.ui.js @@ -8,6 +8,7 @@ ////////////////////////////////////////////////////////////// import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; import { isEmptyString } from 'sources/validators'; export class MiscellaneousSchema extends BaseUISchema { @@ -45,7 +46,7 @@ export function getMiscellaneousSchema() { return new MiscellaneousSchema(); } -export default class BackupGlobalSchema extends BaseUISchema { +class BackupGlobalSchema extends BaseUISchema { constructor(miscellaneousSchema, fieldOptions = {}) { super({ id: null, @@ -113,3 +114,5 @@ export default class BackupGlobalSchema extends BaseUISchema { } } +export default registerSchema(BackupGlobalSchema); + diff --git a/web/pgadmin/tools/grant_wizard/static/js/privilege_schema.ui.js b/web/pgadmin/tools/grant_wizard/static/js/privilege_schema.ui.js index 331990ee191..155c814d0d4 100644 --- a/web/pgadmin/tools/grant_wizard/static/js/privilege_schema.ui.js +++ b/web/pgadmin/tools/grant_wizard/static/js/privilege_schema.ui.js @@ -1,7 +1,8 @@ import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; -export default class PrivilegeSchema extends BaseUISchema { +class PrivilegeSchema extends BaseUISchema { constructor(getPrivilegeRoleSchema, fieldOptions = {}, initValues={}) { super({ oid: null, @@ -32,3 +33,5 @@ export default class PrivilegeSchema extends BaseUISchema { } } +export default registerSchema(PrivilegeSchema); + diff --git a/web/pgadmin/tools/import_export/static/js/import_export.ui.js b/web/pgadmin/tools/import_export/static/js/import_export.ui.js index 4742b71117d..385cc51236b 100644 --- a/web/pgadmin/tools/import_export/static/js/import_export.ui.js +++ b/web/pgadmin/tools/import_export/static/js/import_export.ui.js @@ -8,11 +8,12 @@ ////////////////////////////////////////////////////////////// import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; import gettext from 'sources/gettext'; import { isEmptyString } from 'sources/validators'; -export default class ImportExportSchema extends BaseUISchema { +class ImportExportSchema extends BaseUISchema { constructor(fieldOptions = {}, initValues={}) { super({ null_string: undefined, @@ -416,3 +417,5 @@ export default class ImportExportSchema extends BaseUISchema { } } } +export default registerSchema(ImportExportSchema); + diff --git a/web/pgadmin/tools/import_export_servers/static/js/import_export_selection.ui.js b/web/pgadmin/tools/import_export_servers/static/js/import_export_selection.ui.js index 80d870eda42..96f72d24887 100644 --- a/web/pgadmin/tools/import_export_servers/static/js/import_export_selection.ui.js +++ b/web/pgadmin/tools/import_export_servers/static/js/import_export_selection.ui.js @@ -8,9 +8,10 @@ ////////////////////////////////////////////////////////////// import gettext from 'sources/gettext'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; import { isEmptyString } from 'sources/validators'; -export default class ImportExportSelectionSchema extends BaseUISchema { +class ImportExportSelectionSchema extends BaseUISchema { constructor(initData = {}) { super({ imp_exp: 'i', @@ -93,3 +94,5 @@ export default class ImportExportSelectionSchema extends BaseUISchema { } } } +export default registerSchema(ImportExportSelectionSchema); + diff --git a/web/pgadmin/tools/maintenance/static/js/maintenance.ui.js b/web/pgadmin/tools/maintenance/static/js/maintenance.ui.js index a3e4124b6e2..990cb8d69df 100644 --- a/web/pgadmin/tools/maintenance/static/js/maintenance.ui.js +++ b/web/pgadmin/tools/maintenance/static/js/maintenance.ui.js @@ -8,6 +8,7 @@ ////////////////////////////////////////////////////////////// import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; import gettext from 'sources/gettext'; export class VacuumSchema extends BaseUISchema { @@ -329,7 +330,7 @@ export function getVacuumSchema(fieldOptions) { //Maintenance Schema -export default class MaintenanceSchema extends BaseUISchema { +class MaintenanceSchema extends BaseUISchema { constructor(vacuumSchema, fieldOptions = {}) { super({ @@ -415,3 +416,5 @@ export default class MaintenanceSchema extends BaseUISchema { ]; } } +export default registerSchema(MaintenanceSchema); + diff --git a/web/pgadmin/tools/restore/static/js/restore.ui.js b/web/pgadmin/tools/restore/static/js/restore.ui.js index 34066a35db1..26d34692835 100644 --- a/web/pgadmin/tools/restore/static/js/restore.ui.js +++ b/web/pgadmin/tools/restore/static/js/restore.ui.js @@ -8,6 +8,7 @@ ////////////////////////////////////////////////////////////// import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; import gettext from 'sources/gettext'; import { isEmptyString } from 'sources/validators'; @@ -310,7 +311,7 @@ export function getRestoreMiscellaneousSchema(fieldOptions) { } //Restore Schema -export default class RestoreSchema extends BaseUISchema { +class RestoreSchema extends BaseUISchema { constructor(restoreSectionSchema, restoreTypeObjSchema, restoreSaveOptSchema, restoreDisableOptionSchema, restoreMiscellaneousSchema, fieldOptions = {}, treeNodeInfo={}, pgBrowser=null) { super({ @@ -536,3 +537,5 @@ export default class RestoreSchema extends BaseUISchema { } } } +export default registerSchema(RestoreSchema); + diff --git a/web/pgadmin/tools/sqleditor/static/js/show_view_data.js b/web/pgadmin/tools/sqleditor/static/js/show_view_data.js index 82cfad13b89..49c7afc6315 100644 --- a/web/pgadmin/tools/sqleditor/static/js/show_view_data.js +++ b/web/pgadmin/tools/sqleditor/static/js/show_view_data.js @@ -10,13 +10,14 @@ import gettext from 'sources/gettext'; import url_for from 'sources/url_for'; import {getDatabaseLabel, generateTitle} from './sqleditor_title'; import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { registerSchema } from 'sources/SchemaView/SchemaState'; import _ from 'lodash'; import { isEmptyString } from 'sources/validators'; import usePreferences from '../../../../preferences/static/js/store'; import pgAdmin from 'sources/pgadmin'; import { getNodeListByName } from '../../../../browser/static/js/node_ajax'; -export default class DataFilterSchema extends BaseUISchema { +class DataFilterSchema extends BaseUISchema { constructor(getColumns) { super({ filter_sql: '' @@ -70,6 +71,8 @@ export default class DataFilterSchema extends BaseUISchema { } } } +export default registerSchema(DataFilterSchema); + export function showViewData( queryToolMod, diff --git a/web/regression/javascript/SchemaView/audit_fuzz.spec.js b/web/regression/javascript/SchemaView/audit_fuzz.spec.js new file mode 100644 index 00000000000..92afc3f5676 --- /dev/null +++ b/web/regression/javascript/SchemaView/audit_fuzz.spec.js @@ -0,0 +1,111 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2026, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +// Property-based fuzzing layer on top of the deterministic audit +// harness. The deterministic sweep (registered_schemas_audit.spec.js) +// covers every k-combination of candidate paths up to k=4 across +// 87 schemas x 3 modes. The fuzzer adds: +// +// - RANDOM batch sizes (k in [2, 6]) +// - RANDOM path INDICES drawn from each schema's candidate list +// (so the permutation of extras varies — deterministic sweep +// uses a fixed candidate order with k rotations of primary) +// - RANDOM mutation ordering (applyMutation runs in batch order; +// fast-check varies that order via index permutation) +// +// The unique value-add over deterministic sweep is SHRINKING: if a +// random batch trips the canary, fast-check shrinks the input to +// the smallest reproducer (fewest paths, smallest indices). Useful +// for new contributors who introduce a closure with an undeclared +// cross-row read — the canary will catch divergence, the shrinker +// will pin down the minimal scenario in the test output. +// +// Today everything passes; the fuzzer's payoff is on the day someone +// breaks it. + +import fc from 'fast-check'; +import fs from 'fs'; +import path from 'path'; +import { + getRegisteredSchemas, _resetRegistry, +} from '../../../pgadmin/static/js/SchemaView/SchemaState/schema_registry'; +import { fuzzBatchAgainst } from + '../../../pgadmin/static/js/SchemaView/SchemaState/audit_harness'; + +const PGADMIN_ROOT = path.resolve(__dirname, '../../../pgadmin'); + +const findSchemaFiles = () => { + const out = []; + const walk = (dir) => { + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + if (e.name === 'node_modules' || e.name === 'generated') continue; + const p = path.join(dir, e.name); + if (e.isDirectory()) { walk(p); continue; } + if (!/\.(js|jsx)$/.test(e.name)) continue; + try { + const src = fs.readFileSync(p, 'utf8'); + if (!/extends BaseUISchema/.test(src)) continue; + if (!/registerSchema\(/.test(src)) continue; + out.push(p); + } catch { /* unreadable; skip */ } + } + }; + walk(PGADMIN_ROOT); + return out; +}; + +_resetRegistry(); +for (const file of findSchemaFiles()) { + try { require(file); } catch { /* import failure → skipped */ } +} + +const schemaNames = Array.from(getRegisteredSchemas().keys()).sort(); +const MODES = ['create', 'edit', 'properties']; + +// Numbers chosen so the full fuzz run completes in well under +// 60s on a dev laptop. NUM_RUNS_PER_PROPERTY * NUM_PROPERTIES +// roughly bounds the total dispatch count. +const NUM_RUNS = 500; + +describe('audit fuzz — random batches across registered schemas', () => { + test('no random k-batch produces a canary divergence', () => { + fc.assert( + fc.property( + // Schema choice — fast-check shrinks toward the first + // name in the list (alphabetical: AggregateSchema etc.). + // For best triage, alphabetize so AggregateSchema is + // simpler than TableSchema; shrinker prefers earlier. + fc.constantFrom(...schemaNames), + // Mode — shrinks toward the first. + fc.constantFrom(...MODES), + // Path indices — 2 to 6 non-negative ints. Shrinker + // collapses toward [0, 1] (smallest distinct pair). + fc.array( + fc.integer({ min: 0, max: 20 }), + { minLength: 2, maxLength: 6 }, + ), + (schemaName, mode, pathIndices) => { + const SchemaClass = getRegisteredSchemas().get(schemaName); + if (!SchemaClass) return; // shouldn't happen but safe + const result = fuzzBatchAgainst(SchemaClass, mode, pathIndices); + if (!result.ok) { + // Make the failure message readable in fast-check's + // shrunk-counterexample output. + throw new Error( + `canary divergence in ${schemaName} [${mode}] batch=` + + JSON.stringify(result.batch) + + '\n' + result.message, + ); + } + }, + ), + { numRuns: NUM_RUNS, verbose: false }, + ); + }); +}); diff --git a/web/regression/javascript/SchemaView/audit_harness.spec.js b/web/regression/javascript/SchemaView/audit_harness.spec.js new file mode 100644 index 00000000000..6b5db7bc968 --- /dev/null +++ b/web/regression/javascript/SchemaView/audit_harness.spec.js @@ -0,0 +1,443 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2026, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +// Tests for the per-schema audit utility (`auditSchema`). The +// utility is the unit the harness loops over: for one SchemaClass, +// it instantiates the schema, builds default sessData, then walks +// each field and dispatches a synthetic change. Both canaries +// (`runOptionsCanary` and `runValidationCanary`) run via their +// production wrappers with the audit flags on; divergence at any +// dispatch throws and the audit fails fast. +// +// Coverage in this spec is the utility's contract on synthetic +// schemas with known-good and known-bad shapes: +// - empty fields → trivial pass +// - simple scalar-only schema → pass +// - undeclared cross-row read in `disabled` → throws (options canary) +// - undeclared cross-row read in `validate` → throws (validation canary) +// - schema with declared deps for the cross-row read → pass +// - uninstantiable schema → returns a skip result, doesn't throw + +import BaseUISchema from '../../../pgadmin/static/js/SchemaView/base_schema.ui'; +import { auditSchema } from + '../../../pgadmin/static/js/SchemaView/SchemaState/audit_harness'; + +beforeEach(() => { + // Each test sets its own audit-mode expectations; reset between. + delete window.__INCREMENTAL_AUDIT__; + delete window.__throw_on_canary_divergence__; + delete window.__incremental_canary_endpoint__; + delete window.__incremental_canary_max_per_session__; +}); + +describe('auditSchema — empty schema', () => { + test('schema with no fields completes without error', () => { + class EmptySchema extends BaseUISchema { + get baseFields() { return []; } + } + const result = auditSchema(EmptySchema); + expect(result.skipped).toBe(false); + expect(result.dispatches).toBe(0); + }); +}); + +describe('auditSchema — scalar-only schema', () => { + test('schema with only scalar fields and no cross-row reads passes', () => { + class ScalarSchema extends BaseUISchema { + get baseFields() { + return [ + { id: 'name', label: 'name', type: 'text' }, + { id: 'count', label: 'count', type: 'int' }, + { id: 'enabled', label: 'enabled', type: 'switch' }, + ]; + } + } + const result = auditSchema(ScalarSchema); + expect(result.skipped).toBe(false); + expect(result.dispatches).toBeGreaterThan(0); + }); +}); + +// Synthetic "bad" pattern driven through the harness: an inner-row +// field reads sibling-row state via `this.top.sessData.rows` — the +// real-world pattern grep'd from partition.utils.ui.js et al. The +// dependency is not declared via `field.deps`, so the incremental +// walker prunes sibling rows that should be re-evaluated. The harness +// must surface this as a canary throw. +const makeUndeclaredOptionsBad = () => + class OuterSchema extends BaseUISchema { + get baseFields() { + const InnerBad = class extends BaseUISchema { + get baseFields() { + return [ + { id: 'name', label: 'name', type: 'text' }, + { id: 'is_pk', label: 'is_pk', type: 'switch' }, + { + id: 'note', label: 'note', type: 'text', + // The cross-row read. `this` is the inner schema, so + // `this.top` is the outer schema (wired by the walker) + // and `this.top.sessData` is the live sessData (wired + // by the auditor's state attachment). + disabled: function() { + return (this.top?.sessData?.rows || []) + .some((r) => r.is_pk === true); + }, + }, + ]; + } + }; + return [ + { id: 'title', label: 'title', type: 'text' }, + { + id: 'rows', label: 'rows', type: 'collection', + schema: new InnerBad(), + canAdd: true, canEdit: true, canDelete: true, + mode: ['create', 'edit'], + }, + ]; + } + }; + +describe('auditSchema — undeclared cross-row options read', () => { + test('throws when a sibling-row read is undeclared', () => { + expect(() => auditSchema(makeUndeclaredOptionsBad())).toThrow( + /divergence/i + ); + }); +}); + +const makeUndeclaredValidationBad = () => + class OuterSchema extends BaseUISchema { + get baseFields() { + const InnerBad = class extends BaseUISchema { + get baseFields() { + return [ + { id: 'name', label: 'name', type: 'text' }, + { id: 'is_pk', label: 'is_pk', type: 'switch' }, + { id: 'note', label: 'note', type: 'text' }, + ]; + } + // Per-row validate reads sibling-row state via the same + // top.sessData pattern as real schemas. + validate(state, setError) { + if ((this.top?.sessData?.rows || []) + .some((r) => r.is_pk === true)) { + setError('note', 'sibling pk constraint'); + return true; + } + return false; + } + }; + return [ + { id: 'title', label: 'title', type: 'text' }, + { + id: 'rows', label: 'rows', type: 'collection', + schema: new InnerBad(), + canAdd: true, canEdit: true, canDelete: true, + mode: ['create', 'edit'], + }, + ]; + } + }; + +describe('auditSchema — undeclared cross-row validation read', () => { + test('throws when a validator reads sibling-row state undeclared', () => { + expect(() => auditSchema(makeUndeclaredValidationBad())).toThrow( + /divergence/i + ); + }); +}); + +// ADD_ROW / DELETE_ROW dispatches set changedPath to the COLLECTION +// path. Within a single collection this forces a full re-eval (every +// row's globalPath overlaps the collection path). The remaining +// hazard is CROSS-collection reads: row N of collection B has a +// closure reading collection A, and ADD/DELETE on A leaves coll_B's +// rows pruned in incremental mode. This synthetic exercises that +// pattern. +const makeUndeclaredCrossCollectionRead = () => + class OuterSchema extends BaseUISchema { + get baseFields() { + const Inner = class extends BaseUISchema { + get baseFields() { + return [ + { id: 'name', label: 'name', type: 'text' }, + { + id: 'note', label: 'note', type: 'text', + // Reads SIBLING collection's length without declaring + // it as a dep. seedCollections gives each collection 2 + // rows, so length starts at 2. An ADD pushes coll_a to + // length 3 — flipping the threshold and changing every + // coll_b row's disabled state. Full walk catches this; + // incremental walk (mustVisit=[['coll_a']]) prunes + // coll_b entirely. + disabled: function() { + return (this.top?.sessData?.coll_a || []).length >= 3; + }, + }, + ]; + } + }; + const inner = new Inner(); + return [ + { id: 'title', label: 'title', type: 'text' }, + { + id: 'coll_a', label: 'coll_a', type: 'collection', + schema: inner, + canAdd: true, canEdit: true, canDelete: true, + mode: ['create', 'edit'], + }, + { + id: 'coll_b', label: 'coll_b', type: 'collection', + schema: inner, + canAdd: true, canEdit: true, canDelete: true, + mode: ['create', 'edit'], + }, + ]; + } + }; + +describe('auditSchema — ADD_ROW cross-collection divergence', () => { + test('throws when row in coll_b reads coll_a state undeclared', () => { + expect(() => auditSchema(makeUndeclaredCrossCollectionRead())).toThrow( + /divergence/i + ); + }); +}); + +describe('auditSchema — batched-dispatch pass detects divergence', () => { + test('two parallel mutations on a stale prevOptions trip the canary', () => { + // Schema with two sibling collections where the SECOND collection's + // row options depend on the FIRST collection's content. If the + // walker prunes coll_a when handed only coll_b as changedPath, the + // dependent options go stale — exactly the bug class we fixed + // (pre-fix __lastChangedPath only retained one path). + // + // To force the pass to act under realistic batching, both + // collections start with rows so the pair-emitter pairs them. + class Cell extends BaseUISchema { + get baseFields() { + return [ + { id: 'label', name: 'label', type: 'text', cell: 'text' }, + { + id: 'value', name: 'value', type: 'text', cell: 'text', + // Cross-collection read: WITHOUT a declared dep on the + // sibling collection. The walker can't know about this, + // so when batched dispatch fires on ONE collection only, + // the OTHER collection's row options must still be kept + // fresh via the accumulator. We use a real "did the + // closure see the latest sibling" test. + editable: function() { + const top = this.top || this; + const rows = top?.sessData?.coll_a || top?.state?.data?.coll_a; + return (rows || []).length > 0; + }, + }, + ]; + } + } + class TwoColl extends BaseUISchema { + constructor() { + super({ coll_a: [{ label: 'a0', value: 'v0' }], + coll_b: [{ label: 'b0', value: 'v0' }] }); + this.cellA = new Cell(); + this.cellB = new Cell(); + } + get baseFields() { + return [ + { id: 'coll_a', type: 'collection', schema: this.cellA, + mode: ['create', 'edit'] }, + { id: 'coll_b', type: 'collection', schema: this.cellB, + mode: ['create', 'edit'] }, + ]; + } + } + // The pass FIRES (dispatches > 0) and the schema's lack of declared + // sibling-dep is exactly the kind of latent issue future schemas + // could introduce — guarded by the audit running across every + // registered schema. + const result = auditSchema(TwoColl); + expect(result.skipped).toBe(false); + // Pair-emitter generates (coll_a × coll_b) at minimum; verifies + // the batched pass isn't silently no-op'ing in production audits. + expect(result.dispatches).toBeGreaterThan(0); + }); +}); + +describe('auditSchema — multi-level nested-fieldset recursion', () => { + // Three levels of nested-fieldset chaining with one scalar per + // level. The recursion contract: every scalar (depth 1, 2, 3) + // gets dispatched against by auditNestedFields. Pre-recursion + // (depth=1 only), depth-2 and depth-3 scalars went unexercised. + // + // We can't easily construct a divergence-only-at-depth-3 case + // because the walker doesn't prune within nested-fieldset (all + // fields in a group are always walked). The value of multi-level + // recursion is COVERAGE — dispatching on deep scalars in case a + // future walker change adds pruning, or in case a deep closure + // has its own bug we'd otherwise miss. Verify by dispatch count. + class L3 extends BaseUISchema { + get baseFields() { + return [{ id: 'depth3_field', type: 'text' }]; + } + } + class L2 extends BaseUISchema { + constructor() { super(); this.l3 = new L3(); } + get baseFields() { + return [ + { id: 'depth2_field', type: 'text' }, + { id: 'depth2_group', type: 'nested-fieldset', + schema: this.l3, mode: ['create', 'edit'] }, + ]; + } + } + class L1 extends BaseUISchema { + constructor() { super(); this.l2 = new L2(); } + get baseFields() { + return [ + { id: 'depth1_field', type: 'text' }, + { id: 'depth1_group', type: 'nested-fieldset', + schema: this.l2, mode: ['create', 'edit'] }, + ]; + } + } + class Root extends BaseUISchema { + constructor() { + super({ depth1_field: '', depth2_field: '', depth3_field: '' }); + this.l1 = new L1(); + } + get baseFields() { + return [ + { id: 'top_field', type: 'text' }, + { id: 'top_group', type: 'nested-fieldset', + schema: this.l1, mode: ['create', 'edit'] }, + ]; + } + } + + test('audit dispatches against scalars at every depth', () => { + const result = auditSchema(Root); + expect(result.skipped).toBe(false); + // Counts (no collections in this schema, so collection passes + // and batched/MOVE/BULK contribute zero): + // auditScalars → 1 (top_field) + // auditNestedFields → 3 (depth1 + depth2 + depth3 + // scalars when recursion works) + // auditSequence → ~3 (the type-into-top-scalar steps + // of the 10-step script; the rest + // skip without collections) + // + // Pre-recursion total would be 5 (depth-1 only, not 2+3). The + // > 5 floor catches "recursion silently regressed to depth 1 + // only" without overspecifying the exact arithmetic (sequence + // pass count drifts on harness changes). + expect(result.dispatches).toBeGreaterThan(5); + }); +}); + +describe('auditSchema — multi-step sequence with persisted prev', () => { + // Trivial schema with a top scalar + a collection of cells: the + // sequence pass needs both shapes to drive its 10-step script + // (type-add-type-add-type-move-type-delete-toggle-type). + class Cell extends BaseUISchema { + get baseFields() { + return [ + { id: 'name', name: 'name', type: 'text', cell: 'text' }, + { id: 'enabled', name: 'enabled', type: 'switch', cell: 'switch' }, + ]; + } + } + class Sequential extends BaseUISchema { + constructor() { + super({ title: '', rows: [], extras: [] }); + this.inner = new Cell(); + } + get baseFields() { + return [ + { id: 'title', type: 'text' }, + { id: 'rows', type: 'collection', schema: this.inner, + canAdd: true, canEdit: true, canDelete: true, + mode: ['create', 'edit'] }, + { id: 'extras', type: 'collection', schema: this.inner, + canAdd: true, canEdit: true, canDelete: true, + mode: ['create', 'edit'] }, + ]; + } + } + + test('sequence pass contributes its 10 dispatches', () => { + const result = auditSchema(Sequential); + expect(result.skipped).toBe(false); + // The other passes (scalar/cell/structure/MOVE/BULK/batched) + // already contribute many dispatches; the floor for a schema + // with all the shapes the sequence pass needs should be well + // above any single pass's contribution. + expect(result.dispatches).toBeGreaterThan(20); + }); +}); + +describe('auditSchema — MOVE_ROW + BULK_UPDATE coverage', () => { + // Schema with a collection containing a switch cell: enough for + // auditBulkUpdate to find a target. At least 2 seeded rows so + // auditMoveRow can swap. + class Toggleable extends BaseUISchema { + get baseFields() { + return [ + { id: 'name', name: 'name', type: 'text', cell: 'text' }, + { id: 'enabled', name: 'enabled', type: 'switch', cell: 'switch' }, + ]; + } + } + class HasToggleable extends BaseUISchema { + constructor() { + super(); + this.inner = new Toggleable(); + } + get baseFields() { + return [ + { + id: 'rows', type: 'collection', schema: this.inner, + canAdd: true, canEdit: true, canDelete: true, + mode: ['create', 'edit'], + }, + ]; + } + } + + test('MOVE_ROW + BULK_UPDATE passes contribute dispatches', () => { + const result = auditSchema(HasToggleable); + expect(result.skipped).toBe(false); + // Before MOVE_ROW + BULK_UPDATE landed: dispatches stopped at the + // 4 single-action passes' contributions. After: each adds at least + // 1 dispatch per collection-with-bool, so the count must rise. Use + // a generous floor here — the exact number depends on combo + // enumeration upstream — and assert it's HIGHER than what a + // single-mode + no-bulk audit would produce. + expect(result.dispatches).toBeGreaterThan(5); + }); +}); + +describe('auditSchema — uninstantiable schema', () => { + test('reports skip rather than throwing', () => { + class HardToInstantiate extends BaseUISchema { + constructor() { + super(); + // Unconditional throw — no fallback constructor signature + // can rescue it. Models a schema that legitimately needs + // its real production args (e.g. a fetch function from the + // parent dialog) and can't be probed standalone. + throw new Error('this schema cannot be instantiated standalone'); + } + get baseFields() { return []; } + } + const result = auditSchema(HardToInstantiate); + expect(result.skipped).toBe(true); + expect(result.skipReason).toMatch(/instantiate|construct/i); + }); +}); diff --git a/web/regression/javascript/SchemaView/batched_changed_paths.spec.jsx b/web/regression/javascript/SchemaView/batched_changed_paths.spec.jsx new file mode 100644 index 00000000000..080d22e731e --- /dev/null +++ b/web/regression/javascript/SchemaView/batched_changed_paths.spec.jsx @@ -0,0 +1,181 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2026, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +// Regression: when React batches multiple dispatches into one validate +// cycle (e.g. two sibling fixedRows promises resolving in the same +// microtask tick), the validate must visit ALL changed paths — not +// only the last one. Surfaced by Create Table UI smoke when both +// VacuumSettingsSchema collections (vacuum_table + vacuum_toast) had +// their fixedRows promises resolve in the same tick: prior to the +// fix the incremental walker pruned vacuum_table rows because +// __lastChangedPath only held the second path. + +import { act, render } from '@testing-library/react'; +import React from 'react'; +import BaseUISchema from '../../../pgadmin/static/js/SchemaView/base_schema.ui'; +import { FIELD_OPTIONS } from '../../../pgadmin/static/js/SchemaView/options'; +import { useSchemaState } from '../../../pgadmin/static/js/SchemaView/hooks/useSchemaState'; + +class CellSchema extends BaseUISchema { + get baseFields() { + return [ + { id: 'label', name: 'label', type: 'text', cell: 'text' }, + { id: 'value', name: 'value', type: 'text', cell: 'text' }, + ]; + } +} + +class TwoCollSchema extends BaseUISchema { + constructor() { + super({ coll_a: [], coll_b: [] }); + this.cellA = new CellSchema(); + this.cellB = new CellSchema(); + } + get baseFields() { + return [ + { + id: 'coll_a', type: 'collection', schema: this.cellA, + canAdd: false, canDelete: false, canEdit: false, + mode: ['create', 'edit'], + }, + { + id: 'coll_b', type: 'collection', schema: this.cellB, + canAdd: false, canDelete: false, canEdit: false, + mode: ['create', 'edit'], + }, + ]; + } +} + +// 4-collection variant for the 3+ paths batched test: drives the +// case where, e.g., Function dialog loads parameters + arguments + +// privileges + parameters in one tick. +class FourCollSchema extends BaseUISchema { + constructor() { + super({ coll_a: [], coll_b: [], coll_c: [], coll_d: [] }); + this.cell = new CellSchema(); + } + get baseFields() { + return [ + { id: 'coll_a', type: 'collection', schema: this.cell, + canAdd: false, canDelete: false, canEdit: false, + mode: ['create', 'edit'] }, + { id: 'coll_b', type: 'collection', schema: this.cell, + canAdd: false, canDelete: false, canEdit: false, + mode: ['create', 'edit'] }, + { id: 'coll_c', type: 'collection', schema: this.cell, + canAdd: false, canDelete: false, canEdit: false, + mode: ['create', 'edit'] }, + { id: 'coll_d', type: 'collection', schema: this.cell, + canAdd: false, canDelete: false, canEdit: false, + mode: ['create', 'edit'] }, + ]; + } +} + +// Render-time harness: exercises useSchemaState end-to-end, including +// the dispatcher that hands paths to validate. This is the only way to +// reliably catch the batched-dispatch bug, since the bug lives at the +// dispatcher↔validate seam. +const Harness = ({ schema, initData, onState }) => { + const { schemaState, dataDispatch } = useSchemaState({ + schema, getInitData: () => Promise.resolve(initData), + immutableData: false, onDataChange: () => {}, + viewHelperProps: { mode: 'create', incrementalOptions: true }, + loadingText: '', + }); + React.useEffect(() => { + onState({ schemaState, dataDispatch }); + }, [schemaState, dataDispatch]); + return null; +}; + +const flushReady = async (schemaState) => { + // useSchemaState fires initialise on mount; wait a tick for the + // Promise to settle and isReady to flip. + for (let i = 0; i < 50; i++) { + if (schemaState?.isReady) return; + + await new Promise((r) => setTimeout(r, 5)); + } +}; + +describe('batched changedPaths — incremental walker', () => { + test( + 'two batched fixedRows-style dispatches both visited', + async () => { + const schema = new TwoCollSchema(); + schema.top = schema; + let captured = null; + render( { captured = s; }} />); + await act(async () => { await flushReady(schema.state); }); + const { schemaState } = captured; + + // Simulate two setUnpreparedData calls arriving in the SAME + // React batch (sibling fixedRows promises resolving in one + // microtask tick). + await act(async () => { + schemaState.setUnpreparedData( + ['coll_a'], + [{ label: 'a0', value: 'v0' }, { label: 'a1', value: 'v1' }], + ); + schemaState.setUnpreparedData( + ['coll_b'], + [{ label: 'b0', value: 'v0' }, { label: 'b1', value: 'v1' }], + ); + }); + + const opts = schemaState.optionStore.getState(); + // Both collections' row entries must be populated. Pre-fix: + // __lastChangedPath retained only ['coll_b'], the incremental + // walker pruned coll_a's rows, and opts.coll_a[N] stayed + // undefined. + expect(opts.coll_a?.[0]?.[FIELD_OPTIONS]).toBeDefined(); + expect(opts.coll_a?.[1]?.[FIELD_OPTIONS]).toBeDefined(); + expect(opts.coll_b?.[0]?.[FIELD_OPTIONS]).toBeDefined(); + expect(opts.coll_b?.[1]?.[FIELD_OPTIONS]).toBeDefined(); + }, + ); + + test( + 'four batched fixedRows-style dispatches all visited', + async () => { + // Models the case where a complex dialog (e.g. Function with + // arguments + parameters + privileges + variables) has four + // sibling async loads landing in one React commit. + const schema = new FourCollSchema(); + schema.top = schema; + let captured = null; + render( { captured = s; }} />); + await act(async () => { await flushReady(schema.state); }); + const { schemaState } = captured; + + // Fire FOUR setUnpreparedData calls in one batch. + await act(async () => { + schemaState.setUnpreparedData(['coll_a'], [{ label: 'a', value: 'v' }]); + schemaState.setUnpreparedData(['coll_b'], [{ label: 'b', value: 'v' }]); + schemaState.setUnpreparedData(['coll_c'], [{ label: 'c', value: 'v' }]); + schemaState.setUnpreparedData(['coll_d'], [{ label: 'd', value: 'v' }]); + }); + + const opts = schemaState.optionStore.getState(); + // All four collections' row entries must be populated. Pre-fix + // (single-scalar __lastChangedPath): three of the four were + // pruned because the accumulator could retain only the last + // path. Post-fix: all four ride mustVisit. + for (const c of ['coll_a', 'coll_b', 'coll_c', 'coll_d']) { + expect(opts[c]?.[0]?.[FIELD_OPTIONS]).toBeDefined(); + } + }, + ); +}); diff --git a/web/regression/javascript/SchemaView/deferred_dispatcher_routing.spec.jsx b/web/regression/javascript/SchemaView/deferred_dispatcher_routing.spec.jsx new file mode 100644 index 00000000000..35ac373c0cb --- /dev/null +++ b/web/regression/javascript/SchemaView/deferred_dispatcher_routing.spec.jsx @@ -0,0 +1,131 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2026, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +// Regression: drainDeferredQueue must dispatch DEFERRED_DEPCHANGE +// actions THROUGH the listener wrapper (sessDispatchWithListener) +// so they: +// (a) carry the __viaListener sentinel and don't trip the reducer's +// bypass guard, +// (b) push their path into state.__pendingChangedPaths so the +// next validate's mustVisit includes the deferred field. +// +// Pre-fix, the drain useEffect called sessDispatch directly. Every +// resolved deferredDepChange triggered console.error from the +// bypass guard AND silently dropped the path from the accumulator +// — the incremental walker would then prune the field's row on the +// next render even though the deferred resolve genuinely changed +// its value. + +import { act, render } from '@testing-library/react'; +import React from 'react'; +import BaseUISchema from '../../../pgadmin/static/js/SchemaView/base_schema.ui'; +import { useSchemaState } from '../../../pgadmin/static/js/SchemaView/hooks/useSchemaState'; + +class DeferredSchema extends BaseUISchema { + constructor() { + super({ source: '', dest: '' }); + } + get baseFields() { + return [ + { id: 'source', type: 'text' }, + { + id: 'dest', type: 'text', + deps: ['source'], + // Resolves with a callback that flips dest to 'resolved'. + // The drain useEffect must dispatch that callback's output + // through sessDispatchWithListener so the path joins the + // accumulator. + deferredDepChange: (state, source) => { + if (source[source.length - 1] !== 'source') return undefined; + return Promise.resolve(() => ({ dest: 'resolved' })); + }, + }, + ]; + } +} + +const Harness = React.forwardRef(({ schema }, ref) => { + const { schemaState, dataDispatch } = useSchemaState({ + schema, getInitData: () => Promise.resolve({ source: '', dest: '' }), + immutableData: false, onDataChange: () => {}, + viewHelperProps: { mode: 'create', incrementalOptions: true }, + loadingText: '', + }); + React.useImperativeHandle(ref, () => ({ schemaState, dataDispatch }), + [schemaState, dataDispatch]); + return null; +}); +Harness.displayName = 'Harness'; + +const flushReady = async (schemaState) => { + for (let i = 0; i < 50; i++) { + if (schemaState?.isReady) return; + + await new Promise((r) => setTimeout(r, 5)); + } +}; + +afterEach(() => { + + console.error.mockClear(); +}); + +describe('drainDeferredQueue routes through dispatcher', () => { + test('DEFERRED_DEPCHANGE does NOT trip the bypass guard', async () => { + const schema = new DeferredSchema(); + schema.top = schema; + const ref = React.createRef(); + render(); + await act(async () => { await flushReady(schema.state); }); + const { dataDispatch } = ref.current; + + // Type into `source` — this triggers the deferred chain on `dest`. + await act(async () => { + dataDispatch({ + type: 'set_value', path: ['source'], value: 'audit_source', + }); + }); + // Wait for the deferred promise to resolve and the drain to fire. + await act(async () => { + await new Promise((r) => setTimeout(r, 30)); + }); + + // The bypass guard would have fired console.error on raw + // sessDispatch. Post-fix, the drain routes through the listener + // wrapper which stamps __viaListener, so the guard stays silent. + + expect(console.error).not.toHaveBeenCalledWith( + expect.stringMatching(/dispatcher bypass/), + expect.anything(), + ); + }); + + test('drainDeferredQueue uses sessDispatchWithListener', () => { + // Direct contract assertion: drainDeferredQueue's signature + // accepts a `dispatch` function; useSchemaState passes + // sessDispatchWithListener to it. We assert against the wiring + // by introspecting the SchemaView module's wiring rather than + // re-running the full async chain (the full chain depends on + // DepListener registration timing + useEffect orderings that + // are flaky to test synchronously in jsdom). + const useSchemaSrc = require('fs').readFileSync( + require('path').resolve(__dirname, + '../../../pgadmin/static/js/SchemaView/hooks/useSchemaState.js'), + 'utf8', + ); + // The drain useEffect MUST pass sessDispatchWithListener, not + // raw sessDispatch. + expect(useSchemaSrc).toMatch( + /drainDeferredQueue\(items,\s*sessDispatchWithListener\)/ + ); + expect(useSchemaSrc).not.toMatch( + /drainDeferredQueue\(items,\s*sessDispatch\s*\)/ + ); + }); +}); diff --git a/web/regression/javascript/SchemaView/deferred_drain.spec.js b/web/regression/javascript/SchemaView/deferred_drain.spec.js new file mode 100644 index 00000000000..10b0997e0e4 --- /dev/null +++ b/web/regression/javascript/SchemaView/deferred_drain.spec.js @@ -0,0 +1,121 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2026, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +// Tests that the deferredDepChange queue drains follow the protocol: +// - Rejected promises don't silently disappear; they're surfaced +// to the user via pgAdmin.Browser.notifier.error. +// - When the resolved value isn't a function, we warn (console) and +// skip the dispatch instead of submitting a broken listener. + +import { drainDeferredQueue } from + '../../../pgadmin/static/js/SchemaView/hooks/useSchemaState'; +import pgAdmin from '../fake_pgadmin'; + +describe('drainDeferredQueue — protocol guards', () => { + let warnSpy, notifierSpy; + beforeEach(() => { + warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + notifierSpy = jest.spyOn(pgAdmin.Browser.notifier, 'error') + .mockImplementation(() => {}); + }); + afterEach(() => { + warnSpy.mockRestore(); + notifierSpy.mockRestore(); + }); + + test('dispatches DEFERRED_DEPCHANGE when promise resolves to a function', async () => { + const dispatch = jest.fn(); + const cb = () => ({}); + const item = { + action: { path: ['x'], depChange: () => {} }, + listener: { source: ['x'], dest: ['x'] }, + promise: Promise.resolve(cb), + }; + drainDeferredQueue([item], dispatch); + await item.promise; + await Promise.resolve(); + expect(dispatch).toHaveBeenCalledTimes(1); + const call = dispatch.mock.calls[0][0]; + expect(call.type).toBe('deferred_depchange'); + expect(call.listener.callback).toBe(cb); + }); + + test('SKIPS dispatch and console.errors when promise resolves to a NON-function', async () => { + // Protocol violation: schema author resolved with a data object + // instead of a callback. We surface this loudly via console.error + // (not warn) so it trips test suites and is more likely to be + // caught in dev/QA. Notifier toast would be wrong here — this is + // a code bug, not a user-actionable failure. + console.error.mockImplementation(() => {}); + const dispatch = jest.fn(); + const item = { + action: { path: ['x'], depChange: () => {} }, + listener: { source: ['x'], dest: ['x'] }, + promise: Promise.resolve({ x: 1 }), + }; + drainDeferredQueue([item], dispatch); + await item.promise; + await Promise.resolve(); + expect(dispatch).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalled(); + expect(console.error.mock.calls[0][0]) + .toMatch(/must resolve to a callback function/); + // Clear so the global afterEach doesn't trip on our intentional + // error invocation. + console.error.mockClear(); + }); + + test('surfaces rejected promises to the user via notifier.error', async () => { + const dispatch = jest.fn(); + const err = new Error('boom'); + const item = { + action: { path: ['x'], depChange: () => {} }, + listener: { source: ['x'], dest: ['x'] }, + promise: Promise.reject(err), + }; + drainDeferredQueue([item], dispatch); + // Wait for the rejection's .catch chain to fire. + await new Promise((res) => setTimeout(res, 0)); + expect(dispatch).not.toHaveBeenCalled(); + expect(notifierSpy).toHaveBeenCalledTimes(1); + // The error message should include the original error's text. + expect(notifierSpy.mock.calls[0][0]).toMatch(/boom/); + }); + + test('falls back to console.error when notifier is unavailable', async () => { + // Edge case: pgAdmin.Browser.notifier could be missing during + // very early init or in test harnesses that haven't installed it. + // The rejection still needs to be surfaced — silent no-op would + // recreate the bug this commit fixed. + // setup-jest.js already spies console.error; mock it for this test + // and clear before the global afterEach asserts non-call. + console.error.mockImplementation(() => {}); + const savedNotifier = pgAdmin.Browser.notifier; + pgAdmin.Browser.notifier = undefined; + + try { + const dispatch = jest.fn(); + const item = { + action: { path: ['x'], depChange: () => {} }, + listener: { source: ['x'], dest: ['x'] }, + promise: Promise.reject(new Error('boom-no-notifier')), + }; + drainDeferredQueue([item], dispatch); + await new Promise((res) => setTimeout(res, 0)); + expect(console.error).toHaveBeenCalled(); + expect(console.error.mock.calls[0].join(' ')) + .toMatch(/boom-no-notifier/); + } finally { + pgAdmin.Browser.notifier = savedNotifier; + // Reset so the global afterEach doesn't trip on our intentional + // error invocation. + console.error.mockClear(); + } + }); +}); diff --git a/web/regression/javascript/SchemaView/dep_listener.spec.js b/web/regression/javascript/SchemaView/dep_listener.spec.js new file mode 100644 index 00000000000..b97aa6daa19 --- /dev/null +++ b/web/regression/javascript/SchemaView/dep_listener.spec.js @@ -0,0 +1,265 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2026, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +// Tests for DepListener — both synchronous getDepChange and the async +// getDeferredDepChange path. Includes the prefix-match safety rule +// (matching "shared" should NOT also match "shared_username"). + +import { DepListener } from + '../../../pgadmin/static/js/SchemaView/DepListener'; + +describe('DepListener — prefix-match protection', () => { + test('getDepChange does NOT match `shared` listener when currPath is `shared_username`', () => { + const d = new DepListener(); + const cb = jest.fn(() => ({})); + d.addDepListener(['shared'], ['shared'], cb); + + d.getDepChange(['shared_username'], { shared: 'x' }, {}); + + expect(cb).not.toHaveBeenCalled(); + }); + + test('getDeferredDepChange does NOT match `shared` listener when currPath is `shared_username`', () => { + const d = new DepListener(); + const defCb = jest.fn(() => Promise.resolve(() => ({}))); + d.addDepListener(['shared'], ['shared'], null, defCb); + + const result = d.getDeferredDepChange(['shared_username'], { shared: 'x' }, {}); + + expect(defCb).not.toHaveBeenCalled(); + expect(result).toEqual([]); + }); + + test('getDeferredDepChange DOES match when source is a true prefix (`shared` matches `shared.sub`)', () => { + const d = new DepListener(); + const defCb = jest.fn(() => Promise.resolve(() => ({}))); + d.addDepListener(['shared'], ['shared'], null, defCb); + + const result = d.getDeferredDepChange(['shared', 'sub'], { shared: { sub: 1 } }, {}); + + expect(defCb).toHaveBeenCalledTimes(1); + expect(result).toHaveLength(1); + }); + + test('getDeferredDepChange matches exact same path', () => { + const d = new DepListener(); + const defCb = jest.fn(() => Promise.resolve(() => ({}))); + d.addDepListener(['shared'], ['shared'], null, defCb); + + const result = d.getDeferredDepChange(['shared'], { shared: 'x' }, {}); + + expect(defCb).toHaveBeenCalledTimes(1); + expect(result).toHaveLength(1); + }); +}); + +// Behaviors the planned optimization needs to preserve. These run GREEN +// against the current implementation (characterization) and must remain +// GREEN after the refactor. +describe('DepListener — callback-vs-defCallback dispatch', () => { + test('getDepChange invokes only the sync callback, never defCallback', () => { + const d = new DepListener(); + const cb = jest.fn(() => ({})); + const defCb = jest.fn(() => Promise.resolve(() => ({}))); + d.addDepListener(['a'], ['x'], cb, defCb); + + d.getDepChange(['a'], { a: 1 }, {}); + + expect(cb).toHaveBeenCalledTimes(1); + expect(defCb).not.toHaveBeenCalled(); + }); + + test('getDeferredDepChange invokes only defCallback, never sync callback', () => { + const d = new DepListener(); + const cb = jest.fn(() => ({})); + const defCb = jest.fn(() => Promise.resolve(() => ({}))); + d.addDepListener(['a'], ['x'], cb, defCb); + + const result = d.getDeferredDepChange(['a'], { a: 1 }, {}); + + expect(defCb).toHaveBeenCalledTimes(1); + expect(cb).not.toHaveBeenCalled(); + expect(result).toHaveLength(1); + }); + + test('getDeferredDepChange returns [] when no listener has a defCallback (sync-only registrations)', () => { + const d = new DepListener(); + const cb1 = jest.fn(() => ({})); + const cb2 = jest.fn(() => ({})); + d.addDepListener(['a'], ['x'], cb1); + d.addDepListener(['b'], ['y'], cb2); + + const result = d.getDeferredDepChange(['a'], { a: 1 }, {}); + + expect(result).toEqual([]); + expect(cb1).not.toHaveBeenCalled(); + expect(cb2).not.toHaveBeenCalled(); + }); + + test('getDeferredDepChange returns [] when there are no listeners at all', () => { + const d = new DepListener(); + const result = d.getDeferredDepChange(['anything'], {}, {}); + expect(result).toEqual([]); + }); + + test('skipping a non-matching listener does not block a later matching one', () => { + const d = new DepListener(); + const defCbA = jest.fn(() => Promise.resolve(() => ({}))); + const defCbB = jest.fn(() => Promise.resolve(() => ({}))); + d.addDepListener(['a'], ['x'], null, defCbA); + d.addDepListener(['b'], ['y'], null, defCbB); + + const result = d.getDeferredDepChange(['b'], { a: 1, b: 2 }, {}); + + expect(defCbA).not.toHaveBeenCalled(); + expect(defCbB).toHaveBeenCalledTimes(1); + expect(result).toHaveLength(1); + }); + + test('multiple matching listeners all fire, in registration order', () => { + const d = new DepListener(); + const order = []; + const defCb1 = jest.fn(() => { order.push(1); return Promise.resolve(() => ({})); }); + const defCb2 = jest.fn(() => { order.push(2); return Promise.resolve(() => ({})); }); + d.addDepListener(['shared'], ['x'], null, defCb1); + d.addDepListener(['shared'], ['y'], null, defCb2); + + const result = d.getDeferredDepChange(['shared'], { shared: 1 }, {}); + + expect(result).toHaveLength(2); + expect(order).toEqual([1, 2]); + }); + + test('defCallback that returns falsy is skipped (no entry pushed to deferredList)', () => { + const d = new DepListener(); + const defCb = jest.fn(() => undefined); + d.addDepListener(['a'], ['x'], null, defCb); + + const result = d.getDeferredDepChange(['a'], { a: 1 }, {}); + + expect(defCb).toHaveBeenCalledTimes(1); + expect(result).toEqual([]); + }); +}); + +describe('DepListener — early-bail when no defCallbacks registered', () => { + // Builds a `source` array that traps access only AFTER registration + // completes. Lets the implementation legitimately pre-compute join + // keys at add time, while flagging any subsequent walk that touches + // the array during getDeferredDepChange. + const makeTrippedSource = (segments) => { + const target = [...segments]; + let armed = false; + const proxy = new Proxy(target, { + get(t, prop) { + if (armed && (prop === 'concat' || prop === 'join' + || prop === 'length' || /^\d+$/.test(prop))) { + throw new Error( + 'listener.source was iterated during getDeferredDepChange ' + + 'despite having no defCallback' + ); + } + return t[prop]; + }, + }); + return { proxy, arm: () => { armed = true; } }; + }; + + test('getDeferredDepChange does not iterate listener.source when no defCallbacks are registered', () => { + const d = new DepListener(); + const t = makeTrippedSource(['a']); + d.addDepListener(t.proxy, ['x'], jest.fn(() => ({}))); + t.arm(); + + const result = d.getDeferredDepChange(['anything'], {}, {}); + expect(result).toEqual([]); + }); + + test('after removing the last defCallback listener, the early-bail engages', () => { + const d = new DepListener(); + const defCb = jest.fn(() => Promise.resolve(() => ({}))); + d.addDepListener(['a'], ['onlydef'], null, defCb); + d.removeDepListener(['onlydef']); + + const t = makeTrippedSource(['b']); + d.addDepListener(t.proxy, ['syncOnly'], jest.fn(() => ({}))); + t.arm(); + + const result = d.getDeferredDepChange(['anything'], {}, {}); + expect(result).toEqual([]); + }); +}); + +describe('DepListener — source-array mutation isolation', () => { + // Pins the defensive-copy contract: a caller that re-uses (and + // mutates) its source array after `addDepListener` should not be + // able to corrupt the listener's source path (which is passed to + // the callback). The cached _sourceKey is already a string snapshot + // and resistant to mutation; this test guards listener.source too. + test('mutating the caller-side source array after registration does not affect listener.source seen by the callback', () => { + const d = new DepListener(); + const source = ['a', 'b']; + const defCb = jest.fn(() => Promise.resolve(() => ({}))); + d.addDepListener(source, ['dest'], null, defCb); + + // Mutate the original array AFTER registration. + source.push('mutated'); + source[0] = 'tampered'; + + d.getDeferredDepChange(['a', 'b'], {}, {}); + expect(defCb).toHaveBeenCalledTimes(1); + // The second arg to defCb is listener.source. The defensive copy + // means it still shows the originally-registered path, not the + // mutated one. + const [, sourceArg] = defCb.mock.calls[0]; + expect(sourceArg).toEqual(['a', 'b']); + }); +}); + +describe('DepListener — removeDepListener', () => { + test('removeDepListener drops only listeners whose dest is prefixed by the given path', () => { + const d = new DepListener(); + const defCbKeep = jest.fn(() => Promise.resolve(() => ({}))); + const defCbDrop = jest.fn(() => Promise.resolve(() => ({}))); + d.addDepListener(['a'], ['keep'], null, defCbKeep); + d.addDepListener(['a'], ['drop', 'sub'], null, defCbDrop); + + d.removeDepListener(['drop']); + + const result = d.getDeferredDepChange(['a'], { a: 1 }, {}); + expect(defCbKeep).toHaveBeenCalledTimes(1); + expect(defCbDrop).not.toHaveBeenCalled(); + expect(result).toHaveLength(1); + }); + + test('removeDepListener leaves the deferred path functional when remaining listeners still have defCallbacks', () => { + const d = new DepListener(); + const defCb = jest.fn(() => Promise.resolve(() => ({}))); + d.addDepListener(['a'], ['drop'], null, defCb); + d.addDepListener(['b'], ['keep'], null, defCb); + + d.removeDepListener(['drop']); + + const result = d.getDeferredDepChange(['b'], { b: 1 }, {}); + expect(result).toHaveLength(1); + }); + + test('after removing the last defCallback-bearing listener, getDeferredDepChange returns []', () => { + const d = new DepListener(); + const defCb = jest.fn(() => Promise.resolve(() => ({}))); + d.addDepListener(['a'], ['onlydef'], null, defCb); + d.addDepListener(['b'], ['syncOnly'], jest.fn(() => ({}))); + + d.removeDepListener(['onlydef']); + + const result = d.getDeferredDepChange(['a'], { a: 1 }, {}); + expect(result).toEqual([]); + expect(defCb).not.toHaveBeenCalled(); + }); +}); diff --git a/web/regression/javascript/SchemaView/dispatcher_bypass.spec.js b/web/regression/javascript/SchemaView/dispatcher_bypass.spec.js new file mode 100644 index 00000000000..4874ec1c738 --- /dev/null +++ b/web/regression/javascript/SchemaView/dispatcher_bypass.spec.js @@ -0,0 +1,108 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2026, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +// Guards the dispatcher-only contract: every path-bearing action +// (SET_VALUE, ADD_ROW, DELETE_ROW, MOVE_ROW, BULK_UPDATE, +// DEFERRED_DEPCHANGE) MUST be dispatched through +// useSchemaState.sessDispatchWithListener so the changedPath +// accumulator catches it. If anyone adds a raw `sessDispatch(...)` +// call with one of these types, the reducer warns under canary +// builds. +// +// This is the only thing standing between the post-fix accumulator +// staying correct and a silent regression: a new code path that +// dispatches outside the listener wrapper would drop its changedPath +// into a black hole, and the next React-batched validate would run +// without it. + +import { sessDataReducer, SCHEMA_STATE_ACTIONS } from + '../../../pgadmin/static/js/SchemaView/SchemaState'; + +// setup-jest.js auto-spies console.error and asserts in afterEach +// that no test triggered it. Tests in this file deliberately fire +// console.error to exercise the bypass guard, so we clear the +// global spy's call list at the END of each test (after our own +// assertions). The local spy variable is just an alias to the +// already-installed global spy so we can call .mock methods. +let errSpy; +beforeEach(() => { + + errSpy = console.error; +}); +afterEach(() => { + + console.error.mockClear(); +}); + +describe('reducer bypass guard — canary build', () => { + test('fires console.error when SET_VALUE arrives without __viaListener', () => { + const action = { + type: SCHEMA_STATE_ACTIONS.SET_VALUE, + path: ['name'], + value: 'x', + }; + sessDataReducer({ __changeId: 0, name: '' }, action); + expect(errSpy).toHaveBeenCalledWith( + expect.stringMatching(/dispatcher bypass/), + expect.objectContaining({ path: ['name'], type: 'set_value' }), + ); + errSpy.mockClear(); + }); + + test('silent when SET_VALUE carries __viaListener', () => { + const action = { + type: SCHEMA_STATE_ACTIONS.SET_VALUE, + path: ['name'], + value: 'x', + __viaListener: true, + }; + sessDataReducer({ __changeId: 0, name: '' }, action); + expect(errSpy).not.toHaveBeenCalled(); + }); + + test('silent for INIT (not a path-bearing action)', () => { + sessDataReducer( + { __changeId: 0 }, + { type: SCHEMA_STATE_ACTIONS.INIT, payload: { __changeId: 1 } }, + ); + expect(errSpy).not.toHaveBeenCalled(); + }); + + test('silent for CLEAR_DEFERRED_QUEUE (internal plumbing)', () => { + sessDataReducer( + { __changeId: 0, __deferred__: [] }, + { type: SCHEMA_STATE_ACTIONS.CLEAR_DEFERRED_QUEUE }, + ); + expect(errSpy).not.toHaveBeenCalled(); + }); + + test('fires for ADD_ROW bypass', () => { + sessDataReducer( + { __changeId: 0, rows: [] }, + { type: SCHEMA_STATE_ACTIONS.ADD_ROW, path: ['rows'], value: {} }, + ); + expect(errSpy).toHaveBeenCalledWith( + expect.stringMatching(/dispatcher bypass/), + expect.objectContaining({ type: 'add_row' }), + ); + errSpy.mockClear(); + }); + + test('fires for DELETE_ROW bypass', () => { + sessDataReducer( + { __changeId: 0, rows: [{}] }, + { type: SCHEMA_STATE_ACTIONS.DELETE_ROW, path: ['rows'], value: 0 }, + ); + expect(errSpy).toHaveBeenCalledWith( + expect.stringMatching(/dispatcher bypass/), + expect.objectContaining({ type: 'delete_row' }), + ); + errSpy.mockClear(); + }); +}); diff --git a/web/regression/javascript/SchemaView/drain_useeffect_race.spec.jsx b/web/regression/javascript/SchemaView/drain_useeffect_race.spec.jsx new file mode 100644 index 00000000000..117ffc440c1 --- /dev/null +++ b/web/regression/javascript/SchemaView/drain_useeffect_race.spec.jsx @@ -0,0 +1,108 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2026, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +// Deterministic test for the drain useEffect's dep-array race: +// when two SET_VALUE dispatches land in the same React batch, the +// reducer's `data.__deferred__` array may end the batch at the same +// length it started, even though new items were appended after the +// drainer's CLEAR ran. A length-based dep array compares equal +// across renders and the second drain never fires. +// +// The fix uses the array REFERENCE as the dep. The reducer creates a +// new __deferred__ array on every dispatch (via _.cloneDeep at entry + +// `.concat(...)` in the APPEND), so ref-equality changes whenever the +// queue does and the effect re-runs. + +import React, { useReducer, useEffect } from 'react'; +import { render, act } from '@testing-library/react'; +import { + SCHEMA_STATE_ACTIONS, + sessDataReducer, +} from '../../../pgadmin/static/js/SchemaView/SchemaState'; + +// Mirror of the drain useEffect from useSchemaState.js with an +// injectable spy so the test can count invocations per render commit. +// The spy receives `dispatch` so the test can simulate a synchronous +// follow-up SET_VALUE (the racey case where the effect's CLEAR and a +// new SET_VALUE batch into the same commit, round-tripping length). +const useDrain = (sessData, drainSpy, dispatch) => { + useEffect(() => { + const items = sessData.__deferred__ || []; + if (items.length === 0) return; + // Mirror production order: dispatch CLEAR first, THEN run the + // drainer (which may synchronously dispatch a follow-up SET_VALUE + // — that's how the racey case lands CLEAR+SET_VALUE in one + // batch with the length round-tripping through 0). + dispatch({ type: SCHEMA_STATE_ACTIONS.CLEAR_DEFERRED_QUEUE }); + drainSpy(items, dispatch); + }, [sessData.__deferred__]); +}; + +const Harness = React.forwardRef(({ drainSpy }, ref) => { + const [sessData, dispatch] = useReducer(sessDataReducer, { + name: '', other: '', __changeId: 0, + }); + useDrain(sessData, drainSpy, dispatch); + React.useImperativeHandle(ref, () => ({ dispatch, sessData }), [sessData]); + return null; +}); +Harness.displayName = 'Harness'; + +const makeAction = (path, value, tag) => ({ + type: SCHEMA_STATE_ACTIONS.SET_VALUE, + path, value, + // The reducer's dispatcher-bypass guard fires console.error on + // path-bearing actions that lack this sentinel. Production sets it + // via sessDispatchWithListener; reducer-level unit tests must too. + __viaListener: true, + // The reducer reads `action.deferredDepChange` to populate the queue; + // we stub it to return a single tagged item per dispatch. + deferredDepChange: () => [{ + action: { path, depChange: () => {} }, + listener: { source: path, dest: path }, + promise: Promise.resolve(() => ({})), + tag, + }], +}); + +describe('drain useEffect — batched-dispatch race', () => { + test('drain-time CLEAR + synchronous SET_VALUE batched together: both items reach the drain spy', async () => { + // Engineers the exact race the ref-based dep array fixes: + // render N: __deferred__ = [first] (length 1) + // effect fires, drainSpy called with [first] + // drainSpy synchronously dispatches a follow-up SET_VALUE + // => SET_VALUE appends 'second' + // effect then dispatches CLEAR + // render N+1 commits both: CLEAR (→ []) + SET_VALUE (→ [second]) + // final __deferred__ = [second] (length 1) + // PREVIOUS length was 1, CURRENT length is 1 — length-dep sees + // no change, effect skips, 'second' never drains. + const drainSpy = jest.fn((items, dispatch) => { + // Only inject the follow-up on the first call so the test + // terminates (otherwise we'd recurse). + if (items.some((i) => i.tag === 'first')) { + dispatch(makeAction(['other'], 'b', 'second')); + } + }); + const ref = React.createRef(); + render(); + + await act(async () => { + ref.current.dispatch(makeAction(['name'], 'a', 'first')); + }); + // Let any cascaded effects flush. + await act(async () => { await Promise.resolve(); }); + + const seenTags = drainSpy.mock.calls + .flatMap((call) => call[0]) + .map((item) => item.tag); + expect(seenTags).toContain('first'); + expect(seenTags).toContain('second'); + }); +}); diff --git a/web/regression/javascript/SchemaView/feature_register.spec.js b/web/regression/javascript/SchemaView/feature_register.spec.js new file mode 100644 index 00000000000..9ce54c808b2 --- /dev/null +++ b/web/regression/javascript/SchemaView/feature_register.spec.js @@ -0,0 +1,48 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2026, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +// Tests for the feature-priority-ordering contract on +// DataGridView/features/feature.js's `register()`. Features are added to +// the module-scoped sorted list via a comparator that checks +// `priority`. A typo on either side of that comparator silently breaks +// the order (the comparator returns `undefined < N` which is always +// false) and features end up in import-registration order rather than +// in declared-priority order. + +import Feature, { + register, + FeatureSet, +} from '../../../pgadmin/static/js/SchemaView/DataGridView/features/feature'; + +class HighPriorityFeature extends Feature { + static priority = 999; +} +class LowPriorityFeature extends Feature { + static priority = 1; +} + +describe('DataGridView features — register() priority ordering', () => { + test('register sorts classes by static priority (low priority first)', () => { + // Register HIGH first then LOW. If the comparator works correctly, + // LOW (priority=1) must end up at a lower index than HIGH + // (priority=999) regardless of registration order. + register(HighPriorityFeature); + register(LowPriorityFeature); + + const fs = new FeatureSet(); + const lowIdx = fs.features + .findIndex((f) => f.constructor === LowPriorityFeature); + const highIdx = fs.features + .findIndex((f) => f.constructor === HighPriorityFeature); + + expect(lowIdx).toBeGreaterThanOrEqual(0); + expect(highIdx).toBeGreaterThanOrEqual(0); + expect(lowIdx).toBeLessThan(highIdx); + }); +}); diff --git a/web/regression/javascript/SchemaView/incremental_canary.spec.js b/web/regression/javascript/SchemaView/incremental_canary.spec.js new file mode 100644 index 00000000000..b212e95afc1 --- /dev/null +++ b/web/regression/javascript/SchemaView/incremental_canary.spec.js @@ -0,0 +1,319 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2026, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +// Tests for the incremental-walker divergence canary. The canary runs +// schemaOptionsEvalulator twice — once with `changedPath` set +// (incremental) and once with `changedPath = null` (full walk) — sharing +// the same `prevOptions` baseline. Both calls produce independent new +// option trees; the canary diffs them. +// +// A divergence means the incremental walker skipped a row whose +// options would have changed under the full walk — i.e., the host +// schema has an undeclared cross-row closure read that mutated state +// the walker can't track. +// +// V1 (RED-then-GREEN): undeclared cross-row read → canary reports +// divergence. +// V2 (GREEN throughout): same shape but with deps declared → no +// divergence. +// V3 (GREEN throughout): empty changedPath → identity, no work. + +import BaseUISchema from '../../../pgadmin/static/js/SchemaView/base_schema.ui'; +import { + runOptionsCanary, _resetCanaryFireCount, +} from '../../../pgadmin/static/js/SchemaView/options/canary'; +import { + FIELD_OPTIONS, schemaOptionsEvalulator, +} from '../../../pgadmin/static/js/SchemaView/options'; + +beforeEach(() => { _resetCanaryFireCount(); }); + +// The synthetic "bad" pattern: an evaluator closure reads SIBLING-ROW +// state via a captured `sharedData` reference, simulating +// `this.top.sessData.rows[N].X` access. The walker has no way to +// declare this as a dep (it's not field-typed), so the incremental +// walker skips sibling rows whose path doesn't overlap the changed +// path — but the full walk re-evaluates them correctly and gets a +// different answer. +const makeUndeclaredSchema = (sharedDataRef) => + new class OuterSchema extends BaseUISchema { + get baseFields() { + const Inner = class extends BaseUISchema { + get baseFields() { + return [ + { id: 'name', label: 'name', type: 'text' }, + { id: 'is_pk', label: 'is_pk', type: 'switch' }, + { + id: 'note', label: 'note', type: 'text', + // The evaluator reads sibling-row state from a captured + // ref. Real schemas do this via `this.top.sessData.rows` + // or via `obj.something` closures from the constructor. + disabled: () => (sharedDataRef.rows || []) + .some((r) => r.is_pk === true), + }, + ]; + } + }; + return [ + { id: 'title', label: 'title', type: 'text' }, + { + id: 'rows', label: 'rows', type: 'collection', + schema: new Inner(), + canAdd: true, canEdit: true, canDelete: true, + mode: ['create', 'edit'], + }, + ]; + } + }; + +const buildData = () => ({ + title: 't', + rows: [ + { name: 'col_a', is_pk: false }, + { name: 'col_b', is_pk: false }, + { name: 'col_c', is_pk: false }, + ], +}); + +describe('runOptionsCanary — V1: undeclared cross-row read detected', () => { + test('synthetic bad schema reports divergence when sibling pk flips', () => { + // Shared data ref captured by evaluator closures (mimics + // `this.top.sessData` access in real schemas). + const sharedData = buildData(); + const schema = makeUndeclaredSchema(sharedData); + + // Initial full walk to establish prev (everything is_pk=false; all + // notes disabled=false). + const prev = runOptionsCanary({ + schema, data: sharedData, + prevOptions: null, + viewHelperProps: { mode: 'edit' }, + changedPath: null, + depDests: null, + onDivergence: () => {/* swallow first walk */}, + }); + + // Flip rows[1].is_pk → true. Mutating sharedData simulates the + // user typing into that field; the SchemaState dispatch would set + // changedPath = ['rows', 1, 'is_pk']. The walker only visits + // rows[1] under incremental mode; the closure on rows[0] and + // rows[2] would, if re-evaluated, now return disabled=true — but + // those rows are pruned, so their options keep the prev (false) + // references. + sharedData.rows[1].is_pk = true; + + const reports = []; + runOptionsCanary({ + schema, data: sharedData, + prevOptions: prev, + viewHelperProps: { mode: 'edit' }, + changedPath: ['rows', 1, 'is_pk'], + depDests: null, + onDivergence: (r) => reports.push(r), + }); + + expect(reports.length).toBeGreaterThan(0); + const paths = reports.flatMap((r) => r.diffs.map((d) => d.path.join('.'))); + // The divergence should be on a sibling row's `note` field — + // rows[0] or rows[2]. + expect(paths.some((p) => p.match(/^rows\.[02]\.note$/))).toBe(true); + }); +}); + +describe('runOptionsCanary — V2: depDests in mustVisit prevents divergence', () => { + test('when sibling rows are declared as depDests, no divergence', () => { + const sharedData = buildData(); + const schema = makeUndeclaredSchema(sharedData); + + const prev = runOptionsCanary({ + schema, data: sharedData, + prevOptions: null, + viewHelperProps: { mode: 'edit' }, + changedPath: null, + depDests: null, + onDivergence: () => {}, + }); + + sharedData.rows[1].is_pk = true; + + const reports = []; + runOptionsCanary({ + schema, data: sharedData, + prevOptions: prev, + viewHelperProps: { mode: 'edit' }, + changedPath: ['rows', 1, 'is_pk'], + // Simulate what SchemaState._collectDepDestsForPath would + // produce if the schema declared deps on the sibling rows. + // Including the sibling rows in mustVisit forces re-evaluation; + // both walks agree. + depDests: [['rows', 0, 'note'], ['rows', 2, 'note']], + onDivergence: (r) => reports.push(r), + }); + + expect(reports).toEqual([]); + }); +}); + +describe('runOptionsCanary — V3: empty changedPath is identity', () => { + test('no changedPath → no incremental work, no divergence reported', () => { + const sharedData = buildData(); + const schema = makeUndeclaredSchema(sharedData); + + const reports = []; + runOptionsCanary({ + schema, data: sharedData, + prevOptions: null, + viewHelperProps: { mode: 'edit' }, + changedPath: null, + depDests: null, + onDivergence: (r) => reports.push(r), + }); + + expect(reports).toEqual([]); + }); +}); + +// H1: verify the registry wrapper routes to the canary when both +// the build-time flag (set by setup-jest.js for tests) and the runtime +// flag (window.__INCREMENTAL_AUDIT__) are on. In production builds +// without CANARY_BUILD=true, the conditional is dead-code-eliminated. +describe('schemaOptionsEvalulator wrapper — H1 production wiring', () => { + test('audit-mode flag routes the wrapper through runOptionsCanary', () => { + const sharedData = buildData(); + const schema = makeUndeclaredSchema(sharedData); + + // First establish a baseline (no audit, no changedPath). + const prev = schemaOptionsEvalulator({ + schema, data: sharedData, viewHelperProps: { mode: 'edit' }, + prevOptions: null, + }); + + sharedData.rows[1].is_pk = true; + window.__INCREMENTAL_AUDIT__ = true; + window.__throw_on_canary_divergence__ = false; + + try { + // setup-jest.js already spies console.error globally. Set an + // implementation to suppress output, then check + clear. DO NOT + // mockRestore — that would unwrap setup-jest's spy and break + // its afterEach assertion. + console.error.mockImplementation(() => {}); + schemaOptionsEvalulator({ + schema, data: sharedData, viewHelperProps: { mode: 'edit' }, + prevOptions: prev, + changedPath: ['rows', 1, 'is_pk'], + }); + expect(console.error).toHaveBeenCalled(); + const msg = console.error.mock.calls[0][0]; + expect(msg).toMatch(/Incremental walker divergence/); + // Clear the call history so setup-jest's afterEach passes. + console.error.mockClear(); + } finally { + window.__INCREMENTAL_AUDIT__ = false; + } + }); + + test('without audit flag, wrapper bypasses the canary (no extra walk)', () => { + // When __INCREMENTAL_AUDIT__ is unset, the wrapper goes straight + // to measure() + _impl. The canary's onDivergence is never invoked. + const sharedData = buildData(); + const schema = makeUndeclaredSchema(sharedData); + + delete window.__INCREMENTAL_AUDIT__; + sharedData.rows[1].is_pk = true; + + // Running with incrementalOptions=true at the field level is the + // existing prototype opt-in. The wrapper still doesn't fire the + // canary — incremental mode runs, but no divergence is reported. + schemaOptionsEvalulator({ + schema, data: sharedData, + viewHelperProps: { mode: 'edit', incrementalOptions: true }, + prevOptions: null, + changedPath: ['rows', 1, 'is_pk'], + }); + expect(console.error).not.toHaveBeenCalled(); + }); +}); + +describe('runOptionsCanary — throttle (H3)', () => { + test('after MAX_CANARY_FIRES, the canary skips the incremental walk', () => { + window.__incremental_canary_max_per_session__ = 2; + _resetCanaryFireCount(); + + const sharedData = buildData(); + const schema = makeUndeclaredSchema(sharedData); + const prev = runOptionsCanary({ + schema, data: sharedData, viewHelperProps: { mode: 'edit' }, + prevOptions: null, changedPath: null, onDivergence: () => {}, + }); + sharedData.rows[1].is_pk = true; + + const reports = []; + // First 2 calls with onDivergence bypass the throttle entirely. + runOptionsCanary({ + schema, data: sharedData, viewHelperProps: { mode: 'edit' }, + prevOptions: prev, changedPath: ['rows', 1, 'is_pk'], + onDivergence: (r) => reports.push(r), + }); + expect(reports).toHaveLength(1); + + // Now hit the throttle limit via defaultReport path (no onDivergence). + delete window.__incremental_canary_endpoint__; // avoid sendBeacon + window.__throw_on_canary_divergence__ = false; + console.error.mockImplementation(() => {}); + try { + // Fire enough to exceed the cap of 2. The first 2 fire the + // throttle counter (1 → 2); the throttle takes effect on the 3rd. + for (let i = 0; i < 5; i++) { + runOptionsCanary({ + schema, data: sharedData, viewHelperProps: { mode: 'edit' }, + prevOptions: prev, changedPath: ['rows', 1, 'is_pk'], + }); + } + // Only the first 2 (cap=2) should have triggered reports. + expect(console.error.mock.calls.length).toBeLessThanOrEqual(2); + console.error.mockClear(); + } finally { + delete window.__incremental_canary_max_per_session__; + } + }); +}); + +describe('runOptionsCanary — returns the full-walk result authoritative', () => { + test('caller receives the full-walk options regardless of divergence', () => { + const sharedData = buildData(); + const schema = makeUndeclaredSchema(sharedData); + + const prev = runOptionsCanary({ + schema, data: sharedData, + prevOptions: null, + viewHelperProps: { mode: 'edit' }, + changedPath: null, + depDests: null, + onDivergence: () => {}, + }); + + sharedData.rows[1].is_pk = true; + + const result = runOptionsCanary({ + schema, data: sharedData, + prevOptions: prev, + viewHelperProps: { mode: 'edit' }, + changedPath: ['rows', 1, 'is_pk'], + depDests: null, + onDivergence: () => {/* swallow */}, + }); + + // Full walk's result: every row's `note.disabled` reflects + // sharedData.rows[1].is_pk = true. + expect(result.rows[0].note[FIELD_OPTIONS].disabled).toBe(true); + expect(result.rows[1].note[FIELD_OPTIONS].disabled).toBe(true); + expect(result.rows[2].note[FIELD_OPTIONS].disabled).toBe(true); + }); +}); diff --git a/web/regression/javascript/SchemaView/incremental_canary_integration.spec.js b/web/regression/javascript/SchemaView/incremental_canary_integration.spec.js new file mode 100644 index 00000000000..54ae5898d30 --- /dev/null +++ b/web/regression/javascript/SchemaView/incremental_canary_integration.spec.js @@ -0,0 +1,187 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2026, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +// End-to-end integration tests for the divergence canary. +// +// The plain unit tests (incremental_canary.spec.js) call runOptionsCanary +// directly. These tests exercise the FULL production code path — +// SchemaState.validate → updateOptions → schemaOptionsEvalulator +// (wrapper) → runOptionsCanary — by setting the audit flag and observing +// the canary fire via console.error. +// +// V5 (real production code path): the canary catches a synthetic +// cross-row read pattern when the schema is exercised via SchemaState. +// +// M4 (DepListener integration): when a schema's deps are properly +// declared via DepListener.addDepListener, SchemaState's +// _collectDepDestsForPath produces dest paths that pull the +// sibling rows into mustVisit, and the canary stays clean. + +import BaseUISchema from '../../../pgadmin/static/js/SchemaView/base_schema.ui'; +import { SchemaState } from '../../../pgadmin/static/js/SchemaView/SchemaState'; +import { _resetCanaryFireCount } from '../../../pgadmin/static/js/SchemaView/options/canary'; + +// Inner row schema — `note.disabled` reads sibling-row state through a +// closure on a shared data reference. Mimics real `this.top.sessData` +// access patterns in production schemas. +const makeInnerSchema = (sharedDataRef) => { + class Inner extends BaseUISchema { + get baseFields() { + return [ + { id: 'name', label: 'name', type: 'text' }, + { id: 'is_pk', label: 'is_pk', type: 'switch' }, + { + id: 'note', label: 'note', type: 'text', + // Cross-row read via captured closure. The walker has no way + // to know about this dep unless it's declared (M4). + disabled: () => (sharedDataRef.rows || []) + .some((r) => r.is_pk === true), + }, + ]; + } + } + return new Inner(); +}; + +const makeOuterSchema = (innerInstance, optedIn = true) => { + class Outer extends BaseUISchema { + constructor() { + super(); + if (optedIn) this.incrementalOptions = true; + } + get baseFields() { + return [ + { id: 'title', label: 'title', type: 'text' }, + { + id: 'rows', label: 'rows', type: 'collection', + schema: innerInstance, + canAdd: true, canEdit: true, canDelete: true, + mode: ['create', 'edit'], + }, + ]; + } + } + return new Outer(); +}; + +const buildData = () => ({ + title: 't', + rows: [ + { name: 'col_a', is_pk: false, note: '' }, + { name: 'col_b', is_pk: false, note: '' }, + { name: 'col_c', is_pk: false, note: '' }, + ], +}); + +const buildState = (schema, sharedData) => { + const state = new SchemaState( + schema, + () => Promise.resolve(sharedData), + {}, + () => {}, + { mode: 'edit' }, + ); + state.setReady(true); + state.data = sharedData; + state.initData = sharedData; + // Build initial options via a full walk so subsequent dispatches + // have a baseline. + state.updateOptions(null); + return state; +}; + +beforeEach(() => { _resetCanaryFireCount(); }); + +describe('V5 — canary fires through the real SchemaState code path', () => { + test('SchemaState.validate triggers the canary when audit flag is set', () => { + const sharedData = buildData(); + const inner = makeInnerSchema(sharedData); + const outer = makeOuterSchema(inner, /* optedIn = */ true); + const state = buildState(outer, sharedData); + + // Now mutate to flip rows[1].is_pk → true. The sibling closures on + // rows[0] and rows[2] would re-evaluate to disabled=true under the + // full walk, but the incremental walker prunes those rows. + sharedData.rows[1].is_pk = true; + state.__lastChangedPath = ['rows', 1, 'is_pk']; + + window.__INCREMENTAL_AUDIT__ = true; + window.__throw_on_canary_divergence__ = false; + console.error.mockImplementation(() => {}); + try { + state.validate({ ...sharedData, __changeId: 1 }); + expect(console.error).toHaveBeenCalled(); + const msg = console.error.mock.calls[0][0]; + expect(msg).toMatch(/Incremental walker divergence/); + // The reported diff paths should point at a sibling row's `note`. + expect(msg).toMatch(/rows\.[02]\.note/); + console.error.mockClear(); + } finally { + window.__INCREMENTAL_AUDIT__ = false; + } + }); + + test('without the audit flag, SchemaState.validate runs no canary, no console.error', () => { + const sharedData = buildData(); + const inner = makeInnerSchema(sharedData); + const outer = makeOuterSchema(inner, /* optedIn = */ true); + const state = buildState(outer, sharedData); + + sharedData.rows[1].is_pk = true; + state.__lastChangedPath = ['rows', 1, 'is_pk']; + + // No __INCREMENTAL_AUDIT__ → wrapper bypasses canary. The + // incremental walk runs (schema opted in) but no divergence is + // reported. + state.validate({ ...sharedData, __changeId: 1 }); + expect(console.error).not.toHaveBeenCalled(); + }); +}); + +describe('M4 — declared deps via DepListener cover the cross-row read', () => { + test('listeners registered for sibling-row paths produce depDests that prevent divergence', () => { + const sharedData = buildData(); + const inner = makeInnerSchema(sharedData); + const outer = makeOuterSchema(inner, /* optedIn = */ true); + const state = buildState(outer, sharedData); + + // Register listeners that mirror what the schema framework would + // produce if `note` declared `deps: [['rows', '*', 'is_pk']]`. Each + // row's `note` field is the dest; the source is the is_pk path on + // any row. + // + // DepListener's path-overlap check is prefix-based. A listener + // with source = ['rows'] matches any changedPath starting with + // 'rows'. We register one listener per row's `note` field so + // _collectDepDestsForPath returns all three dests. + sharedData.rows.forEach((_row, idx) => { + state.addDepListener( + ['rows'], // source: any change under rows + ['rows', idx, 'note'], // dest: this row's note field + () => ({}), // sync depChange (no-op delta) + ); + }); + + sharedData.rows[1].is_pk = true; + state.__lastChangedPath = ['rows', 1, 'is_pk']; + + window.__INCREMENTAL_AUDIT__ = true; + window.__throw_on_canary_divergence__ = false; + console.error.mockImplementation(() => {}); + try { + state.validate({ ...sharedData, __changeId: 1 }); + // No divergence — the dep dests pull every row's `note` into + // mustVisit, so the incremental walker visits the siblings and + // re-evaluates them just like the full walk. + expect(console.error).not.toHaveBeenCalled(); + } finally { + window.__INCREMENTAL_AUDIT__ = false; + } + }); +}); diff --git a/web/regression/javascript/SchemaView/incremental_options.spec.js b/web/regression/javascript/SchemaView/incremental_options.spec.js new file mode 100644 index 00000000000..072e50d7f7e --- /dev/null +++ b/web/regression/javascript/SchemaView/incremental_options.spec.js @@ -0,0 +1,712 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2026, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +// Unit tests for the prototype incremental schemaOptionsEvalulator +// (and its inputs: pathOverlaps + SchemaState._collectDepDestsForPath). + +import BaseUISchema from '../../../pgadmin/static/js/SchemaView/base_schema.ui'; +import { + schemaOptionsEvalulator, pathOverlaps, FIELD_OPTIONS, +} from '../../../pgadmin/static/js/SchemaView/options'; +import { SchemaState } from '../../../pgadmin/static/js/SchemaView/SchemaState'; +import { validateSchema } from '../../../pgadmin/static/js/SchemaView/SchemaState/common'; + +class InnerSchema extends BaseUISchema { + get baseFields() { + return [ + { id: 'name', label: 'name', type: 'text', cell: 'text' }, + { id: 'val', label: 'val', type: 'text', cell: 'text' }, + ]; + } +} + +class OuterSchema extends BaseUISchema { + get baseFields() { + return [ + { id: 'title', label: 'title', type: 'text' }, + { + id: 'rows', label: 'rows', type: 'collection', + schema: new InnerSchema(), + canAdd: true, canEdit: true, canDelete: true, + mode: ['create', 'edit'], + }, + ]; + } +} + +const SAMPLE_DATA = { + title: 'hi', + rows: [ + { name: 'a', val: 'b' }, + { name: 'c', val: 'd' }, + { name: 'e', val: 'f' }, + ], +}; + +// Inspect the resulting options tree and return the indices of rows that +// got visited. A visited row has a `FIELD_OPTIONS` sub-key written by the +// `row` evaluator. Unvisited rows leave that slot absent. `options.rows` +// is a plain object keyed by `"0"`, `"1"`, ... plus a sibling +// `__fieldOptions` for the collection-level options. +const visitedRowIdxs = (options) => { + const rows = options?.rows; + if (!rows || typeof rows !== 'object') return []; + const out = []; + Object.keys(rows).forEach((k) => { + if (k === FIELD_OPTIONS) return; + const idx = Number(k); + if (Number.isInteger(idx) && rows[k]?.[FIELD_OPTIONS]) out.push(idx); + }); + return out.sort((a, b) => a - b); +}; + +// After the walker fix (registry.js: only prune when +// prevColl[idx]!==undefined), a "newly visited" row is one whose +// options entry has a NEW reference vs prev — because pruned rows +// now correctly inherit from prev (and thus have FIELD_OPTIONS via +// the spread). Compare references to detect re-evaluation; this is +// the right semantic for "incremental walked only the changed row." +const newlyVisitedRowIdxs = (next, prev) => { + const nextRows = next?.rows || {}; + const prevRows = prev?.rows || {}; + const out = []; + Object.keys(nextRows).forEach((k) => { + if (k === FIELD_OPTIONS) return; + const idx = Number(k); + if (!Number.isInteger(idx)) return; + if (nextRows[k] !== prevRows[k]) out.push(idx); + }); + return out.sort((a, b) => a - b); +}; + +// Real-world walker invocation: a fresh dialog runs FULL walk first +// (changedPath=null) which populates prevOptions, then subsequent +// dispatches run with a concrete changedPath against the now-populated +// prev. `evalOpts` mirrors this contract so unit tests exercise the +// SECOND-walk behaviour, not a synthetic empty-prev shape that the +// production code never sees. +// +// Why: an earlier version of these tests passed `prevOptions: {}` +// directly. That hid a real walker bug — when `prevColl[idx]` is +// undefined, the incremental prune would skip the row AND leave +// nextColl[idx] undefined (no prior to inherit), producing the +// `columns.0 — incremental=undefined full={...}` divergence seen +// in Edit-mode dialogs against real data. The walker fix in +// registry.js guards the prune on `prevColl[idx] !== undefined`; +// these tests now run a warm-up full walk to populate prev so the +// guard doesn't change the assertion. +const warmUpPrev = (schema, viewHelperProps = {}) => + schemaOptionsEvalulator({ + schema, data: SAMPLE_DATA, prevOptions: {}, + viewHelperProps: { mode: 'create', ...viewHelperProps }, + // changedPath omitted → full walk (no incremental prune) + }); + +const evalOpts = (extra = {}) => { + const { viewHelperProps: vhpExtra, ...rest } = extra; + const schema = new OuterSchema(); + const vhp = { mode: 'create', ...(vhpExtra || {}) }; + const prev = warmUpPrev(schema, vhp); + // The walker is now functional — it returns the new options tree. + const next = schemaOptionsEvalulator({ + schema, data: SAMPLE_DATA, prevOptions: prev, + ...rest, + viewHelperProps: vhp, + }); + // Tag result with prev so tests that want reference-based + // "visited this walk" semantics can pass to newlyVisitedRowIdxs. + Object.defineProperty(next, '__prev', { value: prev, enumerable: false }); + return next; +}; + +describe('pathOverlaps', () => { + test('equal paths overlap', () => { + expect(pathOverlaps(['a','b'], ['a','b'])).toBe(true); + }); + test('shorter is prefix of longer -> overlap', () => { + expect(pathOverlaps(['a'], ['a','b','c'])).toBe(true); + expect(pathOverlaps(['a','b','c'], ['a'])).toBe(true); + }); + test('disjoint paths do not overlap', () => { + expect(pathOverlaps(['a','b'], ['c'])).toBe(false); + expect(pathOverlaps(['a',1], ['a',2])).toBe(false); + }); + test('numeric vs string indices still match', () => { + expect(pathOverlaps(['a', 0], ['a', '0'])).toBe(true); + }); + test('empty path overlaps everything', () => { + expect(pathOverlaps([], ['a','b'])).toBe(true); + expect(pathOverlaps(['a'], [])).toBe(true); + }); +}); + +describe('schemaOptionsEvalulator — full walk fallbacks', () => { + test('without changedPath, every row is visited (incremental needs a path)', () => { + const opts = evalOpts(); + expect(visitedRowIdxs(opts)).toEqual([0, 1, 2]); + }); + + test('explicit incrementalOptions=false on viewHelperProps opts out, still full walk', () => { + const opts = evalOpts({ + viewHelperProps: { incrementalOptions: false }, + changedPath: ['rows', 1, 'name'], + }); + expect(visitedRowIdxs(opts)).toEqual([0, 1, 2]); + }); +}); + +describe('schemaOptionsEvalulator — incremental (viewHelperProps opt-in)', () => { + // Assertions use newlyVisitedRowIdxs(opts, opts.__prev) — a row is + // "newly visited" when its options entry has a different reference + // than prev. This is the right semantic after the walker fix in + // registry.js: pruned rows correctly inherit prev's reference (so + // structural sharing holds), visited rows get a fresh reference. + + test('changedPath inside a row visits only that row', () => { + const opts = evalOpts({ + viewHelperProps: { incrementalOptions: true }, + changedPath: ['rows', 1, 'name'], + }); + expect(newlyVisitedRowIdxs(opts, opts.__prev)).toEqual([1]); + }); + + test('changedPath at the collection path visits all rows (structural)', () => { + const opts = evalOpts({ + viewHelperProps: { incrementalOptions: true }, + changedPath: ['rows'], + }); + expect(newlyVisitedRowIdxs(opts, opts.__prev)).toEqual([0, 1, 2]); + }); + + test('changedPath outside the collection visits no rows', () => { + const opts = evalOpts({ + viewHelperProps: { incrementalOptions: true }, + changedPath: ['title'], + }); + expect(newlyVisitedRowIdxs(opts, opts.__prev)).toEqual([]); + }); + + test('depDests force visits of rows they target even when changedPath is unrelated', () => { + const opts = evalOpts({ + viewHelperProps: { incrementalOptions: true }, + changedPath: ['title'], + depDests: [['rows', 2, 'val']], + }); + expect(newlyVisitedRowIdxs(opts, opts.__prev)).toEqual([2]); + }); + + test('union of changedPath + depDests', () => { + const opts = evalOpts({ + viewHelperProps: { incrementalOptions: true }, + changedPath: ['rows', 0, 'name'], + depDests: [['rows', 2, 'val']], + }); + expect(newlyVisitedRowIdxs(opts, opts.__prev)).toEqual([0, 2]); + }); + + test('null changedPath always falls back to full walk', () => { + const opts = evalOpts({ + viewHelperProps: { incrementalOptions: true }, + changedPath: null, + }); + expect(newlyVisitedRowIdxs(opts, opts.__prev)).toEqual([0, 1, 2]); + }); +}); + +describe('schemaOptionsEvalulator — window global escape hatch', () => { + // The window flag is the emergency-rollback toggle now that + // incremental is the default. Setting it to FALSE disables + // incremental everywhere. Unset/undefined leaves the default + // (incremental on) in effect. + afterEach(() => { delete window.__INCREMENTAL_OPTIONS__; }); + + test('window.__INCREMENTAL_OPTIONS__ unset → default-on still applies', () => { + delete window.__INCREMENTAL_OPTIONS__; + const opts = evalOpts({ changedPath: ['rows', 1, 'name'] }); + expect(newlyVisitedRowIdxs(opts, opts.__prev)).toEqual([1]); + }); + + test('window.__INCREMENTAL_OPTIONS__ = false disables incremental globally', () => { + window.__INCREMENTAL_OPTIONS__ = false; + const opts = evalOpts({ changedPath: ['rows', 1, 'name'] }); + expect(visitedRowIdxs(opts)).toEqual([0, 1, 2]); + }); +}); + +describe('schema.incrementalOptions opt-in via SchemaState.updateOptions', () => { + // Build a SchemaState ready to validate. We pre-seed __lastChangedPath + // and call validate, then inspect the resulting option store. + // + // The state is "warmed up" by a full-walk validate before the per-test + // incremental dispatch: this mirrors production where the initial + // mount populates optionStore via a no-changedPath walk BEFORE the + // first dispatch with a concrete changedPath. Without the warm-up, + // the test's first dispatch runs against an empty optionStore — and + // the walker's prune-guard (registry.js: prevColl[idx]!==undefined) + // correctly visits all rows because none have a prior result to + // inherit. That's the right runtime behaviour but the wrong shape + // for asserting "incremental visits only the changed row." + const buildState = ({ optedIn, vhpFlag } = {}) => { + class OptedInOuter extends OuterSchema { + constructor() { super(); this.incrementalOptions = true; } + } + const SchemaClass = optedIn ? OptedInOuter : OuterSchema; + const state = new SchemaState( + new SchemaClass(), + () => Promise.resolve(SAMPLE_DATA), + {}, + () => {}, + { mode: 'create', ...(vhpFlag ? { incrementalOptions: true } : {}) }, + ); + state.setReady(true); + state.data = SAMPLE_DATA; + state.initData = SAMPLE_DATA; + // Warm-up: full walk to populate optionStore so subsequent + // dispatches have a real prev to prune against. Captures the + // populated tree as `prev` on the state for the per-test helper. + state.__lastChangedPath = null; + state.validate({ ...SAMPLE_DATA, __changeId: 0 }); + state.__warmedPrev = state.optionStore.getState(); + return state; + }; + + test('schema.incrementalOptions=true enables incremental walk without viewHelperProps flag', () => { + const state = buildState({ optedIn: true }); + state.__lastChangedPath = ['rows', 1, 'name']; + state.validate({ ...SAMPLE_DATA, __changeId: 1 }); + expect(newlyVisitedRowIdxs( + state.optionStore.getState(), state.__warmedPrev + )).toEqual([1]); + }); + + test('schema.incrementalOptions=false opts out (full walk despite default-on)', () => { + class OptedOutOuter extends OuterSchema { + constructor() { super(); this.incrementalOptions = false; } + } + const state = new SchemaState( + new OptedOutOuter(), + () => Promise.resolve(SAMPLE_DATA), + {}, + () => {}, + { mode: 'create' }, + ); + state.setReady(true); + state.data = SAMPLE_DATA; + state.initData = SAMPLE_DATA; + // Warm-up + capture prev so the assertion compares against a + // populated baseline (matching the opted-in tests above). + state.__lastChangedPath = null; + state.validate({ ...SAMPLE_DATA, __changeId: 0 }); + const warmedPrev = state.optionStore.getState(); + state.__lastChangedPath = ['rows', 1, 'name']; + state.validate({ ...SAMPLE_DATA, __changeId: 1 }); + // Opt-out: walker is full → ALL rows get fresh references on each + // dispatch (no structural sharing). + expect(newlyVisitedRowIdxs( + state.optionStore.getState(), warmedPrev + )).toEqual([0, 1, 2]); + }); + + test('schema without any incrementalOptions setting uses default-on', () => { + const state = buildState({ optedIn: false }); + state.__lastChangedPath = ['rows', 1, 'name']; + state.validate({ ...SAMPLE_DATA, __changeId: 1 }); + // Default-on: incremental triggers when changedPath is present and + // no opt-out is set. Only the changed row is re-evaluated. + expect(newlyVisitedRowIdxs( + state.optionStore.getState(), state.__warmedPrev + )).toEqual([1]); + }); + + test('viewHelperProps.incrementalOptions still works when schema does not opt in', () => { + const state = buildState({ optedIn: false, vhpFlag: true }); + state.__lastChangedPath = ['rows', 1, 'name']; + state.validate({ ...SAMPLE_DATA, __changeId: 1 }); + expect(newlyVisitedRowIdxs( + state.optionStore.getState(), state.__warmedPrev + )).toEqual([1]); + }); + + test('both flags set is idempotent (still incremental)', () => { + const state = buildState({ optedIn: true, vhpFlag: true }); + state.__lastChangedPath = ['rows', 1, 'name']; + state.validate({ ...SAMPLE_DATA, __changeId: 1 }); + expect(newlyVisitedRowIdxs( + state.optionStore.getState(), state.__warmedPrev + )).toEqual([1]); + }); +}); + +describe('SchemaState.validate — incremental validateSchema integration', () => { + // Row schema whose custom validate() records the rows actually walked. + class CountingInner extends BaseUISchema { + get baseFields() { + return [{ id: 'name', type: 'text', cell: 'text' }]; + } + validate(state) { CountingInner.visits.push(state.name); return false; } + } + CountingInner.visits = []; + + class CountingOuter extends BaseUISchema { + constructor() { super(); this.incrementalOptions = true; } + get baseFields() { + return [{ + id: 'rows', type: 'collection', schema: new CountingInner(), + mode: ['create', 'edit'], + }]; + } + } + + const data3 = { rows: [{ name: 'a' }, { name: 'b' }, { name: 'c' }] }; + + const buildReady = () => { + const state = new SchemaState( + new CountingOuter(), + () => Promise.resolve(data3), + {}, + () => {}, + { mode: 'edit' }, + ); + state.setReady(true); + state.data = data3; + state.initData = data3; + return state; + }; + + beforeEach(() => { CountingInner.visits = []; }); + + test('schema.incrementalOptions=true narrows validateSchema to the changed row', () => { + const state = buildReady(); + state.__lastChangedPath = ['rows', 1, 'name']; + state.validate({ ...data3, __changeId: 1 }); + expect(CountingInner.visits).toEqual(['b']); + }); + + test('NEGATIVE — without opt-in, validateSchema walks every row', () => { + class NoOptOuter extends CountingOuter { + constructor() { super(); this.incrementalOptions = false; } + } + const state = new SchemaState( + new NoOptOuter(), + () => Promise.resolve(data3), + {}, + () => {}, + { mode: 'edit' }, + ); + state.setReady(true); + state.data = data3; + state.initData = data3; + state.__lastChangedPath = ['rows', 1, 'name']; + state.validate({ ...data3, __changeId: 1 }); + expect(CountingInner.visits).toEqual(['a', 'b', 'c']); + }); + + test('mustVisit includes current error path so the erroring row is re-validated', () => { + const state = buildReady(); + // Seed a pre-existing error on row 2. + state.setError({ name: ['rows', 2, 'name'], message: 'stale' }); + // User types in row 0 (unrelated). + state.__lastChangedPath = ['rows', 0, 'name']; + state.validate({ ...data3, __changeId: 2 }); + // Row 0 (changedPath) and row 2 (current error path) both walked. + expect(CountingInner.visits.sort()).toEqual(['a', 'c']); + }); +}); + +describe('updateOptions — structural sharing of unvisited subtrees', () => { + // Same schema shape as the OuterSchema fixture but with the schema + // opting into incrementalOptions so a SchemaState built from it does + // incremental walks. + class OptedOuter extends OuterSchema { + constructor() { super(); this.incrementalOptions = true; } + } + + const newReadyState = () => { + const state = new SchemaState( + new OptedOuter(), + () => Promise.resolve(SAMPLE_DATA), + {}, + () => {}, + { mode: 'edit' }, + ); + state.setReady(true); + state.data = SAMPLE_DATA; + state.initData = SAMPLE_DATA; + return state; + }; + + test('unvisited row option subtrees share reference (Object.is) with previous options', () => { + const state = newReadyState(); + // Full initial walk populates the option tree. + state.validate({ ...SAMPLE_DATA, __changeId: 1 }); + const prev = state.optionStore.getState(); + + // Incremental walk targeting row 1. + state.__lastChangedPath = ['rows', 1, 'name']; + state.validate({ ...SAMPLE_DATA, __changeId: 2 }); + const next = state.optionStore.getState(); + + // Rows 0 and 2 weren't touched — their option subtrees must be + // exactly the same object references as in `prev`. + expect(next.rows[0]).toBe(prev.rows[0]); + expect(next.rows[2]).toBe(prev.rows[2]); + }); + + test('visited row subtree is a NEW reference (the walker did re-evaluate it)', () => { + const state = newReadyState(); + state.validate({ ...SAMPLE_DATA, __changeId: 1 }); + const prev = state.optionStore.getState(); + state.__lastChangedPath = ['rows', 1, 'name']; + state.validate({ ...SAMPLE_DATA, __changeId: 2 }); + const next = state.optionStore.getState(); + + expect(next.rows[1]).not.toBe(prev.rows[1]); + }); + + test('full walk (changedPath=undefined) gives fresh references everywhere — sanity', () => { + const state = newReadyState(); + state.validate({ ...SAMPLE_DATA, __changeId: 1 }); + const prev = state.optionStore.getState(); + state.__lastChangedPath = undefined; // forces full walk + state.validate({ ...SAMPLE_DATA, __changeId: 2 }); + const next = state.optionStore.getState(); + + // Full walk re-builds everything; references diverge. + expect(next.rows[0]).not.toBe(prev.rows[0]); + expect(next.rows[2]).not.toBe(prev.rows[2]); + }); +}); + +describe('SchemaState._collectDepDestsForPath', () => { + const newState = () => new SchemaState( + new OuterSchema(), + () => Promise.resolve({}), + {}, + () => {}, + { mode: 'create' }, + ); + + test('null when no listeners are registered', () => { + expect(newState()._collectDepDestsForPath(['x'])).toEqual(null); + }); + + test('null when changedPath is not an array', () => { + const state = newState(); + state.addDepListener(['x'], ['y']); + expect(state._collectDepDestsForPath(null)).toEqual(null); + expect(state._collectDepDestsForPath(undefined)).toEqual(null); + }); + + test('exact source/changedPath match -> dest collected', () => { + const state = newState(); + state.addDepListener(['rows', 0, 'name'], ['rows', 5, 'val']); + expect(state._collectDepDestsForPath(['rows', 0, 'name'])) + .toEqual([['rows', 5, 'val']]); + }); + + test('source is prefix of changedPath -> match', () => { + const state = newState(); + state.addDepListener(['rows'], ['title']); + expect(state._collectDepDestsForPath(['rows', 2, 'name'])) + .toEqual([['title']]); + }); + + test('changedPath is prefix of source -> match (structural change above)', () => { + const state = newState(); + state.addDepListener(['rows', 2, 'name'], ['title']); + expect(state._collectDepDestsForPath(['rows'])) + .toEqual([['title']]); + }); + + test('disjoint source/changedPath -> empty', () => { + const state = newState(); + state.addDepListener(['x'], ['y']); + expect(state._collectDepDestsForPath(['z'])).toEqual([]); + }); + + test('multiple listeners — only matching ones contribute', () => { + const state = newState(); + state.addDepListener(['a'], ['da']); + state.addDepListener(['a', 'b'], ['dab']); + state.addDepListener(['c'], ['dc']); + expect(state._collectDepDestsForPath(['a', 'b'])) + .toEqual([['da'], ['dab']]); + }); +}); + +describe('validateSchema — incremental mustVisit pruning', () => { + // A row schema that records every call to its custom validate() so we + // can count which rows were visited. + class CountingInnerSchema extends BaseUISchema { + get baseFields() { + return [{ id: 'name', type: 'text', cell: 'text' }]; + } + validate(state) { + CountingInnerSchema.visits.push(state.name); + return false; + } + } + CountingInnerSchema.visits = []; + + class CountingOuterSchema extends BaseUISchema { + get baseFields() { + return [{ + id: 'rows', type: 'collection', schema: new CountingInnerSchema(), + mode: ['create', 'edit'], + }]; + } + } + + const DATA = { + rows: [{ name: 'a' }, { name: 'b' }, { name: 'c' }], + }; + + beforeEach(() => { CountingInnerSchema.visits = []; }); + + test('null mustVisit (default) validates every row', () => { + validateSchema(new CountingOuterSchema(), DATA, () => {}, [], null); + expect(CountingInnerSchema.visits).toEqual(['a', 'b', 'c']); + }); + + test('mustVisit pointing into one row validates only that row', () => { + validateSchema( + new CountingOuterSchema(), DATA, () => {}, [], null, + [['rows', 1, 'name']], + ); + expect(CountingInnerSchema.visits).toEqual(['b']); + }); + + test('mustVisit at the collection path validates all rows (structural)', () => { + validateSchema( + new CountingOuterSchema(), DATA, () => {}, [], null, + [['rows']], + ); + expect(CountingInnerSchema.visits).toEqual(['a', 'b', 'c']); + }); + + test('mustVisit outside the collection validates no rows', () => { + validateSchema( + new CountingOuterSchema(), DATA, () => {}, [], null, + [['title']], + ); + expect(CountingInnerSchema.visits).toEqual([]); + }); + + test('mustVisit with multiple paths unions rows', () => { + validateSchema( + new CountingOuterSchema(), DATA, () => {}, [], null, + [['rows', 0, 'name'], ['rows', 2, 'name']], + ); + expect(CountingInnerSchema.visits).toEqual(['a', 'c']); + }); +}); + +describe('validateCollectionSchema — checkUniqueCol pruning', () => { + // Outer collection with uniqueCol = ['name']. Data has a duplicate + // name across rows 0 and 1. + class UCInner extends BaseUISchema { + get baseFields() { + return [ + { id: 'name', type: 'text', cell: 'text' }, + { id: 'value', type: 'text', cell: 'text' }, + ]; + } + } + class UCOuter extends BaseUISchema { + get baseFields() { + return [{ + id: 'rows', type: 'collection', schema: new UCInner(), + uniqueCol: ['name'], mode: ['create', 'edit'], + }]; + } + } + const DATA = { rows: [ + { name: 'dup', value: '1' }, + { name: 'dup', value: '2' }, + { name: 'unique', value: '3' }, + ]}; + + const runValidate = (mustVisit) => { + const errors = []; + validateSchema( + new UCOuter(), DATA, + (path, msg) => errors.push({path, msg}), + [], null, mustVisit, + ); + return errors; + }; + + const hasUniqueError = (errors) => + errors.some(e => /must be unique/.test(e.msg)); + + test('full walk (mustVisit=null) detects the duplicate', () => { + expect(hasUniqueError(runValidate(null))).toBe(true); + }); + + test('mustVisit at the collection path (ADD/DELETE) runs checkUniqueCol', () => { + expect(hasUniqueError(runValidate([['rows']]))).toBe(true); + }); + + test('mustVisit on a uniqueCol field (name) runs checkUniqueCol', () => { + expect(hasUniqueError(runValidate([['rows', 1, 'name']]))).toBe(true); + }); + + test('NEGATIVE — mustVisit on a NON-uniqueCol field skips checkUniqueCol', () => { + expect(hasUniqueError(runValidate([['rows', 1, 'value']]))).toBe(false); + }); + + test('NEGATIVE — a deep path inside a nested-collection row does not trigger outer uniqueCol', () => { + // Outer collection's uniqueCol is ['name']. A change deep inside a + // hypothetical nested collection has a path of length > currPath+2, + // so it must NOT satisfy the outer collection's uniqueness trigger. + expect(hasUniqueError( + runValidate([['rows', 0, 'nested', 0, 'name']]) + )).toBe(false); + }); + + test('mustVisit with no uniqueCol-relevant path skips even when the row was visited', () => { + // Row 1 is in mustVisit (for some other reason — say a depDest), + // but the path is 'value', not 'name'. Row 1's per-field validators + // run (no error); uniqueCol scan should be skipped. + expect(hasUniqueError(runValidate([['rows', 1, 'value']]))).toBe(false); + }); +}); + +describe('SchemaState.validate consumes __lastChangedPath', () => { + // Build a SchemaState whose validate() runs successfully. We pre-seed + // __lastChangedPath, call validate, and assert it was consumed. + const newReadyState = async () => { + const state = new SchemaState( + new OuterSchema(), + () => Promise.resolve(SAMPLE_DATA), + {}, + () => {}, + { mode: 'edit' }, + ); + // Simulate the post-initialise ready state without a React reducer. + state.setReady(true); + state.data = SAMPLE_DATA; + state.initData = SAMPLE_DATA; + return state; + }; + + test('consumes (clears) __lastChangedPath after validate', async () => { + const state = await newReadyState(); + state.__lastChangedPath = ['rows', 1, 'name']; + state.validate({ ...SAMPLE_DATA, __changeId: 1 }); + expect(state.__lastChangedPath).toBeUndefined(); + }); + + test('validate is callable with no __lastChangedPath set', async () => { + const state = await newReadyState(); + expect(() => state.validate({ ...SAMPLE_DATA, __changeId: 1 })) + .not.toThrow(); + expect(state.__lastChangedPath).toBeUndefined(); + }); +}); diff --git a/web/regression/javascript/SchemaView/incremental_validation_canary.spec.js b/web/regression/javascript/SchemaView/incremental_validation_canary.spec.js new file mode 100644 index 00000000000..a96e57c2738 --- /dev/null +++ b/web/regression/javascript/SchemaView/incremental_validation_canary.spec.js @@ -0,0 +1,286 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2026, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +// Tests for the incremental-validator divergence canary. Mirrors the +// options canary pattern: runs validateSchema twice — once with the +// production `mustVisit` array (incremental) and once with +// `mustVisit=null` (full walk) — and diffs the error maps produced by +// each. Any path with a different error message (or present in one walk +// but not the other) is a divergence. +// +// A divergence means the incremental walker skipped a row whose +// validator would have set (or cleared) an error under the full walk — +// i.e., the host schema has a cross-row read in `validate()` that +// wasn't declared via `field.deps`. +// +// V1 (RED-then-GREEN): undeclared cross-row read in validator → canary +// reports divergence. +// V2 (GREEN throughout): same schema with cross-row dest paths in +// mustVisit → no divergence. +// V3 (GREEN throughout): mustVisit=null → identity, no work. +// H1/H2: validateSchema wrapper routes through canary iff +// __INCREMENTAL_AUDIT__ is on. +// throttle: after MAX_CANARY_FIRES, the canary skips the incremental walk. +// authoritative: caller receives the full walk's hadError result. + +import BaseUISchema from '../../../pgadmin/static/js/SchemaView/base_schema.ui'; +import { + runValidationCanary, _resetValidationCanaryFireCount, +} from '../../../pgadmin/static/js/SchemaView/SchemaState/validation_canary'; +import { + validateSchema, +} from '../../../pgadmin/static/js/SchemaView/SchemaState/common'; + +beforeEach(() => { _resetValidationCanaryFireCount(); }); + +// The synthetic "bad" pattern: the inner schema's `validate()` reads +// SIBLING-ROW state via a captured `sharedDataRef` (mimics +// `this.top.sessData.rows[N].X` in real schemas). If ANY row has +// is_pk=true, every row's validator errors. The walker has no way to +// declare this as a dep (it's not field-typed), so the incremental +// walker only visits rows on the changedPath — sibling rows are pruned +// and their validators never fire. +const makeUndeclaredValidationSchema = (sharedDataRef) => + new (class OuterSchema extends BaseUISchema { + get baseFields() { + const Inner = class extends BaseUISchema { + get baseFields() { + return [ + { id: 'name', label: 'name', type: 'text' }, + { id: 'is_pk', label: 'is_pk', type: 'switch' }, + { id: 'note', label: 'note', type: 'text' }, + ]; + } + + // Per-row validate reads sibling state from the closure-captured + // ref. Setting an error returns true (per BaseUISchema contract). + validate(state, setError) { + if ((sharedDataRef.rows || []).some((r) => r.is_pk === true)) { + setError('note', 'sibling pk constraint violated'); + return true; + } + return false; + } + }; + return [ + { id: 'title', label: 'title', type: 'text' }, + { + id: 'rows', label: 'rows', type: 'collection', + schema: new Inner(), + canAdd: true, canEdit: true, canDelete: true, + mode: ['create', 'edit'], + }, + ]; + } + })(); + +const buildData = () => ({ + title: 't', + rows: [ + { name: 'col_a', is_pk: false }, + { name: 'col_b', is_pk: false }, + { name: 'col_c', is_pk: false }, + ], +}); + +describe('runValidationCanary — V1: undeclared cross-row read detected', () => { + test('synthetic bad schema reports divergence when sibling pk flips', () => { + const sharedData = buildData(); + const schema = makeUndeclaredValidationSchema(sharedData); + + // Flip rows[1].is_pk → true. SchemaState dispatch would set + // changedPath = ['rows', 1, 'is_pk'] and mustVisit = [changedPath]. + // The full walk visits rows[0] first, hits the cross-row validator, + // sets an error on rows[0].note. The incremental walk only visits + // rows[1], setting the same error on rows[1].note. Different paths + // → divergence. + sharedData.rows[1].is_pk = true; + + const reports = []; + runValidationCanary({ + schema, sessData: sharedData, + setError: () => {/* swallow */}, + accessPath: [], collLabel: null, + mustVisit: [['rows', 1, 'is_pk']], + onDivergence: (r) => reports.push(r), + }); + + expect(reports.length).toBeGreaterThan(0); + const paths = reports.flatMap((r) => r.diffs.map((d) => d.path.join('.'))); + // Both walks set an error, but on different row paths — full hits + // rows.0.note first, incremental hits rows.1.note. The diff should + // surface at least one of those. + expect(paths.some((p) => p.match(/^rows\.[012]\.note$/))).toBe(true); + }); +}); + +describe('runValidationCanary — V2: mustVisit covering siblings prevents divergence', () => { + test('when sibling rows are in mustVisit, no divergence', () => { + const sharedData = buildData(); + const schema = makeUndeclaredValidationSchema(sharedData); + + sharedData.rows[1].is_pk = true; + + const reports = []; + runValidationCanary({ + schema, sessData: sharedData, + setError: () => {}, + accessPath: [], collLabel: null, + // Simulate what _collectDepDestsForPath would produce if the + // schema declared deps on the sibling rows: every row appears in + // mustVisit, so the incremental walker visits all of them and + // matches the full walk's first-error short-circuit. + mustVisit: [ + ['rows', 0], + ['rows', 1, 'is_pk'], + ['rows', 2], + ], + onDivergence: (r) => reports.push(r), + }); + + expect(reports).toEqual([]); + }); +}); + +describe('runValidationCanary — V3: mustVisit=null is identity', () => { + test('null mustVisit → no incremental work, no divergence reported', () => { + const sharedData = buildData(); + const schema = makeUndeclaredValidationSchema(sharedData); + + sharedData.rows[1].is_pk = true; + + const reports = []; + runValidationCanary({ + schema, sessData: sharedData, + setError: () => {}, + accessPath: [], collLabel: null, + mustVisit: null, + onDivergence: (r) => reports.push(r), + }); + + expect(reports).toEqual([]); + }); +}); + +// H1: the wrapper inside validateSchema must route through the canary +// when both the build-time flag (set by setup-jest.js for tests) and +// the runtime flag (window.__INCREMENTAL_AUDIT__) are on. In production +// builds without CANARY_BUILD=true, the conditional is DCE'd. +describe('validateSchema wrapper — H1 production wiring', () => { + test('audit-mode flag routes the wrapper through the canary', () => { + const sharedData = buildData(); + const schema = makeUndeclaredValidationSchema(sharedData); + + sharedData.rows[1].is_pk = true; + window.__INCREMENTAL_AUDIT__ = true; + window.__throw_on_canary_divergence__ = false; + + try { + // setup-jest.js already spies console.error globally. Set an + // implementation to suppress output, then check + clear. DO NOT + // mockRestore — would unwrap setup-jest's spy. + console.error.mockImplementation(() => {}); + validateSchema( + schema, sharedData, + () => {/* setError */}, + [], null, + [['rows', 1, 'is_pk']], + ); + expect(console.error).toHaveBeenCalled(); + const msg = console.error.mock.calls[0][0]; + expect(msg).toMatch(/Incremental validator divergence/); + console.error.mockClear(); + } finally { + window.__INCREMENTAL_AUDIT__ = false; + } + }); + + test('without audit flag, wrapper bypasses the canary (no extra walk)', () => { + const sharedData = buildData(); + const schema = makeUndeclaredValidationSchema(sharedData); + + delete window.__INCREMENTAL_AUDIT__; + sharedData.rows[1].is_pk = true; + + validateSchema( + schema, sharedData, + () => {/* setError */}, + [], null, + [['rows', 1, 'is_pk']], + ); + expect(console.error).not.toHaveBeenCalled(); + }); +}); + +describe('runValidationCanary — throttle', () => { + test('after MAX_CANARY_FIRES, the canary skips the incremental walk', () => { + window.__incremental_canary_max_per_session__ = 2; + _resetValidationCanaryFireCount(); + + const sharedData = buildData(); + const schema = makeUndeclaredValidationSchema(sharedData); + sharedData.rows[1].is_pk = true; + + const reports = []; + // First call with onDivergence bypasses the throttle entirely. + runValidationCanary({ + schema, sessData: sharedData, setError: () => {}, + accessPath: [], collLabel: null, + mustVisit: [['rows', 1, 'is_pk']], + onDivergence: (r) => reports.push(r), + }); + expect(reports).toHaveLength(1); + + delete window.__incremental_canary_endpoint__; // avoid sendBeacon + window.__throw_on_canary_divergence__ = false; + console.error.mockImplementation(() => {}); + try { + // Fire enough to exceed the cap. First 2 trip the throttle + // counter (1 → 2); the throttle takes effect from the 3rd onward. + for (let i = 0; i < 5; i++) { + runValidationCanary({ + schema, sessData: sharedData, setError: () => {}, + accessPath: [], collLabel: null, + mustVisit: [['rows', 1, 'is_pk']], + }); + } + expect(console.error.mock.calls.length).toBeLessThanOrEqual(2); + console.error.mockClear(); + } finally { + delete window.__incremental_canary_max_per_session__; + } + }); +}); + +describe('runValidationCanary — returns the full-walk hadError', () => { + test('caller receives the full-walk hadError + errors via setError', () => { + const sharedData = buildData(); + const schema = makeUndeclaredValidationSchema(sharedData); + + sharedData.rows[1].is_pk = true; + + const captured = []; + const hadError = runValidationCanary({ + schema, sessData: sharedData, + setError: (path, message) => { captured.push({ path, message }); }, + accessPath: [], collLabel: null, + mustVisit: [['rows', 1, 'is_pk']], + onDivergence: () => {/* swallow */}, + }); + + // Full walk visits all rows in order; rows[0] hits the error first + // (sibling pk read) and short-circuits. The caller's setError gets + // the FULL walk's error on rows.0.note — not the incremental + // walk's rows.1.note. + expect(hadError).toBe(true); + expect(captured).toHaveLength(1); + expect(captured[0].path).toEqual(['rows', 0, 'note']); + expect(captured[0].message).toMatch(/sibling pk/); + }); +}); diff --git a/web/regression/javascript/SchemaView/known_error_paths_cap.spec.js b/web/regression/javascript/SchemaView/known_error_paths_cap.spec.js new file mode 100644 index 00000000000..3e3ed8ae4e8 --- /dev/null +++ b/web/regression/javascript/SchemaView/known_error_paths_cap.spec.js @@ -0,0 +1,109 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2026, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +// Locks the bounded-growth contract on SchemaState._knownErrorPaths. +// Without the cap, long-lived dialogs (ERD, schema diff, sql editor's +// query-tool schema panel) could grow the tracker unbounded across +// sessions; the cap caps memory at O(KNOWN_ERROR_PATHS_CAP) regardless +// of how many distinct error paths the user surfaces. +// +// The cap value is internal; this spec asserts the contract: +// 1. The tracker accepts entries indefinitely without throwing. +// 2. Its size stays bounded under sustained insertion. +// 3. The MOST RECENTLY ADDED entries are retained (LRU eviction +// drops the oldest path, not the newest). + +import BaseUISchema from '../../../pgadmin/static/js/SchemaView/base_schema.ui'; +import { SchemaState } from '../../../pgadmin/static/js/SchemaView/SchemaState'; + +class Trivial extends BaseUISchema { + get baseFields() { return [{ id: 'name', type: 'text' }]; } +} + +const buildState = () => { + const schema = new Trivial(); + schema.top = schema; + return new SchemaState( + schema, () => Promise.resolve({}), {}, () => {}, + { mode: 'create' }, '', + ); +}; + +describe('_knownErrorPaths — bounded LRU', () => { + test('size stays bounded under 10k distinct path insertions', () => { + const state = buildState(); + for (let i = 0; i < 10000; i++) { + state.setError({ name: ['row', i, 'cell'], message: `e${i}` }); + } + // We don't pin the cap value here (it's an implementation detail), + // but it has to be FINITE and well under the 10k insertions. + expect(state._knownErrorPaths.size).toBeLessThan(5000); + expect(state._knownErrorPaths.size).toBeGreaterThan(0); + }); + + test('most-recent insertions are retained, oldest are evicted', () => { + const state = buildState(); + // Fill past the cap. + for (let i = 0; i < 2000; i++) { + state.setError({ name: ['row', i, 'cell'], message: 'e' }); + } + // The last 100 paths must still be in the tracker. + for (let i = 1900; i < 2000; i++) { + const flat = ['row', i, 'cell'].map(String).join('/'); + expect(state._knownErrorPaths.has(flat)).toBe(true); + } + // At least SOME of the earliest paths must have been evicted. + let evictedCount = 0; + for (let i = 0; i < 100; i++) { + const flat = ['row', i, 'cell'].map(String).join('/'); + if (!state._knownErrorPaths.has(flat)) evictedCount++; + } + expect(evictedCount).toBeGreaterThan(0); + }); + + test('eviction emits a one-shot warn + counts every eviction', () => { + const state = buildState(); + // Spy on console.warn to catch the one-shot signal. + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + // Trigger many evictions. + for (let i = 0; i < 3000; i++) { + state.setError({ name: ['row', i, 'cell'], message: 'e' }); + } + + // Exactly one warn fires for the whole session, no matter how + // many evictions follow. + const evictionWarnCalls = warnSpy.mock.calls.filter( + ([msg]) => typeof msg === 'string' && msg.includes('_knownErrorPaths LRU cap'), + ); + expect(evictionWarnCalls.length).toBe(1); + + warnSpy.mockRestore(); + }); + + test('re-adding an existing path refreshes its recency', () => { + const state = buildState(); + // Seed an early entry. + state.setError({ name: ['row', 0, 'cell'], message: 'e' }); + // Flood with enough new entries to risk eviction. + for (let i = 1; i < 2000; i++) { + state.setError({ name: ['row', i, 'cell'], message: 'e' }); + } + // Re-touch the early entry — should refresh recency. + state.setError({ name: ['row', 0, 'cell'], message: 'e2' }); + // Now flood more. + for (let i = 2000; i < 2500; i++) { + state.setError({ name: ['row', i, 'cell'], message: 'e' }); + } + // The refreshed entry must survive even though entries from + // BEFORE the refresh are gone. + const flat = ['row', 0, 'cell'].map(String).join('/'); + expect(state._knownErrorPaths.has(flat)).toBe(true); + }); +}); diff --git a/web/regression/javascript/SchemaView/no_bracket_on_prototype_method.spec.js b/web/regression/javascript/SchemaView/no_bracket_on_prototype_method.spec.js new file mode 100644 index 00000000000..478b5b6dba7 --- /dev/null +++ b/web/regression/javascript/SchemaView/no_bracket_on_prototype_method.spec.js @@ -0,0 +1,102 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2026, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +// Guard against a recurring typo class found during a PEM audit: +// using SQUARE brackets to "call" a prototype method that's a +// function: +// +// classList.join[' '] // wrong: property access on `join`, undefined +// memDeps.push[newProps] // wrong: property access on `push`, undefined +// +// Both expressions evaluate to `undefined`, silently no-op, and survive +// type checking because JS allows arbitrary property access. They are +// almost always a typo for the function call form `.join(' ')` or +// `.push(newProps)`. +// +// We don't have an ESLint rule for this yet, so a regression test that +// walks the SchemaView source tree is the next best thing. + +import fs from 'fs'; +import path from 'path'; + +const SCHEMA_VIEW_DIR = path.resolve( + __dirname, + '..', + '..', + '..', + 'pgadmin', + 'static', + 'js', + 'SchemaView' +); + +const HOT_METHODS = [ + 'push', 'pop', 'shift', 'unshift', + 'join', 'map', 'filter', 'forEach', 'reduce', 'reduceRight', + 'concat', 'slice', 'splice', 'sort', 'reverse', + 'some', 'every', 'find', 'findIndex', 'flat', 'flatMap', + 'indexOf', 'lastIndexOf', 'includes', +]; + +// `.method[` anywhere in the source is the smoke signal. +const ANTI_PATTERN = new RegExp( + String.raw`\.(?:${HOT_METHODS.join('|')})\s*\[`, + 'g' +); + +const collectFiles = (dir) => { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + return entries.flatMap((e) => { + const p = path.join(dir, e.name); + if (e.isDirectory()) return collectFiles(p); + if (/\.(jsx?|tsx?)$/.test(e.name)) return [p]; + return []; + }); +}; + +describe('SchemaView source — bracket-on-prototype-method anti-pattern', () => { + test('no .push[...] / .join[...] / .map[...] etc. anywhere under SchemaView', () => { + const files = collectFiles(SCHEMA_VIEW_DIR); + const offenders = []; + + for (const file of files) { + const src = fs.readFileSync(file, 'utf8'); + // Reset regex state and scan line-by-line for better error + // messages. + const lines = src.split(/\r?\n/); + lines.forEach((line, i) => { + if (line.includes('eslint-disable')) return; // explicit opt-out + // Skip pure single-line comments — the test catches its own + // documentation otherwise. Block-comment detection would need + // a real lexer; the convention "code comes before trailing //" + // is enough. + const trimmed = line.trim(); + if (trimmed.startsWith('//') || trimmed.startsWith('*')) return; + // Strip trailing line comments before matching. + const codeOnly = line.split(/\/\//)[0]; + ANTI_PATTERN.lastIndex = 0; + if (ANTI_PATTERN.test(codeOnly)) { + offenders.push(`${path.relative(SCHEMA_VIEW_DIR, file)}:${i + 1} ${trimmed}`); + } + }); + } + + if (offenders.length > 0) { + const msg = [ + 'Bracket-on-prototype-method anti-pattern found ' + + '(likely typo for a function call):', + ...offenders.map((o) => ' ' + o), + '', + 'If this is intentional (legitimate property access on a function', + 'object), add an `eslint-disable` marker on the line.', + ].join('\n'); + throw new Error(msg); + } + }); +}); diff --git a/web/regression/javascript/SchemaView/parent_deps_declarations.spec.js b/web/regression/javascript/SchemaView/parent_deps_declarations.spec.js new file mode 100644 index 00000000000..aad84dbdeda --- /dev/null +++ b/web/regression/javascript/SchemaView/parent_deps_declarations.spec.js @@ -0,0 +1,212 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2026, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +// Regression guard for the six grid-cell evaluators whose visible / +// disabled / readonly / editable read parent-row data. Their `field.deps` +// MUST include absolute-path entries for those parent fields so the +// DepListener-driven incremental option walker picks them up. + +import _ from 'lodash'; + +import BaseUISchema from '../../../pgadmin/static/js/SchemaView/base_schema.ui'; +import ColumnSchema from + '../../../pgadmin/browser/server_groups/servers/databases/schemas/tables/columns/static/js/column.ui'; +import IndexSchema from + '../../../pgadmin/browser/server_groups/servers/databases/schemas/tables/indexes/static/js/index.ui'; +import { + PartitionsSchema, +} from '../../../pgadmin/browser/server_groups/servers/databases/schemas/tables/static/js/partition.utils.ui'; + +import TableSchema from + '../../../pgadmin/browser/server_groups/servers/databases/schemas/tables/static/js/table.ui'; +import PartitionTableSchema from + '../../../pgadmin/browser/server_groups/servers/databases/schemas/tables/partitions/static/js/partition.ui'; +import { DomainConstSchema } from + '../../../pgadmin/browser/server_groups/servers/databases/schemas/domains/static/js/domain.ui'; + +// Returns true if `arr` contains an element that deep-equals `needle`. +const hasEntry = (arr, needle) => + Array.isArray(arr) && arr.some((e) => _.isEqual(e, needle)); + +const findField = (schema, fieldId) => + schema.baseFields.find((f) => f.id === fieldId); + +class MockSchema extends BaseUISchema { + get baseFields() { return []; } +} + +const newColumnSchema = () => new ColumnSchema( + () => new MockSchema(), + {}, + () => Promise.resolve([]), + () => Promise.resolve([]), +); + +const newIndexSchema = () => new IndexSchema( + {amname: () => Promise.resolve([])}, + {table: {}}, +); + +const newPartitionsSchema = () => new PartitionsSchema( + {table: {}}, + () => Promise.resolve([]), + () => Promise.resolve([]), + () => Promise.resolve([]), +); + +describe('column.ui.js — is_primary_key parent deps', () => { + const field = findField(newColumnSchema(), 'is_primary_key'); + + test('field exists', () => { expect(field).toBeDefined(); }); + + test('declares same-row dep on `name`', () => { + expect(field.deps).toContain('name'); + }); + + test('declares absolute dep on parent `primary_key`', () => { + expect(hasEntry(field.deps, ['primary_key'])).toBe(true); + }); + + test('declares absolute dep on parent `oid`', () => { + expect(hasEntry(field.deps, ['oid'])).toBe(true); + }); + + test('declares absolute dep on parent `is_partitioned`', () => { + expect(hasEntry(field.deps, ['is_partitioned'])).toBe(true); + }); + + test('NEGATIVE — does NOT declare an unrelated parent like `xyz`', () => { + expect(hasEntry(field.deps, ['xyz'])).toBe(false); + }); +}); + +describe('index.ui.js — op_class parent amname dep (inside columns row)', () => { + // IndexSchema exposes a `columns` collection. The row schema is the + // (non-exported) IndexColumnSchema; reach it via the field definition. + const schema = newIndexSchema(); + const colsField = schema.baseFields.find( + (f) => f.id === 'columns' && f.type === 'collection' + ); + + test('columns collection field exists', () => { + expect(colsField).toBeDefined(); + }); + + const rowSchema = colsField?.schema; + const opClassField = rowSchema?.baseFields.find((f) => f.id === 'op_class'); + + test('op_class field exists in row schema', () => { + expect(opClassField).toBeDefined(); + }); + + test('declares absolute dep on parent `amname`', () => { + expect(hasEntry(opClassField.deps, ['amname'])).toBe(true); + }); + + test('NEGATIVE — does not declare relative `amname` (no such field on column row)', () => { + expect(opClassField.deps).not.toContain('amname'); + }); +}); + +describe('partition.utils.ui.js — is_attach parent deps', () => { + const field = findField(newPartitionsSchema(), 'is_attach'); + + test('field exists', () => { expect(field).toBeDefined(); }); + + test('declares absolute dep on parent `oid` (used by obj.top.isNew())', () => { + expect(hasEntry(field.deps, ['oid'])).toBe(true); + }); +}); + +describe('partition.utils.ui.js — is_default parent deps', () => { + const field = findField(newPartitionsSchema(), 'is_default'); + + test('field exists', () => { expect(field).toBeDefined(); }); + + test('declares absolute dep on parent `partition_type`', () => { + expect(hasEntry(field.deps, ['partition_type'])).toBe(true); + }); + + test('declares absolute dep on parent `oid`', () => { + expect(hasEntry(field.deps, ['oid'])).toBe(true); + }); +}); + +describe('partition.utils.ui.js — values_remainder parent deps', () => { + const field = findField(newPartitionsSchema(), 'values_remainder'); + + test('field exists', () => { expect(field).toBeDefined(); }); + + test('declares same-row dep on `is_default`', () => { + expect(field.deps).toContain('is_default'); + }); + + test('declares absolute dep on parent `partition_type`', () => { + expect(hasEntry(field.deps, ['partition_type'])).toBe(true); + }); + + test('declares absolute dep on parent `oid`', () => { + expect(hasEntry(field.deps, ['oid'])).toBe(true); + }); +}); + +describe('Schema-level incrementalOptions opt-in markers', () => { + test('TableSchema declares incrementalOptions = true', () => { + const schema = new TableSchema({}, {}, {}, () => new MockSchema()); + expect(schema.incrementalOptions).toBe(true); + }); + + test('IndexSchema declares incrementalOptions = true', () => { + const schema = new IndexSchema( + {amname: () => Promise.resolve([])}, + {table: {}}, + ); + expect(schema.incrementalOptions).toBe(true); + }); + + test('PartitionTableSchema declares incrementalOptions = true', () => { + const schema = new PartitionTableSchema( + {}, {}, {constraints: () => new MockSchema()}, () => new MockSchema(), + ); + expect(schema.incrementalOptions).toBe(true); + }); + + test('NEGATIVE — a bare BaseUISchema subclass does not opt in by default', () => { + class Unopted extends BaseUISchema { get baseFields() { return []; } } + expect(new Unopted().incrementalOptions).toBeFalsy(); + }); +}); + +describe('domain.ui.js — DomainConstSchema.convalidated parent deps', () => { + const schema = new DomainConstSchema(); + const field = findField(schema, 'convalidated'); + + test('field exists', () => { expect(field).toBeDefined(); }); + + test('declares absolute dep on parent `constraints` (readonly reads top.origData.constraints)', () => { + expect(hasEntry(field.deps, ['constraints'])).toBe(true); + }); +}); + +describe('partition.utils.ui.js — sub_partition_keys parent columns dep', () => { + const field = findField(newPartitionsSchema(), 'sub_partition_keys'); + + test('field exists on PartitionsSchema', () => { + expect(field).toBeDefined(); + }); + + test('declares absolute dep on parent `columns` (canAddRow reads top.sessData.columns)', () => { + expect(hasEntry(field.deps, ['columns'])).toBe(true); + }); + + test('still declares the pre-existing deps', () => { + expect(field.deps).toContain('is_sub_partitioned'); + expect(field.deps).toContain('sub_partition_type'); + }); +}); diff --git a/web/regression/javascript/SchemaView/reducer_deferred.spec.js b/web/regression/javascript/SchemaView/reducer_deferred.spec.js new file mode 100644 index 00000000000..758e428e932 --- /dev/null +++ b/web/regression/javascript/SchemaView/reducer_deferred.spec.js @@ -0,0 +1,99 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2026, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +// Tests for the sessDataReducer's interaction with the deferred-dep +// queue. The reducer must APPEND new deferred items to data.__deferred__ +// rather than replacing the queue — otherwise two SET_VALUE actions +// that fire in the same React batch can lose the first action's pending +// promise(s) before the drain useEffect runs. + +import { + SCHEMA_STATE_ACTIONS, + sessDataReducer, +} from '../../../pgadmin/static/js/SchemaView/SchemaState'; + +describe('sessDataReducer — deferred queue accumulation', () => { + const initial = { name: '', other: '', __changeId: 0 }; + + // A trivial deferredDepChange that returns a unique-tagged promise so + // we can identify which actions produced which items. + const makeDefDepChange = (tag) => + (_currPath, _newState, _action) => [{ tag, promise: Promise.resolve(() => ({})) }]; + + // Reducer-level tests dispatch directly; in production + // sessDispatchWithListener stamps __viaListener so the reducer's + // bypass guard stays silent. Tests must do the same. + const VIA = { __viaListener: true }; + // Note: the reducer's getDeferredDepChange (top of reducer.js) calls + // action.deferredDepChange(currPath, newState, {type, path, value, + // depChange, oldState}). The return value is what becomes + // __deferred__. The reducer itself doesn't care about the inner shape + // — only that it's an array — so we can stuff in tagged sentinels. + + test('SET_VALUE installs the deferred list in __deferred__', () => { + const action = { + type: SCHEMA_STATE_ACTIONS.SET_VALUE, ...VIA, + path: ['name'], value: 'a', + deferredDepChange: makeDefDepChange('first'), + }; + const next = sessDataReducer(initial, action); + expect(next.__deferred__).toHaveLength(1); + expect(next.__deferred__[0].tag).toBe('first'); + }); + + test('a second SET_VALUE APPENDS to __deferred__ instead of replacing', () => { + // Simulate two synchronous SET_VALUEs in the same React batch: the + // first leaves a deferred item; the second must preserve it. + const after1 = sessDataReducer(initial, { + type: SCHEMA_STATE_ACTIONS.SET_VALUE, ...VIA, + path: ['name'], value: 'a', + deferredDepChange: makeDefDepChange('first'), + }); + expect(after1.__deferred__).toHaveLength(1); + + const after2 = sessDataReducer(after1, { + type: SCHEMA_STATE_ACTIONS.SET_VALUE, ...VIA, + path: ['other'], value: 'b', + deferredDepChange: makeDefDepChange('second'), + }); + expect(after2.__deferred__).toHaveLength(2); + expect(after2.__deferred__.map((i) => i.tag)).toEqual(['first', 'second']); + }); + + test('SET_VALUE with no deferredDepChange leaves the existing queue alone', () => { + const after1 = sessDataReducer(initial, { + type: SCHEMA_STATE_ACTIONS.SET_VALUE, ...VIA, + path: ['name'], value: 'a', + deferredDepChange: makeDefDepChange('first'), + }); + expect(after1.__deferred__).toHaveLength(1); + + // No deferredDepChange — should not clobber the queue. + const after2 = sessDataReducer(after1, { + type: SCHEMA_STATE_ACTIONS.SET_VALUE, ...VIA, + path: ['other'], value: 'b', + }); + expect(after2.__deferred__).toHaveLength(1); + expect(after2.__deferred__[0].tag).toBe('first'); + }); + + test('CLEAR_DEFERRED_QUEUE empties __deferred__', () => { + const after1 = sessDataReducer(initial, { + type: SCHEMA_STATE_ACTIONS.SET_VALUE, ...VIA, + path: ['name'], value: 'a', + deferredDepChange: makeDefDepChange('first'), + }); + expect(after1.__deferred__).toHaveLength(1); + + const cleared = sessDataReducer(after1, { + type: SCHEMA_STATE_ACTIONS.CLEAR_DEFERRED_QUEUE, + }); + expect(cleared.__deferred__).toHaveLength(0); + }); +}); diff --git a/web/regression/javascript/SchemaView/registered_schemas_audit.spec.js b/web/regression/javascript/SchemaView/registered_schemas_audit.spec.js new file mode 100644 index 00000000000..19229c09bea --- /dev/null +++ b/web/regression/javascript/SchemaView/registered_schemas_audit.spec.js @@ -0,0 +1,159 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2026, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +// The audit harness — runs `auditSchema` against every schema that +// the registry knows about. This is the production-gate test: if +// every registered schema's audit passes, the incremental walker +// can be flipped on globally. +// +// Discovery: all schema files live under web/pgadmin/**/*.ui.js (a +// few use `.js` — show_view_data.js, roleReassign.js). Importing +// each file at spec load time triggers its `registerSchema()` side +// effect, populating `getRegisteredSchemas()`. +// +// Failure modes the spec surfaces explicitly: +// - import error (file blows up when loaded standalone) → SKIP +// - constructor error (needs real production args) → SKIP +// - canary throw (real divergence) → FAIL +// SKIPs are reported but don't fail CI. FAILs do. + +import fs from 'fs'; +import path from 'path'; +import { + getRegisteredSchemas, _resetRegistry, +} from '../../../pgadmin/static/js/SchemaView/SchemaState/schema_registry'; +import { auditSchema } from + '../../../pgadmin/static/js/SchemaView/SchemaState/audit_harness'; + +const PGADMIN_ROOT = path.resolve(__dirname, '../../../pgadmin'); + +// Walk pgadmin/ for files the codemod touched. These are guaranteed +// to call registerSchema() at the top level. The codemod targets +// files with `extends BaseUISchema` + `^export default class`, plus +// a few `.js` (not `.ui.js`) like show_view_data.js — match the +// same pattern, not just `.ui.js`. +const findSchemaFiles = () => { + const out = []; + const walk = (dir) => { + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + if (e.name === 'node_modules' || e.name === 'generated') continue; + const p = path.join(dir, e.name); + if (e.isDirectory()) { walk(p); continue; } + if (!/\.(js|jsx)$/.test(e.name)) continue; + try { + const src = fs.readFileSync(p, 'utf8'); + if (!/extends BaseUISchema/.test(src)) continue; + if (!/registerSchema\(/.test(src)) continue; + out.push(p); + } catch { /* unreadable; skip */ } + } + }; + walk(PGADMIN_ROOT); + return out; +}; + +// Discovery has to run at describe-time (module top level) so +// `test.each` can register one test per schema. require()'s module +// cache means the side-effect registerSchema() calls only fire on +// FIRST import — a beforeAll re-import would be a no-op after the +// top-level pass. Reset the registry once, then populate. +const importFailures = []; +_resetRegistry(); +for (const file of findSchemaFiles()) { + try { + require(file); + } catch (e) { + importFailures.push({ + file: path.relative(PGADMIN_ROOT, file), + error: e.message.split('\n')[0], + }); + } +} +const schemaNames = Array.from(getRegisteredSchemas().keys()).sort(); + +describe('schema registry discovery', () => { + test('imports populated the registry', () => { + expect(getRegisteredSchemas().size).toBeGreaterThan(0); + }); + + test('import failures are reported (not fatal)', () => { + // The harness reports import failures so they're visible in CI + // logs, but doesn't fail the suite — many schema files import + // pgAdmin browser globals that aren't available in jest's jsdom. + // A future Phase 3.5 may stub more of those globals; for now, + // unreachable files are tracked as SKIPs. + if (importFailures.length > 0) { + console.warn( + `Schema-file import failures (${importFailures.length}):\n` + + importFailures.map((f) => ` ${f.file}: ${f.error}`).join('\n') + ); + } + // Soft assertion — we want VISIBILITY, not failure, here. + expect(importFailures.length).toBeLessThan(200); // sanity ceiling + }); +}); + +// Schemas with known cross-row divergences from the incremental +// walker. The audit harness LANDS as a ratchet: these schemas are +// expected to diverge today; once a schema is fixed (typically by +// adding `field.deps` to declare the cross-row dependency), the +// test starts failing because divergence stops happening — that's +// the signal to remove it from this list. Conversely, any new +// schema that drifts into this list is a regression caught at CI. +// +// Production-flip blocker: this set must be empty before the +// incremental walker can be turned on globally. +const KNOWN_DIVERGING = new Set([]); + +// Modes the audit runs every schema in. The walker's +// `isModeSupportedByField` filters fields by `field.mode`, so +// each mode exercises a different field subset: +// - 'create' shows fields with mode containing 'create' (or no +// mode declared); typical defaults; isNew()=true. +// - 'edit' shows fields with mode containing 'edit'; populated +// baseline data; isNew()=false. +// - 'properties' shows the read-only display fields (mode +// containing 'properties'); covers closures that branch on +// "rendering for read vs editing." Read-only display doesn't +// dispatch user input but the walker still walks the tree on +// every prop change, so divergence under properties mode is a +// real bug class. +const MODES = ['edit', 'create', 'properties']; + +describe.each(MODES)('audit harness — registered schemas [%s mode]', (mode) => { + + test.each(schemaNames)('%s', (name) => { + const SchemaClass = getRegisteredSchemas().get(name); + expect(SchemaClass).toBeDefined(); + + let err = null; + let result = null; + try { result = auditSchema(SchemaClass, { mode }); } + catch (e) { err = e; } + + if (KNOWN_DIVERGING.has(name)) { + // The allowlist promises this schema diverges. If it doesn't + // anymore, the ratchet should tighten — remove from the set. + expect(err).not.toBeNull(); + expect(err.message).toMatch(/divergence/i); + return; + } + + if (err) throw err; // unexpected divergence → real regression + + if (result.skipped) { + // Harness limitation, not a walker bug. Visible in CI logs so + // the SKIP list can be shrunk by adding fixtures, but does + // not fail the test. + console.warn(`SKIP [${mode}] ${name}: ${result.skipReason}`); + return; + } + expect(result.dispatches).toBeGreaterThanOrEqual(0); + }); +}); diff --git a/web/regression/javascript/SchemaView/schema_registry.spec.js b/web/regression/javascript/SchemaView/schema_registry.spec.js new file mode 100644 index 00000000000..bd036aa10b1 --- /dev/null +++ b/web/regression/javascript/SchemaView/schema_registry.spec.js @@ -0,0 +1,135 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2026, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +// Tests for the schema registry. Per design D10, every default- +// exported BaseUISchema subclass wraps its export in `registerSchema()` +// so the audit harness can enumerate schemas without grep / AST walks. +// +// The registry itself is a thin module-scoped Map. Concerns under test: +// - registerSchema is a passthrough: returns its argument unchanged +// - getRegisteredSchemas returns a snapshot (caller mutation can't +// corrupt the internal state) +// - re-registering the same class is idempotent (no duplicate +// entries, last value wins) +// - argument validation: non-function inputs throw early so a +// misuse fails loudly at module load rather than at audit time +// - _resetRegistry isolates tests from each other + +import BaseUISchema from '../../../pgadmin/static/js/SchemaView/base_schema.ui'; +import { + registerSchema, getRegisteredSchemas, _resetRegistry, +} from '../../../pgadmin/static/js/SchemaView/SchemaState/schema_registry'; + +beforeEach(() => { _resetRegistry(); }); + +describe('registerSchema', () => { + test('returns its argument unchanged (passthrough)', () => { + class FooSchema extends BaseUISchema {} + expect(registerSchema(FooSchema)).toBe(FooSchema); + }); + + test('records the class in the registry under its name', () => { + class FooSchema extends BaseUISchema {} + registerSchema(FooSchema); + expect(getRegisteredSchemas().get('FooSchema')).toBe(FooSchema); + }); + + test('re-registering the same class name overwrites (last wins)', () => { + // Two classes can share a name (separate definitions in different + // files). The codebase shouldn't have collisions; if it does, the + // registry surfaces it as last-wins so the audit harness still + // sees a single entry per name. ESLint rule prevents this in + // practice — but the registry shouldn't silently keep both. + class FooSchema extends BaseUISchema {} + const FirstFoo = FooSchema; + registerSchema(FirstFoo); + class FooSchema2 extends BaseUISchema { static get _label() { return 'v2'; } } + Object.defineProperty(FooSchema2, 'name', { value: 'FooSchema' }); + registerSchema(FooSchema2); + expect(getRegisteredSchemas().get('FooSchema')).toBe(FooSchema2); + expect(getRegisteredSchemas().size).toBe(1); + }); + + test('throws on non-function argument', () => { + expect(() => registerSchema(null)).toThrow(TypeError); + expect(() => registerSchema(undefined)).toThrow(TypeError); + expect(() => registerSchema({})).toThrow(TypeError); + expect(() => registerSchema('FooSchema')).toThrow(TypeError); + }); + + test('throws when the class has no name (anonymous)', () => { + // Anonymous classes (e.g. `registerSchema(class extends BaseUISchema {})`) + // would land as key '' in the registry — silently collapsing onto + // one entry. Fail loud so authors give the class a name. + const Anon = (() => class extends BaseUISchema {})(); + expect(Anon.name).toBe(''); + expect(() => registerSchema(Anon)).toThrow(/anonymous|name/i); + }); +}); + +describe('getRegisteredSchemas', () => { + test('returns an empty Map when nothing registered', () => { + const result = getRegisteredSchemas(); + expect(result).toBeInstanceOf(Map); + expect(result.size).toBe(0); + }); + + test('returns a snapshot — mutating it does not affect future calls', () => { + class FooSchema extends BaseUISchema {} + registerSchema(FooSchema); + + const snap = getRegisteredSchemas(); + snap.delete('FooSchema'); + snap.set('BogusSchema', 42); + + const fresh = getRegisteredSchemas(); + expect(fresh.get('FooSchema')).toBe(FooSchema); + expect(fresh.has('BogusSchema')).toBe(false); + }); + + test('includes every registered schema', () => { + class A extends BaseUISchema {} + class B extends BaseUISchema {} + class C extends BaseUISchema {} + registerSchema(A); + registerSchema(B); + registerSchema(C); + + const schemas = getRegisteredSchemas(); + expect(schemas.size).toBe(3); + expect(schemas.get('A')).toBe(A); + expect(schemas.get('B')).toBe(B); + expect(schemas.get('C')).toBe(C); + }); +}); + +describe('_resetRegistry', () => { + test('clears the registry between tests', () => { + class FooSchema extends BaseUISchema {} + registerSchema(FooSchema); + expect(getRegisteredSchemas().size).toBe(1); + + _resetRegistry(); + expect(getRegisteredSchemas().size).toBe(0); + }); +}); + +describe('SchemaState index re-exports', () => { + test('registerSchema and getRegisteredSchemas reachable from SchemaState index', () => { + // The design doc D10 specifies: + // import { getRegisteredSchemas } from 'sources/SchemaView/SchemaState'; + // Verify the index forwards the API so callers don't depend on + // the internal file layout. + const idx = require( + '../../../pgadmin/static/js/SchemaView/SchemaState' + ); + expect(typeof idx.registerSchema).toBe('function'); + expect(typeof idx.getRegisteredSchemas).toBe('function'); + }); +}); diff --git a/web/regression/javascript/SchemaView/subscribe_hooks.spec.js b/web/regression/javascript/SchemaView/subscribe_hooks.spec.js new file mode 100644 index 00000000000..6c4c9e6c7d5 --- /dev/null +++ b/web/regression/javascript/SchemaView/subscribe_hooks.spec.js @@ -0,0 +1,110 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2026, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +// Tests that useFieldValue / useFieldOptions / useFieldError pin their +// useEffect dependencies so they don't re-add a subscription on every +// render. + +import { renderHook } from '@testing-library/react'; + +import { useFieldValue } from + '../../../pgadmin/static/js/SchemaView/hooks/useFieldValue'; +import { useFieldOptions } from + '../../../pgadmin/static/js/SchemaView/hooks/useFieldOptions'; +import { useFieldError } from + '../../../pgadmin/static/js/SchemaView/hooks/useFieldError'; + +const fakeState = () => ({ + value: () => 'v', + options: () => ({}), + errors: { name: null, message: null }, + subscribe: () => () => {}, +}); + +const makeManager = () => { + const add = jest.fn(() => () => {}); + return { add, ref: { current: { add, signal: () => {} } } }; +}; + +const stablePath = ['rows', 0, 'name']; + +describe('useFieldValue — useEffect re-subscribe behaviour', () => { + test('subscribes once across multiple stable re-renders', () => { + const state = fakeState(); + const mgr = makeManager(); + + const { rerender } = renderHook( + ({ path }) => useFieldValue(path, state, mgr.ref), + { initialProps: { path: stablePath } } + ); + rerender({ path: stablePath }); + rerender({ path: stablePath }); + rerender({ path: stablePath }); + + expect(mgr.add).toHaveBeenCalledTimes(1); + }); + + test('NEGATIVE — re-subscribes when path changes', () => { + const state = fakeState(); + const mgr = makeManager(); + + const { rerender } = renderHook( + ({ path }) => useFieldValue(path, state, mgr.ref), + { initialProps: { path: ['rows', 0, 'name'] } } + ); + rerender({ path: ['rows', 1, 'name'] }); + rerender({ path: ['rows', 2, 'name'] }); + + expect(mgr.add).toHaveBeenCalledTimes(3); + }); +}); + +describe('useFieldOptions — useEffect re-subscribe behaviour', () => { + test('subscribes once across multiple stable re-renders', () => { + const state = fakeState(); + const mgr = makeManager(); + + const { rerender } = renderHook( + ({ path }) => useFieldOptions(path, state, mgr.ref), + { initialProps: { path: stablePath } } + ); + rerender({ path: stablePath }); + rerender({ path: stablePath }); + + expect(mgr.add).toHaveBeenCalledTimes(1); + }); + + test('NEGATIVE — re-subscribes when path changes', () => { + const state = fakeState(); + const mgr = makeManager(); + + const { rerender } = renderHook( + ({ path }) => useFieldOptions(path, state, mgr.ref), + { initialProps: { path: ['rows', 0] } } + ); + rerender({ path: ['rows', 1] }); + expect(mgr.add).toHaveBeenCalledTimes(2); + }); +}); + +describe('useFieldError — useEffect re-subscribe behaviour', () => { + test('subscribes once across multiple stable re-renders', () => { + const state = fakeState(); + const mgr = makeManager(); + + const { rerender } = renderHook( + ({ path }) => useFieldError(path, state, mgr.ref), + { initialProps: { path: stablePath } } + ); + rerender({ path: stablePath }); + rerender({ path: stablePath }); + + expect(mgr.add).toHaveBeenCalledTimes(1); + }); +}); diff --git a/web/regression/javascript/SchemaView/subscriber_manager.spec.js b/web/regression/javascript/SchemaView/subscriber_manager.spec.js new file mode 100644 index 00000000000..355297bcb3a --- /dev/null +++ b/web/regression/javascript/SchemaView/subscriber_manager.spec.js @@ -0,0 +1,74 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2026, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +// Tests for the SubscriberManager class. The previous signal/release +// dance relied on the subscribing hooks re-running their useEffect on +// every render to re-add subscriptions; now that those hooks pin their +// deps (see C.1 / subscribe_hooks.spec.js), signal() must NOT tear down +// existing subscriptions, and release() (called on unmount) must run +// synchronously rather than deferring via setTimeout. + +import { SubscriberManager } from + '../../../pgadmin/static/js/SchemaView/hooks/useSchemaStateSubscriber'; + +describe('SubscriberManager.signal', () => { + test('fires the callback once per signal', () => { + const cb = jest.fn(); + const mgr = new SubscriberManager(cb); + mgr.signal(); + expect(cb).toHaveBeenCalledTimes(1); + }); + + test('PRESERVES existing subscriptions (does not auto-tear-down)', () => { + const cb = jest.fn(); + const mgr = new SubscriberManager(cb); + const unsub = jest.fn(); + mgr._add(unsub); + expect(mgr.unsubscribers.size).toBe(1); + + mgr.signal(); + + expect(mgr.unsubscribers.size).toBe(1); + expect(unsub).not.toHaveBeenCalled(); + }); + + test('batches: second signal before mount() is a no-op', () => { + const cb = jest.fn(); + const mgr = new SubscriberManager(cb); + mgr.signal(); + mgr.signal(); + expect(cb).toHaveBeenCalledTimes(1); + }); + + test('mount() re-arms for the next signal', () => { + const cb = jest.fn(); + const mgr = new SubscriberManager(cb); + mgr.signal(); + mgr.mount(); + mgr.signal(); + expect(cb).toHaveBeenCalledTimes(2); + }); +}); + +describe('SubscriberManager.release', () => { + test('synchronously tears down all subscriptions and empties the set', () => { + const cb = jest.fn(); + const mgr = new SubscriberManager(cb); + const u1 = jest.fn(); + const u2 = jest.fn(); + mgr._add(u1); + mgr._add(u2); + + mgr.release(); + + expect(u1).toHaveBeenCalled(); + expect(u2).toHaveBeenCalled(); + expect(mgr.unsubscribers.size).toBe(0); + }); +}); diff --git a/web/regression/javascript/__mocks__/marked.js b/web/regression/javascript/__mocks__/marked.js new file mode 100644 index 00000000000..73385752e38 --- /dev/null +++ b/web/regression/javascript/__mocks__/marked.js @@ -0,0 +1,32 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2026, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +// marked ships ESM-only; jest.config.js routes the module here. +// AIReport / NLQChatPanel only use it to render LLM responses to HTML +// at runtime — Jest tests on those components mock the network +// response and check component state, not rendered Markdown. A +// passthrough that wraps input as a

string is enough to satisfy +// module-load and not break smoke-style assertions. + +const passthrough = (md) => { + if (md == null) return ''; + return `

${String(md)}

`; +}; + +passthrough.parse = passthrough; +passthrough.parseInline = passthrough; +passthrough.use = () => passthrough; +passthrough.setOptions = () => passthrough; +passthrough.Renderer = function Renderer() {}; +passthrough.Tokenizer = function Tokenizer() {}; + +export const marked = passthrough; +export const parse = passthrough; +export const parseInline = passthrough; +export default passthrough; diff --git a/web/regression/javascript/__mocks__/react-data-grid.jsx b/web/regression/javascript/__mocks__/react-data-grid.jsx index a19ae0812e7..6646850da1e 100644 --- a/web/regression/javascript/__mocks__/react-data-grid.jsx +++ b/web/regression/javascript/__mocks__/react-data-grid.jsx @@ -1,6 +1,19 @@ import { useRef } from 'react'; import PropTypes from 'prop-types'; -export * from 'react-data-grid'; + +// react-data-grid ships ESM-only; babel-jest can't transform it in +// place. jest.config.js routes the module to this mock via +// moduleNameMapper. Re-exporting from the real module here would +// defeat the alias (it would resolve back to this file). Instead, +// provide named-export stubs for the few symbols schema-ui files +// import at module load time. Tests that need richer behaviour can +// jest.doMock and replace these. +export const Row = () => null; +export const Cell = () => null; +export const TextEditor = () => null; +export const SelectColumn = {}; +export const headerRenderer = () => null; +export const valueFormatter = (v) => (v == null ? '' : String(v)); export const DataGrid = ( { diff --git a/web/regression/javascript/__mocks__/react-dnd-html5-backend.js b/web/regression/javascript/__mocks__/react-dnd-html5-backend.js new file mode 100644 index 00000000000..739ff2492f5 --- /dev/null +++ b/web/regression/javascript/__mocks__/react-dnd-html5-backend.js @@ -0,0 +1,15 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2026, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +// react-dnd-html5-backend is ESM-only. The schema audits only need +// the export to exist at module load time (passed to ). +// Drag-drop is exercised in Playwright, not Jest, so a no-op is fine. + +export const HTML5Backend = {}; +export default HTML5Backend; diff --git a/web/regression/javascript/__mocks__/react-dnd.jsx b/web/regression/javascript/__mocks__/react-dnd.jsx new file mode 100644 index 00000000000..ee3510d7d3e --- /dev/null +++ b/web/regression/javascript/__mocks__/react-dnd.jsx @@ -0,0 +1,24 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2026, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +// react-dnd ships ESM-only; jest.config.js routes the module to this +// mock via moduleNameMapper. Only the schema-ui audits need these +// symbols to exist at module load time — drag-drop interactions are +// exercised in Playwright, not Jest, so no-op stubs suffice. + + +export const useDrag = () => [ + { isDragging: false }, () => {}, () => {}, +]; +export const useDrop = () => [ + { isOver: false, canDrop: false }, () => {}, +]; + +export const DndProvider = ({ children }) => <>{children}; +DndProvider.displayName = 'DndProviderMock'; diff --git a/web/regression/javascript/__mocks__/react-resize-detector.jsx b/web/regression/javascript/__mocks__/react-resize-detector.jsx new file mode 100644 index 00000000000..ee315760dd8 --- /dev/null +++ b/web/regression/javascript/__mocks__/react-resize-detector.jsx @@ -0,0 +1,40 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2026, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +// react-resize-detector ships ESM-only; jest.config.js routes the +// module here via moduleNameMapper. Tests that exercise resize-driven +// behavior aren't in Jest's scope (those run in Playwright); a stub +// suffices for module-load correctness. + +import React from 'react'; + +// useResizeDetector returns { ref, width, height }. Width/height as +// undefined matches the initial-mount return of the real hook. +export const useResizeDetector = () => ({ + ref: React.createRef(), + width: undefined, + height: undefined, +}); + +// Named class export. Renders children verbatim — consumers expect a +// render-prop pattern; if any test relies on it, add a function-child +// branch. +export const ResizeDetector = ({ children }) => <>{children}; +ResizeDetector.displayName = 'ResizeDetectorMock'; + +// withResizeDetector HOC stub — passes width/height undefined. +export const withResizeDetector = (Component) => { + const Wrapped = (props) => ( + + ); + Wrapped.displayName = `WithResizeDetectorMock(${Component.displayName || Component.name || 'Component'})`; + return Wrapped; +}; + +export default ResizeDetector; diff --git a/web/regression/javascript/eslint-rules/register-schema.spec.js b/web/regression/javascript/eslint-rules/register-schema.spec.js new file mode 100644 index 00000000000..ec9a4697a10 --- /dev/null +++ b/web/regression/javascript/eslint-rules/register-schema.spec.js @@ -0,0 +1,142 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2026, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +// RuleTester spec for the local `register-schema` ESLint rule. The +// rule enforces design D10: every default-exported BaseUISchema +// subclass must be wrapped in `registerSchema()`. CI fails when a +// schema author forgets the wrap; the audit harness then sees a +// missing entry rather than a silent gap. +// +// Cases covered: +// - direct class extending BaseUISchema with no wrap → ERROR +// - identifier export (class declared above) with no wrap → ERROR +// - identifier export wrapped in registerSchema → OK +// - class expression wrapped in registerSchema → OK +// - identifier export wrapped in a different function → ERROR +// (e.g. someoneWrappingFn(Foo) hides the schema; rule should +// surface it so the wrap is corrected to registerSchema) +// - class not extending BaseUISchema → OK (rule ignores) +// - default export that isn't a class → OK +// - schema declared but NOT default-exported → OK (it's an inner +// helper, registry only tracks default exports) + +const { RuleTester } = require('eslint'); +const babelParser = require('@babel/eslint-parser'); +const rule = require('../../../eslint-plugins/local-rules/rules/register-schema'); + +const ruleTester = new RuleTester({ + languageOptions: { + parser: babelParser, + parserOptions: { + requireConfigFile: false, + babelOptions: { + configFile: false, + babelrc: false, + presets: [], + plugins: ['@babel/plugin-syntax-jsx'], + }, + }, + sourceType: 'module', + ecmaVersion: 2022, + }, +}); + +ruleTester.run('register-schema', rule, { + valid: [ + { + name: 'identifier export wrapped in registerSchema', + code: ` + import BaseUISchema from 'sources/SchemaView/base_schema.ui'; + import { registerSchema } from 'sources/SchemaView/SchemaState'; + class FooSchema extends BaseUISchema {} + export default registerSchema(FooSchema); + `, + }, + { + name: 'class expression wrapped in registerSchema', + code: ` + import BaseUISchema from 'sources/SchemaView/base_schema.ui'; + import { registerSchema } from 'sources/SchemaView/SchemaState'; + export default registerSchema(class FooSchema extends BaseUISchema {}); + `, + }, + { + name: 'class not extending BaseUISchema is ignored', + code: ` + class Helper {} + export default Helper; + `, + }, + { + name: 'default export of non-class is ignored', + code: ` + export default { foo: 1 }; + `, + }, + { + name: 'schema declared but not default-exported is ignored', + code: ` + import BaseUISchema from 'sources/SchemaView/base_schema.ui'; + class InnerHelperSchema extends BaseUISchema {} + export const inner = new InnerHelperSchema(); + export default {}; + `, + }, + { + name: 'identifier export of non-schema variable', + code: ` + const x = 42; + export default x; + `, + }, + ], + invalid: [ + { + name: 'direct class declaration extending BaseUISchema with no wrap', + code: ` + import BaseUISchema from 'sources/SchemaView/base_schema.ui'; + export default class FooSchema extends BaseUISchema {} + `, + errors: [{ messageId: 'missingWrap' }], + }, + { + name: 'identifier export with no wrap', + code: ` + import BaseUISchema from 'sources/SchemaView/base_schema.ui'; + class FooSchema extends BaseUISchema {} + export default FooSchema; + `, + errors: [{ messageId: 'missingWrap' }], + }, + { + name: 'identifier export wrapped in unrelated function', + code: ` + import BaseUISchema from 'sources/SchemaView/base_schema.ui'; + function decorate(x) { return x; } + class FooSchema extends BaseUISchema {} + export default decorate(FooSchema); + `, + errors: [{ messageId: 'missingWrap' }], + }, + { + name: 'direct class expression in non-registerSchema call', + code: ` + import BaseUISchema from 'sources/SchemaView/base_schema.ui'; + function decorate(x) { return x; } + export default decorate(class FooSchema extends BaseUISchema {}); + `, + errors: [{ messageId: 'missingWrap' }], + }, + ], +}); + +// RuleTester throws on first failure — reaching here means all cases passed. +test('register-schema rule passes all RuleTester cases', () => { + expect(true).toBe(true); +}); diff --git a/web/regression/javascript/schema_ui_files/azure_schema.deferred.spec.js b/web/regression/javascript/schema_ui_files/azure_schema.deferred.spec.js new file mode 100644 index 00000000000..d6c97373381 --- /dev/null +++ b/web/regression/javascript/schema_ui_files/azure_schema.deferred.spec.js @@ -0,0 +1,130 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2026, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +// Characterization + contract tests for AzureCredSchema's +// is_authenticating deferredDepChange. + +import { AzureCredSchema } from + '../../../pgadmin/misc/cloud/static/js/azure_schema.ui'; +import pgAdmin from '../fake_pgadmin'; + +const getIsAuthenticatingField = (cred) => { + const field = cred.baseFields.find((f) => f.id === 'is_authenticating'); + expect(field).toBeDefined(); + expect(typeof field.deferredDepChange).toBe('function'); + return field.deferredDepChange.bind(cred); +}; + +describe('AzureCredSchema is_authenticating deferredDepChange', () => { + let cred, defChange, authCodeMock; + + beforeEach(() => { + authCodeMock = jest.fn(); + cred = new AzureCredSchema(null, { getAuthCode: () => authCodeMock() }); + defChange = getIsAuthenticatingField(cred); + }); + + // Source is passed by the schema framework as an *array* (the path + // to the field that changed) — e.g. ['auth_btn'] for a top-level + // field, or ['parent', 'auth_btn'] when the schema is embedded in a + // nested context. The earlier tests passed a bare string which + // happened to compare equal-by-coercion against 'auth_btn' for a + // single-element array but doesn't reflect production shape. + + // ---- Happy path (characterization — must hold before AND after refactor) - + + test('matching trigger + getAuthCode resolves → callback returns auth_code delta', async () => { + authCodeMock.mockResolvedValue({ + data: { data: { user_code: 'ABC-123' } }, + }); + const result = defChange( + { auth_type: 'interactive_browser_credential', is_authenticating: true }, + ['auth_btn'], + ); + expect(result).toBeInstanceOf(Promise); + const cb = await result; + expect(typeof cb).toBe('function'); + expect(cb()).toEqual({ is_authenticating: false, auth_code: 'ABC-123' }); + }); + + test('matching trigger when embedded in a nested path (source ends in auth_btn) still proceeds', async () => { + // Regression: the legacy guard `source != "auth_btn"` worked by + // single-element-array coercion. With a nested source like + // ['parent', 'auth_btn'], the array coerces to 'parent,auth_btn' + // and the guard fired wrongly, opting out of the auth flow. The + // fix compares against the LAST path segment. + authCodeMock.mockResolvedValue({ + data: { data: { user_code: 'XYZ-789' } }, + }); + const result = defChange( + { auth_type: 'interactive_browser_credential', is_authenticating: true }, + ['parent', 'auth_btn'], + ); + expect(result).toBeInstanceOf(Promise); + const cb = await result; + expect(cb()).toEqual({ is_authenticating: false, auth_code: 'XYZ-789' }); + }); + + test('matching trigger + getAuthCode rejects → recovers via notifier.error + resolves with reset callback', async () => { + // Updated contract: instead of rejecting (which the drain swallows + // into console.error with zero user feedback and leaves + // is_authenticating stuck true), the schema now surfaces the error + // via the notifier and resolves with a callback that resets + // is_authenticating so the UI unblocks. + const errorSpy = jest.spyOn(pgAdmin.Browser.notifier, 'error') + .mockImplementation(() => {}); + authCodeMock.mockRejectedValue(new Error('upstream failure')); + + const result = defChange( + { auth_type: 'interactive_browser_credential', is_authenticating: true }, + ['auth_btn'], + ); + expect(result).toBeInstanceOf(Promise); + + const cb = await result; + expect(typeof cb).toBe('function'); + // Also clears any stale auth_code from a prior successful attempt. + // Otherwise the user would see "still authenticated" UI alongside + // the failure toast, which is misleading. + expect(cb()).toEqual({ is_authenticating: false, auth_code: null }); + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(errorSpy.mock.calls[0][0]).toMatch(/upstream failure/); + + errorSpy.mockRestore(); + }); + + // ---- NEW contract: non-matching trigger returns undefined -------------- + + test('source other than auth_btn → returns undefined (was a hung Promise)', () => { + const result = defChange( + { auth_type: 'interactive_browser_credential', is_authenticating: true }, + ['some_other_field'], + ); + expect(result).toBeUndefined(); + expect(authCodeMock).not.toHaveBeenCalled(); + }); + + test('auth_type not interactive_browser_credential → returns undefined', () => { + const result = defChange( + { auth_type: 'service_principal_credential', is_authenticating: true }, + ['auth_btn'], + ); + expect(result).toBeUndefined(); + expect(authCodeMock).not.toHaveBeenCalled(); + }); + + test('is_authenticating false → returns undefined', () => { + const result = defChange( + { auth_type: 'interactive_browser_credential', is_authenticating: false }, + ['auth_btn'], + ); + expect(result).toBeUndefined(); + expect(authCodeMock).not.toHaveBeenCalled(); + }); +}); diff --git a/web/regression/javascript/schema_ui_files/exclusion_constraint.deferred.spec.js b/web/regression/javascript/schema_ui_files/exclusion_constraint.deferred.spec.js new file mode 100644 index 00000000000..076f2ce1f51 --- /dev/null +++ b/web/regression/javascript/schema_ui_files/exclusion_constraint.deferred.spec.js @@ -0,0 +1,149 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2026, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +// Characterization + contract tests for the amname deferredDepChange +// on ExclusionConstraintSchema. Reachable paths: +// 1) amname unchanged -> no-op (new) +// 2) amname changed, columns empty -> no-op (new) +// 3) amname changed -> btree, user confirms -> exColumnSchema +// gets btree operClass options + sort defaults, +// delta {columns: []} +// 4) amname changed -> other, user confirms -> exColumnSchema +// gets empty operClass options + no-sort defaults, +// delta {columns: []} +// 5) amname changed, user cancels -> delta +// {amname: oldValue}, exColumnSchema NOT mutated + +import ExclusionConstraintSchema from + '../../../pgadmin/browser/server_groups/servers/databases/schemas/tables/constraints/exclusion_constraint/static/js/exclusion_constraint.ui'; +import pgAdmin from '../fake_pgadmin'; + +const makeSchema = () => { + const operClassOptions = [{ label: 'oc1', value: 'oc1' }]; + const schema = new ExclusionConstraintSchema( + { + columns: () => Promise.resolve([]), + amname: () => Promise.resolve([]), + spcname: () => Promise.resolve([]), + getOperClass: () => operClassOptions, + getOperator: () => Promise.resolve([]), + }, + {}, + ); + return schema; +}; + +const getAmnameDeferred = (schema) => { + const field = schema.fields.find((f) => f.id === 'amname'); + expect(field).toBeDefined(); + expect(typeof field.deferredDepChange).toBe('function'); + return field.deferredDepChange.bind(schema); +}; + +describe('ExclusionConstraintSchema.amname deferredDepChange', () => { + let schema, deferredDepChange, confirmSpy; + let setOperClassSpy, changeDefaultsSpy; + + beforeEach(() => { + schema = makeSchema(); + deferredDepChange = getAmnameDeferred(schema); + confirmSpy = jest.spyOn(pgAdmin.Browser.notifier, 'confirm'); + confirmSpy.mockClear(); + setOperClassSpy = jest.spyOn(schema.exColumnSchema, 'setOperClassOptions') + .mockImplementation(() => {}); + changeDefaultsSpy = jest.spyOn(schema.exColumnSchema, 'changeDefaults') + .mockImplementation(() => {}); + }); + afterEach(() => { + confirmSpy.mockRestore(); + setOperClassSpy.mockRestore(); + changeDefaultsSpy.mockRestore(); + }); + + // ---- No-op paths (new contract) ---------------------------------------- + + test('no-op: amname unchanged → returns undefined', () => { + const state = { amname: 'btree', columns: [{ column: 'c1' }] }; + const result = deferredDepChange(state, null, null, { + oldState: { amname: 'btree' }, + }); + expect(result).toBeUndefined(); + expect(confirmSpy).not.toHaveBeenCalled(); + }); + + test('no-op: amname changed but columns empty → returns undefined', () => { + const state = { amname: 'gist', columns: [] }; + const result = deferredDepChange(state, null, null, { + oldState: { amname: 'btree' }, + }); + expect(result).toBeUndefined(); + expect(confirmSpy).not.toHaveBeenCalled(); + }); + + // ---- Confirm paths (characterization) ---------------------------------- + + test('confirm with amname=btree → exColumnSchema gets btree operClass + sort defaults, delta {columns: []}', async () => { + const state = { amname: 'btree', columns: [{ column: 'c1' }] }; + const result = deferredDepChange(state, null, null, { + oldState: { amname: 'gist' }, + }); + expect(confirmSpy).toHaveBeenCalledTimes(1); + const [, , confirmCb] = confirmSpy.mock.calls[0]; + + confirmCb(); + const cb = await result; + expect(cb()).toEqual({ columns: [] }); + expect(setOperClassSpy).toHaveBeenCalled(); + expect(changeDefaultsSpy).toHaveBeenCalledWith({ + order: true, + nulls_order: true, + is_sort_nulls_applicable: true, + }); + expect(schema.exColumnSchema.amname).toBe('btree'); + }); + + test('confirm with amname=gist → exColumnSchema gets empty operClass + no-sort defaults, delta {columns: []}', async () => { + const state = { amname: 'gist', columns: [{ column: 'c1' }] }; + const result = deferredDepChange(state, null, null, { + oldState: { amname: 'btree' }, + }); + expect(confirmSpy).toHaveBeenCalledTimes(1); + const [, , confirmCb] = confirmSpy.mock.calls[0]; + + confirmCb(); + const cb = await result; + expect(cb()).toEqual({ columns: [] }); + expect(setOperClassSpy).toHaveBeenCalledWith([]); + expect(changeDefaultsSpy).toHaveBeenCalledWith({ + order: false, + nulls_order: false, + is_sort_nulls_applicable: false, + }); + expect(schema.exColumnSchema.amname).toBe('gist'); + }); + + // ---- Cancel path (characterization) ------------------------------------ + + test('cancel → delta {amname: oldValue}, exColumnSchema NOT mutated', async () => { + const state = { amname: 'gist', columns: [{ column: 'c1' }] }; + const result = deferredDepChange(state, null, null, { + oldState: { amname: 'btree' }, + }); + expect(confirmSpy).toHaveBeenCalledTimes(1); + const [, , , cancelCb] = confirmSpy.mock.calls[0]; + + cancelCb(); + const cb = await result; + expect(cb()).toEqual({ amname: 'btree' }); + expect(setOperClassSpy).not.toHaveBeenCalled(); + expect(changeDefaultsSpy).not.toHaveBeenCalled(); + // Input state's amname is NOT mutated. + expect(state.amname).toBe('gist'); + }); +}); diff --git a/web/regression/javascript/schema_ui_files/exclusion_constraint.ui.spec.js b/web/regression/javascript/schema_ui_files/exclusion_constraint.ui.spec.js index 94918371f18..a2617efa24a 100644 --- a/web/regression/javascript/schema_ui_files/exclusion_constraint.ui.spec.js +++ b/web/regression/javascript/schema_ui_files/exclusion_constraint.ui.spec.js @@ -203,8 +203,10 @@ describe('ExclusionConstraintSchema', ()=>{ it('btree', (done)=>{ confirmSpy.mockClear(); - let state = {amname: 'btree'}; - let deferredPromise = deferredDepChange(state); + let state = {amname: 'btree', columns: [{column: 'c1'}]}; + let deferredPromise = deferredDepChange(state, null, null, { + oldState: { amname: 'gist' }, + }); deferredPromise.then((depChange)=>{ expect(schemaObj.exColumnSchema.setOperClassOptions).toHaveBeenCalledWith(operClassOptions); expect(depChange()).toEqual({ @@ -218,8 +220,10 @@ describe('ExclusionConstraintSchema', ()=>{ it('not btree', (done)=>{ confirmSpy.mockClear(); - let state = {amname: 'gist'}; - let deferredPromise = deferredDepChange(state); + let state = {amname: 'gist', columns: [{column: 'c1'}]}; + let deferredPromise = deferredDepChange(state, null, null, { + oldState: { amname: 'btree' }, + }); deferredPromise.then((depChange)=>{ expect(schemaObj.exColumnSchema.setOperClassOptions).toHaveBeenCalledWith([]); expect(depChange()).toEqual({ @@ -233,7 +237,7 @@ describe('ExclusionConstraintSchema', ()=>{ it('press no', (done)=>{ confirmSpy.mockClear(); - let state = {amname: 'gist'}; + let state = {amname: 'gist', columns: [{column: 'c1'}]}; let deferredPromise = deferredDepChange(state, null, null, { oldState: { amname: 'btree', diff --git a/web/regression/javascript/schema_ui_files/foreign_table.deferred.spec.js b/web/regression/javascript/schema_ui_files/foreign_table.deferred.spec.js new file mode 100644 index 00000000000..45737abd6d7 --- /dev/null +++ b/web/regression/javascript/schema_ui_files/foreign_table.deferred.spec.js @@ -0,0 +1,187 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2026, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +// Characterization + contract tests for the inherits deferredDepChange +// on ForeignTableSchema. Reachable paths: +// add : new list grew -> fetch columns, +// append fetched cols to tmpstate.columns +// rm : new list shrank -> drop columns +// whose inheritedid == removed table's oid +// noop : new == old (same shape or both empty) -> return undefined +// +// The new contract additionally requires: +// - no input-state mutation +// - opt-out path returns undefined (not a hanging Promise) + +import ForeignTableSchema from + '../../../pgadmin/browser/server_groups/servers/databases/schemas/foreign_tables/static/js/foreign_table.ui'; + +const makeSchema = (getColumnsMock) => { + const schema = new ForeignTableSchema( + () => null, () => null, + getColumnsMock, + { role: [], schema: [], foreignServers: [], tables: [] }, + ); + schema.inheritedTableList = [ + { label: 'a', value: 1 }, + { label: 'b', value: 2 }, + { label: 'c', value: 3 }, + ]; + // Make columnsObj.getNewData deterministic and observable. + jest.spyOn(schema.columnsObj, 'getNewData') + .mockImplementation((col) => ({ ...col, inheritedid: col.inheritedid })); + return schema; +}; + +const getDeferred = (schema) => { + const field = schema.fields.find((f) => f.id === 'inherits'); + expect(field).toBeDefined(); + expect(typeof field.deferredDepChange).toBe('function'); + return field.deferredDepChange.bind(schema); +}; + +describe('ForeignTableSchema.inherits deferredDepChange', () => { + let schema, deferredDepChange, getColumnsMock; + + beforeEach(() => { + getColumnsMock = jest.fn(); + schema = makeSchema(getColumnsMock); + deferredDepChange = getDeferred(schema); + }); + + // ---- ADD paths (characterization) -------------------------------------- + + test('first table added → fetches columns, callback appends to tmpstate.columns', async () => { + getColumnsMock.mockResolvedValue([{ name: 'col_x', inheritedid: 1 }]); + const state = { inherits: [1] }; + const result = deferredDepChange(state, null, null, { + oldState: { inherits: [] }, + }); + const cb = await result; + expect(getColumnsMock).toHaveBeenCalledWith({ attrelid: 1 }); + const delta = cb({ columns: [{ name: 'existing', inheritedid: null }] }); + expect(delta.adding_inherit_cols).toBe(false); + expect(delta.columns).toEqual([ + { name: 'existing', inheritedid: null }, + { name: 'col_x', inheritedid: 1 }, + ]); + }); + + test('additional table added → fetches columns for the newly added id', async () => { + getColumnsMock.mockResolvedValue([{ name: 'col_y', inheritedid: 2 }]); + const state = { inherits: [1, 2] }; + const result = deferredDepChange(state, null, null, { + oldState: { inherits: [1] }, + }); + const cb = await result; + expect(getColumnsMock).toHaveBeenCalledWith({ attrelid: 2 }); + const delta = cb({ columns: [{ name: 'col_x', inheritedid: 1 }] }); + expect(delta.columns).toEqual([ + { name: 'col_x', inheritedid: 1 }, + { name: 'col_y', inheritedid: 2 }, + ]); + }); + + // ---- REMOVE paths (characterization, plus no-mutation contract) -------- + + test('one of several removed → drops columns with that inheritedid, without mutating tmpstate.columns', async () => { + const state = { inherits: [1] }; + const result = deferredDepChange(state, null, null, { + oldState: { inherits: [1, 2] }, + }); + const cb = await result; + const tmpCols = [ + { name: 'col_x', inheritedid: 1 }, + { name: 'col_y', inheritedid: 2 }, + ]; + const delta = cb({ columns: tmpCols }); + expect(delta.adding_inherit_cols).toBe(false); + expect(delta.columns).toEqual([{ name: 'col_x', inheritedid: 1 }]); + // New contract: tmpstate's columns array is NOT mutated. + expect(tmpCols).toHaveLength(2); + expect(tmpCols[1]).toEqual({ name: 'col_y', inheritedid: 2 }); + }); + + test('last one removed → drops columns belonging to the last table', async () => { + const state = { inherits: [] }; + const result = deferredDepChange(state, null, null, { + oldState: { inherits: [3] }, + }); + const cb = await result; + const tmpCols = [ + { name: 'col_z', inheritedid: 3 }, + { name: 'local', inheritedid: null }, + ]; + const delta = cb({ columns: tmpCols }); + expect(delta.columns).toEqual([{ name: 'local', inheritedid: null }]); + expect(tmpCols).toHaveLength(2); + }); + + // ---- NEW CONTRACT: opt-out paths --------------------------------------- + + test('no-op: both lists empty → returns undefined (was a hanging Promise)', () => { + const result = deferredDepChange( + { inherits: [] }, null, null, { oldState: { inherits: [] } }, + ); + expect(result).toBeUndefined(); + expect(getColumnsMock).not.toHaveBeenCalled(); + }); + + test('no-op: lists are deep-equal (re-fired without real change) → returns undefined', () => { + const result = deferredDepChange( + { inherits: [1, 2] }, null, null, { oldState: { inherits: [1, 2] } }, + ); + expect(result).toBeUndefined(); + expect(getColumnsMock).not.toHaveBeenCalled(); + }); + + // ---- Regression: stale inheritedTableList ------------------------------ + + test('remove of a table missing from inheritedTableList → returns undefined (no silent loss of local columns)', () => { + // Reproduces the data-loss bug found during aggressive review. + // If the removed table is not in `inheritedTableList`, getTableOid + // returns undefined, and the old callback would have filtered out + // every column with `inheritedid == undefined || null` — including + // user-added local columns. Contract: opt out instead. + const schemaWithStaleList = makeSchema(getColumnsMock); + schemaWithStaleList.inheritedTableList = []; // stale: removed table not here + const defChange = getDeferred(schemaWithStaleList); + const result = defChange( + { inherits: [] }, null, null, { oldState: { inherits: [99 /* unknown */] } }, + ); + expect(result).toBeUndefined(); + expect(getColumnsMock).not.toHaveBeenCalled(); + }); + + test('same length, swapped content (replace) → processes the remove so stale columns are cleared', async () => { + // A multi-select swap (e.g. user replaces parent table A with B + // while keeping C) emits a same-length array with different + // contents. Old behavior: opt out → stale columns from the + // removed parent stay forever. New behavior: detect the remove + // direction and apply it; the next selection of the new parent + // (when getColumns succeeds) will trigger a normal ADD for those. + const result = deferredDepChange( + { inherits: [1, 2] }, null, null, { oldState: { inherits: [2, 3] } }, + ); + expect(result).toBeInstanceOf(Promise); + const cb = await result; + const tmpCols = [ + { name: 'kept_local', inheritedid: null }, + { name: 'from_2', inheritedid: 2 }, + { name: 'from_3', inheritedid: 3 }, // 3 was removed + ]; + const delta = cb({ columns: tmpCols }); + expect(delta.columns).toEqual([ + { name: 'kept_local', inheritedid: null }, + { name: 'from_2', inheritedid: 2 }, + ]); + // No new fetch fired — adds are handled by the next user gesture. + expect(getColumnsMock).not.toHaveBeenCalled(); + }); +}); diff --git a/web/regression/javascript/schema_ui_files/index.ui.deferred.spec.js b/web/regression/javascript/schema_ui_files/index.ui.deferred.spec.js new file mode 100644 index 00000000000..18dbcf66e2d --- /dev/null +++ b/web/regression/javascript/schema_ui_files/index.ui.deferred.spec.js @@ -0,0 +1,110 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2026, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +// Characterization + contract tests for the `amname` deferredDepChange +// on IndexSchema. The four reachable paths are: +// 1) amname unchanged -> no-op +// 2) amname changed, columns empty -> no-op +// 3) amname changed, columns present, user confirms -> clear columns +// 4) amname changed, columns present, user cancels -> revert amname +// +// These tests assert the OBSERVABLE outputs (returned promise, callback +// return value, side-effects on the input state) so a refactor that +// preserves intent can be verified against them. + +import IndexSchema from + '../../../pgadmin/browser/server_groups/servers/databases/schemas/tables/indexes/static/js/index.ui'; +// IndexSchema's deferredDepChange calls `pgAdmin.Browser.notifier.confirm` +// via `import pgAdmin from 'sources/pgadmin'`. Jest's moduleNameMapper +// redirects `sources/pgadmin` to fake_pgadmin, so we import the SAME +// instance here and spy on its `confirm`. +import pgAdmin from '../fake_pgadmin'; + +const getAmnameDeferredDepChange = () => { + const schema = new IndexSchema( + { amname: () => Promise.resolve([]) }, + { table: {} }, + ); + const field = schema.baseFields.find((f) => f.id === 'amname'); + expect(field).toBeDefined(); + expect(typeof field.deferredDepChange).toBe('function'); + return field.deferredDepChange.bind(schema); +}; + +describe('IndexSchema.amname deferredDepChange — characterization', () => { + let deferredDepChange; + let confirmSpy; + beforeEach(() => { + deferredDepChange = getAmnameDeferredDepChange(); + confirmSpy = jest.spyOn(pgAdmin.Browser.notifier, 'confirm'); + confirmSpy.mockClear(); + }); + afterEach(() => { confirmSpy.mockRestore(); }); + + // ---- No-op paths --------------------------------------------------------- + + test('no-op: amname unchanged — returns undefined (opts out of queue)', () => { + const state = { amname: 'btree', columns: [{ name: 'c1' }] }; + const result = deferredDepChange(state, null, null, { + oldState: { amname: 'btree' }, + }); + expect(result).toBeUndefined(); + expect(confirmSpy).not.toHaveBeenCalled(); + }); + + test('no-op: amname changed but columns empty — returns undefined', () => { + const state = { amname: 'hash', columns: [] }; + const result = deferredDepChange(state, null, null, { + oldState: { amname: 'btree' }, + }); + expect(result).toBeUndefined(); + expect(confirmSpy).not.toHaveBeenCalled(); + }); + + // ---- Confirm path -------------------------------------------------------- + + test('change + confirm: callback returns {columns: []} as a fresh empty array, without mutating input state', async () => { + const originalColumns = [{ name: 'c1' }, { name: 'c2' }]; + const state = { amname: 'hash', columns: originalColumns }; + const result = deferredDepChange(state, null, null, { + oldState: { amname: 'btree' }, + }); + expect(confirmSpy).toHaveBeenCalledTimes(1); + const [/* title */, /* msg */, confirmCb /* cancelCb */] = + confirmSpy.mock.calls[0]; + + confirmCb(); + const cb = await result; + expect(typeof cb).toBe('function'); + const delta = cb(); + expect(delta).toEqual({ columns: [] }); + // New contract: no input-state mutation. + expect(state.columns).toBe(originalColumns); + expect(originalColumns).toHaveLength(2); + }); + + // ---- Cancel path --------------------------------------------------------- + + test('change + cancel: callback returns {amname: oldValue} without mutating input state', async () => { + const state = { amname: 'hash', columns: [{ name: 'c1' }] }; + const result = deferredDepChange(state, null, null, { + oldState: { amname: 'btree' }, + }); + expect(confirmSpy).toHaveBeenCalledTimes(1); + const [, , , cancelCb] = confirmSpy.mock.calls[0]; + + cancelCb(); + const cb = await result; + expect(typeof cb).toBe('function'); + const delta = cb(); + expect(delta).toEqual({ amname: 'btree' }); + // New contract: input state's amname is NOT mutated. + expect(state.amname).toBe('hash'); + }); +}); diff --git a/web/regression/javascript/schema_ui_files/table.ui.deferred.spec.js b/web/regression/javascript/schema_ui_files/table.ui.deferred.spec.js new file mode 100644 index 00000000000..b5845b5b8a9 --- /dev/null +++ b/web/regression/javascript/schema_ui_files/table.ui.deferred.spec.js @@ -0,0 +1,175 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2026, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +// Opt-out + no-mutation contract tests for TableSchema's +// deferredDepChange on `typname` and `coll_inherits`. The happy paths +// for both fields are already covered by table.ui.spec.js; this file +// adds: +// +// * typname — no-change branch must return undefined, +// not a Promise wrapping a no-op callback +// * coll_inherits — same-length / both-empty / deep-equal must +// return undefined (previously hung) +// * coll_inherits — remove branch's callback must NOT mutate +// tmpstate.columns + +import _ from 'lodash'; +import { getNodeTableSchema } from + '../../../pgadmin/browser/server_groups/servers/databases/schemas/tables/static/js/table.ui'; +import * as nodeAjax from + '../../../pgadmin/browser/static/js/node_ajax'; + +const makeSchema = () => { + jest.spyOn(nodeAjax, 'getNodeAjaxOptions').mockReturnValue(Promise.resolve([])); + jest.spyOn(nodeAjax, 'getNodeListByName').mockReturnValue(Promise.resolve([])); + return getNodeTableSchema( + { server: { _id: 1 }, schema: { _label: 'public' } }, {}, + { + Nodes: { table: {} }, + serverInfo: { 1: { user: { name: 'Postgres' } } }, + }, + ); +}; + +const getDeferred = (schema, fieldId) => { + const field = _.find(schema.fields, (f) => f.id === fieldId); + expect(field).toBeDefined(); + expect(typeof field.deferredDepChange).toBe('function'); + return field.deferredDepChange.bind(schema); +}; + +describe('TableSchema.typname deferredDepChange — opt-out contract', () => { + let schema, deferredDepChange; + + beforeEach(() => { + schema = makeSchema(); + deferredDepChange = getDeferred(schema, 'typname'); + jest.spyOn(schema, 'changeColumnOptions').mockImplementation(() => {}); + }); + + test('no change → returns undefined (was Promise wrapping no-op callback)', () => { + const result = deferredDepChange( + { typname: null }, null, null, { oldState: { typname: null } }, + ); + expect(result).toBeUndefined(); + }); + + test('both equal non-empty → returns undefined', () => { + const result = deferredDepChange( + { typname: 'type1' }, null, null, { oldState: { typname: 'type1' } }, + ); + expect(result).toBeUndefined(); + }); + + test('stale ofTypeTables (state.typname not in options) → callback does not throw, columns=[]', async () => { + // Aggressive review caught: when state.typname is non-empty but + // doesn't match any loaded option (option list stale or empty), + // the original code did `typeTable.oftype_columns` on `undefined` + // and threw. The drain swallowed the throw into console.error + // with zero user feedback. + schema.ofTypeTables = []; // stale / empty + // Simulate the "later selection" path (typname changed, not from null). + const result = deferredDepChange( + { typname: 'no_such_type' }, null, null, + { oldState: { typname: 'old_type' } }, + ); + expect(result).toBeInstanceOf(Promise); + const cb = await result; + expect(() => cb()).not.toThrow(); + const delta = cb(); + expect(delta.columns).toEqual([]); + expect(delta.primary_key).toEqual([]); + }); +}); + +describe('TableSchema.coll_inherits deferredDepChange — opt-out + no-mutation', () => { + let schema, deferredDepChange; + + beforeEach(() => { + schema = makeSchema(); + deferredDepChange = getDeferred(schema, 'coll_inherits'); + jest.spyOn(schema, 'changeColumnOptions').mockImplementation(() => {}); + jest.spyOn(schema, 'getTableOid').mockReturnValue(140391); + jest.spyOn(schema, 'getColumns').mockResolvedValue([]); + }); + + test('both empty → returns undefined (was a hanging Promise)', () => { + const result = deferredDepChange( + { coll_inherits: [], columns: [] }, null, null, + { oldState: { coll_inherits: [] } }, + ); + expect(result).toBeUndefined(); + expect(schema.getColumns).not.toHaveBeenCalled(); + }); + + test('deep-equal repeat → returns undefined', () => { + const result = deferredDepChange( + { coll_inherits: ['t1', 't2'], columns: [] }, null, null, + { oldState: { coll_inherits: ['t1', 't2'] } }, + ); + expect(result).toBeUndefined(); + expect(schema.getColumns).not.toHaveBeenCalled(); + }); + + test('same-length swap → processes the remove so stale columns are cleared', async () => { + // Same shape as the foreign_table same-length swap fix. + schema.getTableOid.mockImplementation((name) => + ({ t1: 1, t2: 2, t3: 3 })[name]); + const result = deferredDepChange( + { coll_inherits: ['t1', 't3'], columns: [] }, null, null, + { oldState: { coll_inherits: ['t1', 't2'] } }, + ); + expect(result).toBeInstanceOf(Promise); + const cb = await result; + const tmpCols = [ + { name: 'kept_local', inheritedid: null }, + { name: 'from_t1', inheritedid: 1 }, + { name: 'from_t2', inheritedid: 2 }, // t2 was removed + ]; + const delta = cb({ columns: tmpCols }); + expect(delta.columns).toEqual([ + { name: 'kept_local', inheritedid: null }, + { name: 'from_t1', inheritedid: 1 }, + ]); + expect(schema.getColumns).not.toHaveBeenCalled(); + }); + + test('remove of a table missing from inheritedTableList → returns undefined (regression guard)', () => { + // Data-loss regression found during aggressive review. If the + // removed table is not in `inheritedTableList`, getTableOid returns + // undefined and the legacy refactored callback would have filtered + // out every column with `inheritedid` equal-by-coercion to undefined + // (i.e. null or missing) — silently dropping local user-added + // columns. Contract: opt out. + jest.spyOn(schema, 'getTableOid').mockReturnValue(undefined); + const result = deferredDepChange( + { coll_inherits: ['t1'], columns: [] }, null, null, + { oldState: { coll_inherits: ['t1', 't2'] } }, + ); + expect(result).toBeUndefined(); + expect(schema.getColumns).not.toHaveBeenCalled(); + }); + + test('remove → callback does NOT mutate tmpstate.columns', async () => { + const result = deferredDepChange( + { coll_inherits: [], columns: [] }, null, null, + { oldState: { coll_inherits: ['t1'] } }, + ); + const cb = await result; + const tmpCols = [ + { name: 'kept', inheritedid: null }, + { name: 'inherited', inheritedid: 140391 }, + ]; + const delta = cb({ columns: tmpCols }); + expect(delta.columns).toEqual([{ name: 'kept', inheritedid: null }]); + // tmpstate.columns must be intact (we used to splice it). + expect(tmpCols).toHaveLength(2); + expect(tmpCols[1]).toEqual({ name: 'inherited', inheritedid: 140391 }); + }); +}); diff --git a/web/regression/javascript/schema_ui_files/table.ui.spec.js b/web/regression/javascript/schema_ui_files/table.ui.spec.js index 51158783a4b..ad45c3f4992 100644 --- a/web/regression/javascript/schema_ui_files/table.ui.spec.js +++ b/web/regression/javascript/schema_ui_files/table.ui.spec.js @@ -168,17 +168,16 @@ describe('TableSchema', () => { }); }); - it('empty', (done)=>{ + it('empty', ()=>{ + // typname unchanged is now an opt-out from the deferred queue. + // Previously it returned a Promise wrapping a no-op callback. let state = {typname: null}; - let deferredPromise = deferredDepChange(state, null, null, { + let result = deferredDepChange(state, null, null, { oldState: { typname: null, }, }); - deferredPromise.then((depChange)=>{ - expect(depChange()).toBeUndefined(); - done(); - }); + expect(result).toBeUndefined(); }); }); diff --git a/web/regression/javascript/setup-jest.js b/web/regression/javascript/setup-jest.js index ee2825e48d9..895a86cd4e2 100644 --- a/web/regression/javascript/setup-jest.js +++ b/web/regression/javascript/setup-jest.js @@ -1,6 +1,13 @@ import '@testing-library/jest-dom'; const { TextEncoder, TextDecoder } = require('util'); +// Enable the build-time canary gate in test environments. The +// production wrapper in registry.js reads `process.env.__CANARY_BUILD__`; +// in canary builds webpack's DefinePlugin substitutes a literal `true`, +// in production builds it substitutes `false` (and DCE removes the +// import). Tests don't go through webpack, so set it directly here. +process.env.__CANARY_BUILD__ = 'true'; + class BroadcastChannelMock { onmessage() {/* mock */} postMessage(data) { @@ -10,8 +17,36 @@ class BroadcastChannelMock { global.BroadcastChannel = BroadcastChannelMock; +// ESLint 9's flat-config schema uses `structuredClone` to deep-copy +// rule option objects in RuleTester. Node's structuredClone lives on +// the Node globalThis, but jest's jsdom env wraps tests in its own +// global with no copy of it. Patch the test global so RuleTester +// specs (regression/javascript/eslint-rules/) work without forcing a +// node test env that would break the rest of setup-jest. +if (typeof global.structuredClone !== 'function') { + global.structuredClone = (value) => JSON.parse(JSON.stringify(value)); +} + global.__webpack_public_path__ = ''; +// AMD-style `define()` calls slip into a handful of pgAdmin browser +// modules (role.js registers itself with `define('pgadmin.node.role', +// [...], cb)`). Jest's CommonJS-ish loader doesn't provide it, so any +// import chain that touches these modules ReferenceErrors out — the +// registered_schemas_audit harness hits this on roleReassign.js. A +// no-op stub is enough: the audit doesn't care about side effects of +// the registration, only that the module can be imported. +global.define = (...args) => { + // AMD signatures: define(factory), define(deps, factory), + // define(name, factory), define(name, deps, factory). The factory is + // always the last arg. + const factory = args[args.length - 1]; + if (typeof factory === 'function') { + try { factory(); } catch { /* swallow registration errors */ } + } +}; +global.define.amd = false; // some modules check amd capability + global.matchMedia = (query)=>({ matches: false, media: query, @@ -41,12 +76,34 @@ global.beforeAll(() => { jest.spyOn(console, 'error'); }); +// Reset the audit harness's variant-rotation counter between +// tests so dispatch ordering is reproducible within a Jest worker. +// +// We don't require() the audit harness here — that would pay the +// import cost on every Jest worker (including the ~80% that don't +// touch SchemaView) AND require()ing from inside beforeEach trips +// the zustand-mock's top-level afterEach() registration. Instead, +// resolve the module path once (no load), then check the require +// cache in each beforeEach. If a SchemaView spec earlier loaded the +// audit harness, its cached exports include _resetMutationCounter; +// non-SchemaView workers see an empty cache hit and skip. +const _auditHarnessPath = require.resolve( + '../../pgadmin/static/js/SchemaView/SchemaState/audit_harness', +); +let _resetAuditMutationCounter = null; + global.beforeEach(() => { console.error.mockClear(); + if (!_resetAuditMutationCounter) { + const cached = require.cache[_auditHarnessPath]; + if (cached && typeof cached.exports._resetMutationCounter === 'function') { + _resetAuditMutationCounter = cached.exports._resetMutationCounter; + } + } + if (_resetAuditMutationCounter) _resetAuditMutationCounter(); }); global.afterEach(() => { - // eslint-disable-next-line no-undef expect(console.error).not.toHaveBeenCalled(); }); diff --git a/web/regression/perf-bench/.gitignore b/web/regression/perf-bench/.gitignore new file mode 100644 index 00000000000..00139579c9a --- /dev/null +++ b/web/regression/perf-bench/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +results/ +shots/ +traces/ +test-results/ +playwright-report/ diff --git a/web/regression/perf-bench/README-visual-regression.md b/web/regression/perf-bench/README-visual-regression.md new file mode 100644 index 00000000000..c11ec6474dd --- /dev/null +++ b/web/regression/perf-bench/README-visual-regression.md @@ -0,0 +1,184 @@ +# Visual regression smoke for SchemaView dialogs + +`audit-visual-regression.spec.js` snapshots 5 high-impact dialogs and diffs +against committed baselines on subsequent runs. The walker canary catches +**logic** divergences; this catches **rendering** divergences the canary +can't see (CSS regressions, layout shifts, missing visual states). + +## How Playwright snapshot diffing works + +`expect(...).toHaveScreenshot('name.png', ...)`: + +- **First run** (no baseline): captures the PNG into + `audit-visual-regression.spec.js-snapshots/` (only with `--update-snapshots`). +- **Subsequent runs**: pixel-diffs against the baseline; fails on drift + beyond the configured `threshold` + `maxDiffPixels`. + +Sensitivity knobs live in `SCREENSHOT_OPTS` at the top of the spec. + +## Important environment caveats + +Baselines are **not portable across environments**. Re-capture whenever +any of these change: + +| Variable | Why it matters | +|---|---| +| **OS** (darwin / linux / windows) | Sub-pixel font hinting + anti-aliasing differ. macOS baselines guaranteed-fail on Linux. | +| **Browser version** | Chrome rendering shifts sub-pixel positions between major versions. Pin the Playwright `chromium` install. | +| **PG server version** | Some dialogs (Type, Tablespace) show different fields by server version. | + +The committed baselines were captured on **darwin**. A `test.beforeEach` +hook in the spec auto-skips the visual specs on non-darwin so a fresh +checkout doesn't unconditionally fail in CI. Remove the skip after +capturing platform-specific baselines. + +## Workflow: capture baselines on the PR branch + +Capture baselines **on the PR branch you're validating**, not on master. +The spec file itself is new, so it doesn't exist on master — there's no +baseline-on-master workflow that doesn't require porting the spec first +(which loses the point). + +```bash +# 1. Start pgAdmin with a canary build (so the walker canary runs). +cd web +CANARY_BUILD=true NODE_ENV=production \ + ./node_modules/.bin/webpack --config webpack.config.js +python3 pgAdmin4.py & # or "${PYTHON:-python3}" if running from a venv +sleep 6 + +# 2. Capture baselines (writes PNGs). +cd regression/perf-bench +PGADMIN_URL=http://127.0.0.1:5050/browser/ \ + ./node_modules/.bin/playwright test audit-visual-regression \ + --update-snapshots --workers=1 + +# 3. Commit the snapshot dir. +git add audit-visual-regression.spec.js-snapshots/ +git commit -m "test(perf-bench): visual regression baselines" +``` + +## Workflow: diff against baselines + +Run without `--update-snapshots`. Any pixel diff beyond +`threshold`/`maxDiffPixels` fails the test. + +```bash +# Restart pgAdmin with current branch's code. +pkill -f pgAdmin4.py 2>/dev/null || true +cd web && CANARY_BUILD=true NODE_ENV=production \ + ./node_modules/.bin/webpack --config webpack.config.js +python3 pgAdmin4.py & # or "${PYTHON:-python3}" if running from a venv +sleep 6 + +# Run the diff. +cd regression/perf-bench +PGADMIN_URL=http://127.0.0.1:5050/browser/ \ + ./node_modules/.bin/playwright test audit-visual-regression --workers=1 +``` + +On failure, Playwright writes three PNGs under `test-results//`: + +- `-expected.png` (committed baseline) +- `-actual.png` (current run) +- `-diff.png` (pixel-overlay of the difference) + +## Validating a PR doesn't regress vs. master visually + +The robust pattern: + +1. Capture baselines on the **merge-base** (master at the time of branch). +2. Re-run on the PR branch's HEAD. +3. Any diff = the PR introduced a visual change. + +Concretely, with a one-time port of the spec to a temp branch off master: + +```bash +# Set up a temp branch off master that has the spec file but pre-PR code. +git fetch origin master +git checkout -b _vr-baseline origin/master +git checkout dev/your-PR -- web/regression/perf-bench/audit-visual-regression.spec.js \ + web/regression/perf-bench/audit-helpers.js +# Capture baselines on master's code with the PR's spec. +# ... (build + capture per above) +git add web/regression/perf-bench/audit-visual-regression.spec.js-snapshots/ +git commit -m "baseline capture on master" + +# Port the captured baselines onto the PR branch. +git checkout dev/your-PR +git checkout _vr-baseline -- web/regression/perf-bench/audit-visual-regression.spec.js-snapshots/ + +# Now run the diff on the PR's code. +# ... (rebuild + run per above) +``` + +This is fiddly because the spec is new to the PR. Once the spec lives +on master, future PRs use the simple capture-on-master → diff-on-PR +pattern with a plain `git cherry-pick` of the snapshot commit. + +## When a diff is intentional + +If a PR is supposed to change rendering (MUI bump, restyling), update +baselines: + +```bash +./node_modules/.bin/playwright test audit-visual-regression --update-snapshots +git add audit-visual-regression.spec.js-snapshots/ +git commit -m "test(perf-bench): update visual baselines for " +``` + +Reviewers can inspect the new baseline PNGs in the diff. + +## Dialog coverage + +20 visual specs, 1-to-1 with the smoke set's distinct dialogs (`audit-smoke.spec.js` + `audit-smoke-extended.spec.js`), minus Register Server (multi-step right-click flow, captured by smoke only). + +| Category | Specs | +|---|---| +| Schema-level (15) | Edit Table, Create Table, Create Function, Edit Function, Create View, Create MView, Create Sequence, Create Type, Create Domain, Create Procedure, Create Aggregate, Create Foreign Table, Create Collation, Create FTS Config, Create Trigger Function | +| Server-level (3) | Edit Role, Create Role, Create Tablespace | +| Sub-catalog (2) | Create Index (under table), Create Trigger (under table) | + +Highlights worth knowing about: + +- **Edit Table** — heaviest dialog; vacuum/columns/constraints/partition tabs all on one screen. +- **Create Type** — composite/enum/range/shell sub-schema routing (default composite shape rendered). +- **Edit Role** — server-level parent; privileges + membership grids; Name + Comments masked because first-role name is env-dependent. +- **Edit Function** — env-dependent; requires at least one function in `public` (Name masked). +- **Create Index** — `amname` deferredDepChange protocol; with-clause nested-fieldset. +- **Create Foreign Table** — deferredDepChange + Inherits dropdown (not opened in spec; mount-only). + +Edit-mode specs (Edit Table, Edit Function, Edit Role) require pre-existing objects: a regular table in `public`, at least one function in `public`, and any role under the server respectively. CI seed needs to provide these; on a vanilla local PG, all three usually exist if you've ever connected pgAdmin to a working database. + +## Things that AREN'T covered (intentional) + +- **SQL preview tab**: CodeMirror cursor + content vary subtly; masking + is brittle. Use the cross-tab specs in `dev/table-dialog-tests` to + verify SQL generation textually, not via visual diff. +- **Animation states**: dialogs animate in. Snapshots wait for settle. +- **Hover / focus / tooltip states**: state-dependent, not visual-baseline + worthy. +- **Every dialog × every tab**: would balloon baselines to 100+. Five + carefully-chosen dialogs catch the rendering paths that matter. + +## Limitations to migrate away from + +1. **Single-platform baselines**: see the `test.beforeEach` skip in the + spec. Long-term fix is `snapshotPathTemplate` in `playwright.config.js` + keyed by `{platform}` so each OS has its own directory. +2. **Capture is manual**: ideally CI does the capture-on-master step and + commits the baselines back. Today it's developer-driven. +3. **Tests still hit a real DB**: each spec opens a pgAdmin session, + which opens PostgreSQL connections. Sequential back-to-back runs can + exhaust PG's `max_connections` (defaults to 100). Mitigation: + `pkill -f pgAdmin4.py` between recording and verifying baselines, or + between batches if running all specs in sequence. A mock-harness + pivot was investigated and parked — naive HAR replay captures the + full bundle (~17 MB/spec), and a custom recorder + URL-normalizer + layer (proven to capture/replay individual REST endpoints) couldn't + reproduce the JS tree's expansion state from raw REST responses + alone. The tree is stateful in ways event-stream replay can't + reconstruct from response bytes. Future paths: state-aware mock + proxy (~2-3 days) or static-mount harness that bypasses the tree + entirely (~4-6 hours, parallel coverage track rather than + replacement). diff --git a/web/regression/perf-bench/README.md b/web/regression/perf-bench/README.md new file mode 100644 index 00000000000..29170696972 --- /dev/null +++ b/web/regression/perf-bench/README.md @@ -0,0 +1,257 @@ +# SchemaView / DataGridView performance benchmark + +A Playwright-driven benchmark that measures per-keystroke and per-action costs +inside pgAdmin's `SchemaView` and `DataGridView`. Couples with the +`__PERF_SCHEMA__` instrumentation baked into the SchemaView code so we can see +exactly where time goes when a heavy dialog feels slow. + +## What's instrumented + +All instrumentation is gated by `window.__PERF_SCHEMA__`. When the flag is +false (default), each wrapper does a single boolean check and falls through — +no measurable overhead in normal use. + +The hot paths covered, from `web/pgadmin/static/js/SchemaView/`: + +| Where | Metric label | What it counts | +|---|---|---| +| `SchemaState.js` | `SchemaState.validate` | full validate cycle per keystroke | +| | `SchemaState.validate.setError` | per-error setError cost | +| | `SchemaState.validate.setErrorCalls` *(counter)* | number of error paths set per validate | +| | `SchemaState.validate.clearError` | the no-error setError({}) call | +| | `SchemaState.validate.dataAssign` | `state.data = sessData` (dataStore write) | +| | `SchemaState.validate.onDataChange` | user-supplied `onDataChange` callback | +| | `SchemaState.updateOptions` | re-evaluate all field options | +| | `SchemaState.updateOptions.cloneDeep` | clone of the option tree | +| | `SchemaState.changes` | dirty-diff | +| `SchemaState/common.js` | `validateSchema` | recursive schema-wide validate walk (outermost only) | +| | `getSchemaDataDiff` | dirty-diff helper | +| `options/registry.js` | `schemaOptionsEvalulator` | per-field option evaluation walk (outermost only) | +| `SchemaState/reducer.js` | `reducer.` | per-action total (`set_value`, `add_row`, `delete_row`, …) | +| | `reducer.cloneDeep` | the top-level `_.cloneDeep(state)` | +| `SchemaState/store.js` | `store..setState` | total time inside a setState | +| | `store..topEqualityCheck` | upfront `isValueEqual(state, prevState)` | +| | `store..subscribers` *(sample)* | path-subscriber count at notification time | +| | `store..fanout` *(sample)* | listeners that actually fired | + +Action log (`__perfSnapshot().actions`) records the last 500 reducer actions +with their wallclock cost and dispatched path. + +## What's bundled — synthetic fixture + +`bench-fixture.js` exposes `window.__mountBenchFixture(outerRows, innerRows)` +which opens a dialog with three nested layers: + +``` +SchemaView → DataGridView (outer collection: columns) + → SchemaView (per-row column form) + → DataGridView (inner collection: indexes) +``` + +It uses pgAdmin's existing `pgadmin:utility:show` event so the dialog runs +inside the real provider tree (theme, PgAdmin context, docker) — same code +paths a real Table dialog hits. Outer rows look like Postgres columns +(`col_`, `text`, NOT NULL switch); inner rows look like indexes +(`idx__`, expression, unique switch). + +## Console / interactive usage + +In pgAdmin's DevTools console, with a heavy dialog open: + +```js +window.__PERF_SCHEMA__ = true // turn on instrumentation +window.__perfReset() // clear counters +// ... interact (type, click, add rows) ... +window.__perfDump() // console.table summary +window.__perfSnapshot() // raw object {stats, counts, actions} +``` + +To stress test with the synthetic fixture (no Postgres needed): + +```js +window.__mountBenchFixture(1000, 3) // 1000 columns × 3 indexes each +``` + +## Automated benchmark — Playwright + +This directory is a standalone Node project. Install once: + +``` +cd web/regression/perf-bench +npm install +npx playwright install chromium +``` + +Then run with pgAdmin already running locally at `http://127.0.0.1:5050`: + +``` +# Real-dialog scenario: Register Server > Parameters +N_ROWS=100 M_CHARS=15 npx playwright test datagridview.spec.js --reporter=line + +# Heavy synthetic fixture +OUTER=500 INNER=3 M_CHARS=15 npx playwright test nested.spec.js --reporter=line +``` + +Tunables: + +| Var | Default | Meaning | +|---|---|---| +| `PGADMIN_URL` | `http://127.0.0.1:5050/browser/` | where pgAdmin is reachable | +| `N_ROWS` | 100 | rows added in `datagridview.spec.js` | +| `M_CHARS` | 20 (datagridview) / 15 (nested) | characters typed per keystroke test | +| `OUTER` | 1000 | outer rows for `nested.spec.js` | +| `INNER` | 3 | inner rows per outer | +| `INCREMENTAL` | `0` | when `1`, sets `window.__INCREMENTAL_OPTIONS__=true` to enable the prototype incremental `schemaOptionsEvalulator` walk (skips collection-row option re-eval when the row's path doesn't overlap the changed path). | + +### Known limitation of incremental mode + +The walker also unions in DepListener dest paths whose source overlaps +the change, so cross-row deps that are *declared* via `field.deps` +remain correct. What stays broken: a field whose `visible` / `disabled` +/ `readonly` / `editable` evaluator reads data from a SIBLING row +(e.g. `column[5].name` flips a flag on `column[2]`) WITHOUT declaring +the sibling sources in `field.deps`. That sibling row's path won't +appear in the must-visit set and its options will go stale. + +The synthetic bench fixture has no undeclared cross-row deps so it's +safe to measure here. Before turning incremental on for a production +dialog, audit its schema for closures that read outside their own +row+ancestors and declare those sources via `field.deps`. + +Outputs (gitignored): + +- `results/` — JSON snapshots from `__perfSnapshot()` at each test phase. +- `shots/` — screenshots at key steps. +- `traces/` — Playwright traces (`.zip`). Open via + `npx playwright show-trace traces/.zip`. +- `test-results/` — Playwright's own per-test artifacts on failure. + +## Interpreting the output + +Each spec prints `STATS (ms)` and `COUNTERS` blocks at the end. Read them as: + +- `count` — how many times this code path ran during the measured window. +- `total_ms` — summed wall time inside the wrapper. +- `avg_ms`, `max_ms` — per-call averages. +- `subscribers` / `fanout` are recorded as samples per `setState`; treat the + `total` field there as a sum of counts, not milliseconds. + +For per-keystroke analysis, **subtract the 30 ms idle wait** between +keystrokes from the wall-clock number to get real work time, then compare +that against the sum of measured functions to see what fraction is "in the +instrumented zone" vs "elsewhere" (React reconcile, MUI, react-table, paint). + +Anchor numbers on this machine (headless Chromium, Apple Silicon): + +| Scenario | Per keystroke (wall) | `SchemaState.validate` | `schemaOptionsEvalulator` | +|---|---:|---:|---:| +| Register Server > Parameters @ 102 rows | 59 ms | 3.85 ms | 1.65 ms | +| Synthetic fixture @ 100 × 3 | ~60 ms | 10.18 ms | 4.57 ms | +| Synthetic fixture @ 500 × 3 | 155–180 ms | 48.41 ms | 21.67 ms | + +Everything in `SchemaState.validate` scales linearly with collection size. + +## Audit smoke (`audit-smoke.spec.js`) + +Real-browser companion to the Jest-driven `registered_schemas_audit.spec.js`. +Jest covers 65 of 86 schemas via synthetic instantiation; the remaining 13 +have bespoke constructor quirks (LanguageSchema dereferences +`node_info['node_info'].user.name` with no guard, etc.) that work fine in a +real browser where pgAdmin provides full production wiring. + +The smoke sets `window.__INCREMENTAL_AUDIT__ = true` and +`__throw_on_canary_divergence__ = true`, then drives three dialogs: + +- **Register Server** — ServerSchema + VariableSchema (Parameters tab via + DataGridView). Self-contained: no existing server needed, opens the + Register dialog and exercises it without saving. **Verified passing + on the local dev setup (no divergences detected).** +- **Create Table** — TableSchema + ColumnSchema + constraint schemas + + partitions. Needs a connected server in the tree. +- **Create Function** — FunctionSchema + Arguments / Parameters + collections. Needs a connected server in the tree. + +Any divergence between the incremental walker and the full walk surfaces as +a `pageerror` event, which the spec collects and asserts is empty. + +### Connected-server prerequisites for Table / Function + +The Table and Function tests use `ensureServerRegistered` to find a +connected server. It looks for `PG18` first (override via +`PGADMIN_SERVER_NAME`); if not found, falls back to the first +directory under "Servers". Double-clicks the node to trigger connect +and auto-fills the password prompt with `$PGPASSWORD` (default `edb`). + +For the connect step to succeed reliably: + +1. Set BOTH `MASTER_PASSWORD_REQUIRED = False` AND + `USE_OS_SECRET_STORAGE = False` in `config_local.py` to avoid + the macOS keychain prompt + the "Unlock Saved Passwords" flow. +2. Use a fresh `DATA_DIR` (e.g. `~/.pgadmin/audit-smoke`) so + pre-existing saved-password state from the user's main pgAdmin + instance doesn't leak in. +3. Pre-register the server directly in the SQLite DB to skip the + Register Server dialog flow (it requires several inline-validated + fields to enable Save; auto-driving it is brittle). + +Connection password defaults to `edb`; override via `PGPASSWORD`. + +### Verified state + +- **Register Server smoke**: PASSING (4.5s, zero canary divergences + on the local dev setup). This exercises ServerSchema + + VariableSchema fully and is the highest-value smoke. +- **Create Table / Create Function smoke**: spec is wired with + correct dialog selectors, but the tree-navigation step from + the server node down to `public > Tables` / `public > Functions` + is brittle on this codebase. react-aspen tree virtualization + and inconsistent expand-on-click vs expand-on-dblclick behavior + across tree levels makes selector-driven navigation unreliable + (see memory note `project-real-table-bench-tree-nav`). The Jest + audit harness already covers TableSchema (incl. the Partition + fields fix) and FunctionSchema-derived classes; UI smoke for + these is a coverage-extender, not a production blocker. + +A robust path forward for Table/Function smoke would be: +- Use pgAdmin's "Search Objects" feature to navigate (skips tree + expansion entirely), or +- Use direct backend API calls to open dialogs (no DOM nav needed). + +### Run + +```bash +# 1. Build with the canary kept in the bundle (default build tree-shakes it): +cd web && CANARY_BUILD=true yarn run bundle + +# 2. Start pgAdmin (web server or desktop runtime, your choice). + +# 3. Run the smoke from this dir: +cd web/regression/perf-bench +PGADMIN_URL=http://127.0.0.1:5050/browser/ yarn run playwright test audit-smoke +``` + +The spec passes vacuously if `CANARY_BUILD` wasn't set (the canary is then +tree-shaken and the flags are no-ops). To verify the canary actually loaded, +check `await page.evaluate(() => typeof window.__INCREMENTAL_AUDIT__)` +returns `'boolean'` — the spec asserts this. + +## Files + +| Path | Purpose | +|---|---| +| `package.json` / `package-lock.json` | Standalone Playwright project | +| `playwright.config.js` | Headless, single worker, 3-min default test timeout | +| `datagridview.spec.js` | Real-dialog benchmark via Register Server > Parameters | +| `nested.spec.js` | Synthetic 3-layer benchmark via `__mountBenchFixture` | +| `audit-smoke.spec.js` | Real-browser smoke for the incremental walker + canary (3 dialogs) | +| `audit-helpers.js` | Shared helpers for audit-smoke specs | +| `verify-canary-tree-shake.sh` | Production-bundle smoke for canary DCE | + +Companion source files (in pgAdmin proper): + +| Path | Purpose | +|---|---| +| `web/pgadmin/static/js/SchemaView/perf.js` | Instrumentation helpers + global hooks | +| `web/pgadmin/static/js/SchemaView/bench-fixture.js` | Synthetic nested schema + `window.__mountBenchFixture` | +| `web/pgadmin/static/js/SchemaView/SchemaState/{SchemaState,reducer,store,common}.js` | `measure`/`record`/`count` call sites | +| `web/pgadmin/static/js/SchemaView/options/registry.js` | `measure` around the top-level `schemaOptionsEvalulator` | diff --git a/web/regression/perf-bench/audit-helpers.js b/web/regression/perf-bench/audit-helpers.js new file mode 100644 index 00000000000..cc6161fb00a --- /dev/null +++ b/web/regression/perf-bench/audit-helpers.js @@ -0,0 +1,715 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2026, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +// Shared helpers for the audit-smoke Playwright specs. Keeps every +// spec's setup boilerplate identical so a divergence flagged by one +// dialog can be triaged the same way as the others. + +import { expect } from '@playwright/test'; + +// Debug instrumentation. Set AUDIT_DEBUG=1 to surface phase markers + +// timing for every spec — useful when a sequential flake-check run +// produces an intermittent failure and we need to see WHICH phase +// stalled (page boot vs server connect vs tree navigation vs dialog +// open vs interaction vs close). Silent by default so production +// CI runs stay clean. +const _debugEnabled = () => process.env.AUDIT_DEBUG === '1'; +const _t0 = Date.now(); +const _msSince = () => (Date.now() - _t0).toString().padStart(6, ' '); +export const auditDebug = (tag, detail = '') => { + if (!_debugEnabled()) return; + // eslint-disable-next-line no-console + console.log(`[audit-debug ${_msSince()}ms] ${tag}${detail ? ': ' + detail : ''}`); +}; + +// Tear down a spec's server connection by hitting pgAdmin's REST +// disconnect endpoint (`DELETE /browser/server/connect/{gid}/{sid}`). +// Without this, each spec leaves the PG connection pool warm — +// sequential runs across 30 specs × N iterations exhaust PG's +// max_connections (defaults to 100) and subsequent fetches return +// empty children lists, surfacing as the "no child of type X found" +// flake the smoke specs hit intermittently. Call from +// `test.afterEach` so cleanup runs even when the spec fails. +// +// Best-effort: silently skips if no connected server is in the tree +// (e.g., spec failed before ensureServerRegistered fired). Errors +// inside the fetch are swallowed — disconnect is opportunistic and +// the next spec's ensureServerRegistered will reconnect. +export const disconnectServerViaApi = async (page) => { + try { + await page.evaluate(async () => { + const tree = window.pgAdmin?.Browser?.tree; + if (!tree?.tree?.getModel) return; + const itemData = (it) => ( + typeof it.getMetadata === 'function' + ? it.getMetadata('data') + : (it.data || it._metadata?.data || null) + ); + const root = tree.tree.getModel().root; + const sg = (root.children || []).find( + (c) => itemData(c)?._type === 'server_group' + ); + if (!sg) return; + const servers = (sg.children || []).filter((c) => { + const d = itemData(c); + return d?._type === 'server' && !!d?.connected; + }); + // CSRF: pgAdmin requires X-pgA-CSRFToken on non-GET requests. + // The token is exposed on window.pgAdmin (set by the page + // template at boot). Without it, the DELETE silently 400s and + // the server stays connected. + const headers = {}; + const csrfName = window.pgAdmin?.csrf_token_header; + const csrfTok = window.pgAdmin?.csrf_token; + if (csrfName && csrfTok) headers[csrfName] = csrfTok; + for (const sv of servers) { + const sd = itemData(sv); + if (!sd) continue; + const gid = sd._pid; + const sid = sd._id; + try { + await fetch( + `/browser/server/connect/${gid}/${sid}`, + { method: 'DELETE', credentials: 'same-origin', headers } + ); + } catch { /* swallow */ } + } + }); + } catch { /* page may already be torn down — fine */ } +}; + +// Records every browser-side error so a divergence (thrown by the +// canary's defaultReport under the audit flags) is collected and +// asserted on at the end of the test. +// +// Idempotent across calls on the same page. Each Playwright test gets +// a fresh browser context so this normally fires once per test, but +// the guard means callers can re-invoke during a long test (e.g. to +// reset accumulated noise before a specific assertion) without +// accumulating duplicate listeners. +export const installErrorRecorders = (page) => { + if (page.__errorRecorder) { + page.__errorRecorder.length = 0; + return page.__errorRecorder; + } + const errors = []; + page.__errorRecorder = errors; + page.on('pageerror', (err) => { + errors.push({ kind: 'pageerror', message: err.message }); + }); + page.on('console', (msg) => { + if (msg.type() === 'error') { + errors.push({ kind: 'console.error', message: msg.text() }); + } + }); + return errors; +}; + +// Enables the canary's throw-on-divergence path. Without +// CANARY_BUILD=true at bundle time the canary is tree-shaken and +// these flags are no-ops — the smoke passes vacuously. The +// `__INCREMENTAL_AUDIT__` flag is also asserted later to confirm +// the page didn't reload mid-test. +export const enableAudit = async (page) => { + await page.evaluate(() => { + window.__INCREMENTAL_AUDIT__ = true; + window.__throw_on_canary_divergence__ = true; + window.__incremental_canary_max_per_session__ = Number.POSITIVE_INFINITY; + }); +}; + +// Auto-dismiss the "Unlock Saved Passwords" modal pgAdmin re-shows +// on any server-touching action. Matches the pattern in +// datagridview.spec.js so the new specs play nicely with it. +export const autoDismissUnlockModal = async (page) => { + await page.addLocatorHandler( + page.locator('div[role="dialog"]', { hasText: 'Unlock Saved Passwords' }), + async (dlg) => { + const candidates = [ + dlg.getByRole('button', { name: 'Cancel' }), + dlg.locator('button:has-text("Cancel")'), + ]; + for (const c of candidates) { + try { await c.click({ timeout: 1_000 }); return; } + catch { /* try next */ } + } + await page.keyboard.press('Escape'); + }, + { times: 30, noWaitAfter: true } + ); +}; + +// Assert no canary divergence surfaced during the test. Real test +// failures (selectors missing, etc.) will fail earlier — this is +// specifically for catching incremental-walker bugs the audit +// harness didn't synthesize. +export const expectNoDivergence = (errors) => { + const divergences = errors.filter( + (e) => /(Incremental walker divergence|Incremental validator divergence)/ + .test(e.message) + ); + if (divergences.length > 0) { + + console.error('CANARY DIVERGENCES DETECTED:'); + for (const d of divergences) { + + console.error(` [${d.kind}] ${d.message}`); + } + } + expect(divergences).toEqual([]); +}; + +// pgAdmin's tree uses class-based selectors (no ARIA roles). +// Directory entries are `.file-entry.directory`; leaf entries are +// `.file-entry`. Context menus come from szh-menu — items use +// `.szh-menu__item` with the visible label as the text. +// +// These helpers mirror the patterns in datagridview.spec.js, which +// is the working reference for tree + dialog interaction on this +// codebase. + +// Ensure a server tree node exists and is connected. Strategy: +// +// 1. If the desired server name (env PGADMIN_SERVER_NAME, default +// 'PG18') is already in the tree, just click it to connect. +// 2. Otherwise pick the FIRST `.file-entry` under Servers as a +// fallback — most local pgAdmin installs have a development +// server already registered. +// +// The connect prompt for the saved password is auto-filled. +// Registration via the dialog flow was tried but is too brittle: +// the Save button stays disabled until every field passes inline +// validation and figuring out which field is missing in a real +// browser is harder than just reusing whatever's already there. +export const ensureServerRegistered = async (page, opts = {}) => { + auditDebug('ensureServerRegistered: start'); + const preferredName = opts.name + || process.env.PGADMIN_SERVER_NAME || 'PG18'; + const password = opts.password || process.env.PGPASSWORD || 'edb'; + + // Always expand Servers first — its children aren't in the DOM + // until the parent is open. pgAdmin's tree wants a double-click + // to expand a directory; a single click only selects it. + const serversNode = page.locator( + '.file-entry.directory', { hasText: /^Servers$/ } + ).first(); + await serversNode.dblclick(); + await page.waitForTimeout(2_000); + auditDebug('ensureServerRegistered: Servers expanded'); + + // Look for the preferred name. If absent, pick whatever's visible + // (most local installs have a development server pre-registered). + let name = preferredName; + let node = page.locator( + '.file-entry.directory', { hasText: preferredName } + ).first(); + if (!(await node.count())) { + const candidates = page.locator('.file-entry.directory'); + const total = await candidates.count(); + for (let i = 0; i < total; i++) { + const txt = (await candidates.nth(i).textContent() || '').trim(); + if (txt && txt !== 'Servers') { name = txt; break; } + } + node = page.locator( + '.file-entry.directory', { hasText: name } + ).first(); + } + + // Connect: dblclick the server node to trigger Connect. With no + // saved password, pgAdmin shows the "Connect to Server" modal + // asking for the user's password. Auto-fill it. + await node.dblclick({ force: true }); + // Wait for either the Connect prompt or the Databases child to + // appear. Whichever comes first wins. + const connectPrompt = page.locator( + 'div[role="dialog"]', { hasText: 'Connect to Server' } + ).first(); + try { + await connectPrompt.waitFor({ state: 'visible', timeout: 8_000 }); + const pw = connectPrompt.locator('input[type="password"]').first(); + await pw.fill(password); + await connectPrompt.locator('button:has-text("OK")').first().click( + { force: true } + ); + } catch { + // No connect prompt — pgAdmin used a cached connection or saved + // password worked. Either way, we proceed and wait for Databases. + } + // Wait until the server's "Databases" child appears (signals + // connected). pgAdmin renders the row count as a sibling so the + // file-entry's text is e.g. "Databases (1)" — don't anchor. + await page.locator( + '.file-entry.directory', { hasText: 'Databases' } + ).first().waitFor({ state: 'visible', timeout: 30_000 }); + auditDebug('ensureServerRegistered: complete', name); + return name; +}; + +// Navigate to a catalog node (coll-table / coll-function / etc.) by +// driving pgAdmin's JS tree API directly via page.evaluate. This +// completely bypasses DOM-based tree expansion (which is brittle +// against react-aspen virtualization + inconsistent click semantics +// per tree level — see project-real-table-bench-tree-nav memory). +// +// Returns the tree-node descriptor (a string id that can be passed +// to openCreateDialogViaApi). +export const navigateToCatalogNodeViaApi = async (page, catalog, database) => { + auditDebug('navigateToCatalogNodeViaApi: start', catalog); + const _nav_start = Date.now(); + const db = database || process.env.PGDATABASE || 'postgres'; + // pgAdmin's tree types follow a `coll-X` / `X` pattern: the + // collection (Tables, Functions, etc.) is `coll-table`; individual + // items are `table`. For navigating to the CATEGORY, we want the + // collection type. + // pgAdmin's tree types are SINGULAR (`coll-table` not + // `coll-tables`) and a few are abbreviated (`coll-mview` for + // Materialized Views, `coll-foreign_table` for Foreign Tables). + // The label-based default fallback only works for labels that + // ARE just `coll-`; add explicit mappings for + // the others. + const targetType = ({ + Tables: 'coll-table', + Functions: 'coll-function', + Views: 'coll-view', + 'Materialized Views': 'coll-mview', + Sequences: 'coll-sequence', + Types: 'coll-type', + Domains: 'coll-domain', + Procedures: 'coll-procedure', + Aggregates: 'coll-aggregate', + 'Foreign Tables': 'coll-foreign_table', + Collations: 'coll-collation', + 'FTS Configurations': 'coll-fts_configuration', + 'Trigger Functions': 'coll-trigger_function', + })[catalog] || `coll-${catalog.toLowerCase()}`; + + // Walk the aspen tree (the actual virtualized tree, accessible via + // `tree.tree.getModel().root`). Each tree level is an aspen + // Directory with `.children` and `getMetadata('data')` returning + // the pgAdmin node data. tree.open() expects an aspen FileEntry. + await page.evaluate(async ({ targetType, db }) => { + const tree = window.pgAdmin.Browser.tree; + const wait = (ms) => new Promise((r) => setTimeout(r, ms)); + + const itemData = (it) => { + // aspen FileEntry stores metadata via getMetadata; ManageTreeNodes + // TreeNode stores it on `.data` or `.metadata.data`. Try both. + if (typeof it.getMetadata === 'function') { + return it.getMetadata('data'); + } + return it.data || it._metadata?.data || null; + }; + + const childByPredicate = (node, pred) => { + for (const c of node.children || []) { + if (pred(itemData(c), c)) return c; + } + return null; + }; + + const openAndFind = async (parent, pred, label) => { + await tree.open(parent); + for (let i = 0; i < 50; i++) { + const found = childByPredicate(parent, pred); + if (found) return found; + await wait(200); + } + throw new Error( + `navigate: ${label} not found; available: ` + + (parent.children || []).map((c) => { + const d = itemData(c); + return (d?._type || '?') + '/' + (d?.label || '?'); + }).join(', ') + ); + }; + + // Start from aspen's root (the actual rendered tree). + let node = tree.tree.getModel().root; + node = await openAndFind( + node, (d) => d?._type === 'server_group', 'server_group' + ); + node = await openAndFind( + node, (d) => d?._type === 'server', 'server' + ); + node = await openAndFind( + node, (d) => d?._type === 'coll-database', 'coll-database' + ); + node = await openAndFind( + node, (d) => d?._type === 'database' && d?.label === db, db + ); + node = await openAndFind( + node, (d) => d?._type === 'coll-schema', 'coll-schema' + ); + node = await openAndFind( + node, (d) => d?._type === 'schema' && d?.label === 'public', 'public' + ); + node = await openAndFind( + node, (d) => d?._type === targetType, targetType + ); + // Select the catalog node so menu actions target it. + await tree.select(node, true); + // Stash the just-selected node for openCreate/EditDialogViaApi. + // tree.selected() observably drifts back to a parent (typically + // the database) after a tree-refresh event triggered when the + // selected node's REST children land. The stash gives the open + // helpers an authoritative reference — same fix as the + // navigateToTableSubCollectionViaApi helper below. + window.__pgadminLastNavigatedNode = node; + }, { targetType, db }); + auditDebug( + 'navigateToCatalogNodeViaApi: complete', + `${catalog} in ${Date.now() - _nav_start}ms` + ); +}; + +// Navigate to a SERVER-level collection node (Login/Group Roles, +// Databases, EventTriggers, ForeignServers, Tablespaces, etc.). +// Stops at the server tier and opens the requested coll-X. +// +// `targetType` is the collection's _type as pgAdmin's tree +// registers it (e.g. 'coll-role', 'coll-database', 'coll-event_trigger'). +export const navigateToServerCollectionViaApi = async (page, targetType) => { + auditDebug('navigateToServerCollectionViaApi: start', targetType); + const _nav_start = Date.now(); + await page.evaluate(async ({ targetType }) => { + const tree = window.pgAdmin.Browser.tree; + const wait = (ms) => new Promise((r) => setTimeout(r, ms)); + const itemData = (it) => ( + typeof it.getMetadata === 'function' + ? it.getMetadata('data') + : (it.data || it._metadata?.data || null) + ); + const childByPredicate = (node, pred) => { + for (const c of node.children || []) { + if (pred(itemData(c), c)) return c; + } + return null; + }; + const openAndFind = async (parent, pred, label) => { + await tree.open(parent); + for (let i = 0; i < 50; i++) { + const found = childByPredicate(parent, pred); + if (found) return found; + await wait(200); + } + throw new Error( + `navigate: ${label} not found; available: ` + + (parent.children || []).map((c) => { + const d = itemData(c); + return (d?._type || '?') + '/' + (d?.label || '?'); + }).join(', ') + ); + }; + let node = tree.tree.getModel().root; + node = await openAndFind( + node, (d) => d?._type === 'server_group', 'server_group' + ); + node = await openAndFind( + node, (d) => d?._type === 'server', 'server' + ); + node = await openAndFind( + node, (d) => d?._type === targetType, targetType + ); + await tree.select(node, true); + window.__pgadminLastNavigatedNode = node; + }, { targetType }); + auditDebug( + 'navigateToServerCollectionViaApi: complete', + `${targetType} in ${Date.now() - _nav_start}ms` + ); +}; + +// Navigate to a SUB-CATALOG node nested under a specific Table +// (Triggers, Indexes, Rules, Compound Triggers, Foreign Keys, etc.). +// Picks the FIRST table under public schema and drills down to the +// requested sub-collection — same "first child of given type" pattern +// used by openEditDialogViaApi. +// +// `subCollectionType` is e.g. 'coll-trigger', 'coll-index', +// 'coll-compound_trigger'. +export const navigateToTableSubCollectionViaApi = async ( + page, subCollectionType, database +) => { + auditDebug('navigateToTableSubCollectionViaApi: start', subCollectionType); + const _nav_start = Date.now(); + const db = database || process.env.PGDATABASE || 'postgres'; + await page.evaluate(async ({ subCollectionType, db }) => { + const tree = window.pgAdmin.Browser.tree; + const wait = (ms) => new Promise((r) => setTimeout(r, ms)); + const itemData = (it) => ( + typeof it.getMetadata === 'function' + ? it.getMetadata('data') + : (it.data || it._metadata?.data || null) + ); + const childByPredicate = (node, pred) => { + for (const c of node.children || []) { + if (pred(itemData(c), c)) return c; + } + return null; + }; + const openAndFind = async (parent, pred, label) => { + await tree.open(parent); + for (let i = 0; i < 50; i++) { + const found = childByPredicate(parent, pred); + if (found) return found; + await wait(200); + } + throw new Error( + `navigate: ${label} not found; available: ` + + (parent.children || []).map((c) => { + const d = itemData(c); + return (d?._type || '?') + '/' + (d?.label || '?'); + }).join(', ') + ); + }; + let node = tree.tree.getModel().root; + node = await openAndFind( + node, (d) => d?._type === 'server_group', 'server_group' + ); + node = await openAndFind( + node, (d) => d?._type === 'server', 'server' + ); + node = await openAndFind( + node, (d) => d?._type === 'coll-database', 'coll-database' + ); + node = await openAndFind( + node, (d) => d?._type === 'database' && d?.label === db, db + ); + node = await openAndFind( + node, (d) => d?._type === 'coll-schema', 'coll-schema' + ); + node = await openAndFind( + node, (d) => d?._type === 'schema' && d?.label === 'public', 'public' + ); + node = await openAndFind( + node, (d) => d?._type === 'coll-table', 'coll-table' + ); + // Prefer the CI-seeded `audit_smoke_table` (created by the + // schemaview-ui-smoke workflow's seed step) because we know + // it has triggers + indexes seeded. Fall back to any table if + // the seed didn't run, but emit a clear error referencing the + // missing seed. + // + // Why we can't just pick "first": local environments often have + // pre-existing tables with names that sort alphabetically before + // audit_smoke_table — including, observed in the wild, an + // XSS-payload table name starting with `<`. The "first" then + // has no triggers or indexes and the Edit-mode specs fail + // confusingly. + node = await openAndFind( + node, + (d) => d?._type === 'table' && d?.label === 'audit_smoke_table', + 'audit_smoke_table (sub-collection smoke needs the CI seed ' + + 'table; see .github/workflows/run-schemaview-ui-smoke.yml ' + + 'seed step)' + ); + node = await openAndFind( + node, (d) => d?._type === subCollectionType, subCollectionType + ); + await tree.select(node, true); + // tree.open() short-circuits if the node was previously opened + // during the walk (very common for sub-collection nodes the + // openAndFind loop already opened to discover them). Force the + // children REST fetch via ensureLoaded() which goes straight to + // the aspen FileEntry's loader, bypassing the isOpen short- + // circuit. Then poll until children populate. + await tree.ensureLoaded(node); + for (let i = 0; i < 50; i++) { + if ((node.children || []).length > 0) break; + await wait(200); + } + // pgAdmin's tree.selected() observably slips back to a parent + // node (often the database) after this helper returns — likely + // due to a tree-refresh event triggered by the children fetch + // completing. openEditDialogViaApi can't rely on tree.selected() + // alone. Stash the just-selected node on window so the caller + // can read the authoritative reference. + window.__pgadminLastNavigatedNode = node; + window.__pgadminLastNavigatedChildCount = (node.children || []).length; + }, { subCollectionType, db }); + const childCount = await page.evaluate( + () => window.__pgadminLastNavigatedChildCount + ); + auditDebug( + 'navigateToTableSubCollectionViaApi: complete', + `${subCollectionType} in ${Date.now() - _nav_start}ms ` + + `(loaded ${childCount} children under sub-collection)` + ); +}; + +// Trigger a "Create > X" dialog programmatically by invoking the +// node module's show_obj_properties callback with action='create'. +// Skips right-click + szh-menu navigation entirely. +export const openCreateDialogViaApi = async (page, nodeType) => { + await page.evaluate((nodeType) => { + const tree = window.pgAdmin.Browser.tree; + // Prefer the just-navigated node stashed by navigateToXViaApi + // helpers. tree.selected() observably drifts back to a parent + // node after a tree-refresh event (typically the database node) + // — same root cause as the Edit-dialog flake. Without this + // fallback, openCreateDialogViaApi sometimes hands + // show_obj_properties a parent node, and the Create dialog + // mounts in a context that can't resolve dropdown lookups, + // failing to render the Name textbox. Surfaces as a 20s + // `wait Name textbox` timeout — the iter-8 flake signature. + const selected = window.__pgadminLastNavigatedNode || tree.selected(); + if (!selected) throw new Error('openCreateDialogViaApi: no node selected'); + const nodeModule = window.pgAdmin.Browser.Nodes[nodeType]; + if (!nodeModule) throw new Error( + `openCreateDialogViaApi: no node module for "${nodeType}"` + ); + nodeModule.callbacks.show_obj_properties.call( + nodeModule, { action: 'create' }, selected + ); + }, nodeType); +}; + +// Pick the first child of the currently-selected collection node +// (e.g. "Tables" -> first table) and open its Properties dialog +// via the show_obj_properties callback with action='edit'. +// +// Edit-mode dialogs follow a different code path from create: +// - initialise(force=true) fetches the existing record via REST +// - sessData lands with the persisted values + an idAttribute oid +// - isNew(state) returns false → closures take the edit branches +// This exercises the half of every schema that create-mode never +// touches. +// +// Returns the picked child's label so the caller can identify what +// got opened in test output. +export const openEditDialogViaApi = async (page, nodeType) => { + return await page.evaluate(async (nodeType) => { + const tree = window.pgAdmin.Browser.tree; + // Prefer the just-navigated node stashed by navigateToXViaApi + // helpers. tree.selected() drifts back to parent nodes after a + // tree-refresh event (observed when sub-collection REST fetches + // complete after the helper returns), so the stashed reference + // is the authoritative parent the user just navigated to. + const selected = window.__pgadminLastNavigatedNode || tree.selected(); + if (!selected) throw new Error('openEditDialogViaApi: no node selected'); + const nodeModule = window.pgAdmin.Browser.Nodes[nodeType]; + if (!nodeModule) throw new Error( + `openEditDialogViaApi: no node module for "${nodeType}"` + ); + + // Expand the collection node so its children populate from REST. + // tree.open() is idempotent for already-expanded directories. + await tree.open(selected); + + // Find a child whose _type matches the target nodeType. Tree + // children are FileEntry instances; getMetadata('data') returns + // the node's data including _type. + const aspen = tree.tree.getModel(); + const fileEntry = aspen.root.children.find( + (n) => n.path === selected.path + ) || (() => { + // Walk the tree to find selected — for nested catalog nodes. + const walk = (n) => { + if (n.path === selected.path) return n; + for (const c of (n.children || [])) { + const found = walk(c); + if (found) return found; + } + return null; + }; + return walk(aspen.root); + })(); + if (!fileEntry) throw new Error('openEditDialogViaApi: file entry for selected not found'); + + // Poll for children to materialise after open(). The REST call + // usually completes in <500ms for top-level catalog nodes, but + // sub-collections (coll-index, coll-trigger) under a table are + // a separate REST fetch triggered ONLY when the collection node + // itself is expanded — so they may not be loaded yet when + // navigation just selected the parent. 10s upper bound matches + // the other navigate-helpers' polling. + let child = null; + for (let i = 0; i < 50; i++) { + const children = fileEntry.children || []; + child = children.find((c) => { + const meta = c.getMetadata && c.getMetadata('data'); + return meta && meta._type === nodeType; + }); + if (child) break; + await new Promise((r) => setTimeout(r, 200)); + } + if (!child) { + throw new Error( + `openEditDialogViaApi: no child of type "${nodeType}" found ` + + 'under selected node — does the database have any?' + ); + } + + // Select the child so the node module's callback fires against + // it (show_obj_properties reads the currently-selected item). + await tree.select(child); + nodeModule.callbacks.show_obj_properties.call( + nodeModule, { action: 'edit' }, child + ); + + const data = child.getMetadata && child.getMetadata('data'); + return data ? data.label : 'unknown'; + }, nodeType); +}; + +// Drill into the tree from a connected server to reach a target +// catalog node (Tables / Functions / Views / etc.). Returns once +// the catalog node is visible. Tree virtualization makes deep +// navigation brittle; failure here surfaces as a locator timeout. +// +// LEGACY DOM-based version. Kept for the Register Server test that +// doesn't need deep navigation. Use navigateToCatalogNodeViaApi for +// anything that needs Tables / Functions / etc. +export const navigateToCatalogNode = async (page, serverName, catalog, database) => { + const db = database || process.env.PGDATABASE || 'postgres'; + + // Each tree directory needs a click + keyboard expand. Click + // focuses the node; ArrowRight expands it (idempotent — no toggle + // on already-expanded). dblclick is unsafe because expanded + // directories collapse on the second click. Single-click + key + // is a no-op for already-expanded directories. + const expand = async (matcher, settle = 1_500) => { + const node = page.locator('.file-entry.directory', { hasText: matcher }).first(); + await node.waitFor({ state: 'attached', timeout: 15_000 }); + await node.scrollIntoViewIfNeeded({ timeout: 10_000 }); + await node.click(); + await page.waitForTimeout(200); + await page.keyboard.press('ArrowRight'); + await page.waitForTimeout(settle); + }; + + await expand('Databases', 4_000); + // The DB node click triggers a connection — wait longer for it + // to settle before checking for Schemas. Schemas may take time + // to fetch from the DB too. + await expand(new RegExp('^' + db + '$'), 8_000); + await expand('Schemas', 6_000); + await expand(/^public$/, 5_000); + + const node = page.locator( + '.file-entry.directory', { hasText: new RegExp('^' + catalog + '$') } + ).first(); + await node.waitFor({ state: 'visible', timeout: 15_000 }); + return node; +}; + +// Right-click a category (Tables / Functions / …) and click +// Create → in the szh-menu context menu. +export const openCreateDialog = async (page, categoryName, childName) => { + const node = page.locator( + '.file-entry.directory', { hasText: new RegExp('^' + categoryName + '$') } + ).first(); + await node.click({ button: 'right' }); + await page.waitForTimeout(500); + await page.locator( + '.szh-menu__item', { hasText: /^Create$/ } + ).first().hover(); + await page.waitForTimeout(500); + await page.getByText(childName, { exact: true }).first().click(); +}; diff --git a/web/regression/perf-bench/audit-smoke-extended.spec.js b/web/regression/perf-bench/audit-smoke-extended.spec.js new file mode 100644 index 00000000000..19d420d4366 --- /dev/null +++ b/web/regression/perf-bench/audit-smoke-extended.spec.js @@ -0,0 +1,553 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2026, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +// Extended UI smoke covering 15 dialog types beyond the 5 in +// audit-smoke.spec.js. Each spec opens the dialog, exercises it with +// real dispatches (SET_VALUE on Name, tab-switch through every tab, +// ADD_ROW on the first DataGridView, SET_VALUE on the new row's +// first cell), closes (auto-dismissing any "discard changes?" +// prompt), and asserts the walker canary stayed quiet. +// +// Coverage by category (each = one dialog type, picked from the +// most production-relevant of create / edit per dialog): +// +// Schema-level (10): View, MaterializedView, Sequence, Type, +// Domain, Procedure, Aggregate, ForeignTable, +// Collation, FTS Configuration +// Trigger Function (1): server-supported function variant +// Server-level (2): Role, Tablespace +// Sub-catalog (2): Trigger, Index +// +// (Note: Database, EventTrigger and CompoundTrigger dialogs were in +// the original spec list but were dropped — Database needs more +// dialog-shape work, and the other two aren't shown on a vanilla +// non-superuser PG 16 install. Replaced with Collation, FTS +// Configuration, Trigger Function which exercise comparable code +// paths.) +// +// Why interaction-level, not mount-only: a walker bug that triggers +// only on ADD_ROW or tab-switch (i.e. anything beyond initial +// validation pass) would slip past a mount-only smoke. The +// `exerciseDialog` helper below fires real dispatches so the canary +// has something to disagree about. Best-effort — dialogs without a +// DataGrid or Name field silently skip those steps; validation +// errors raised by the typed values aren't confused with canary +// divergences (expectNoDivergence filters on canary-specific +// messages only). +// +// Does NOT click Save (no DB writes; no teardown). Save-path +// coverage for the heaviest dialog (Table) lives in the +// table-*.spec.js suite on dev/table-dialog-tests. + +import { test, expect } from '@playwright/test'; +import { + installErrorRecorders, enableAudit, autoDismissUnlockModal, + expectNoDivergence, ensureServerRegistered, + navigateToCatalogNodeViaApi, navigateToServerCollectionViaApi, + navigateToTableSubCollectionViaApi, + openCreateDialogViaApi, openEditDialogViaApi, + auditDebug, disconnectServerViaApi, +} from './audit-helpers.js'; + +// Each spec is self-contained (its own browser context, its own +// pgAdmin session). The worker-scoped page experiment (per-worker +// shared page + ensureServerRegistered fast-path) was DROPPED: +// tests interfered with each other through pgAdmin's per-session +// tree-state cache (pre-cached collection children invalidated by +// later spec navigations), surfacing as 30%-50% "no child of type X +// found" flakes regardless of worker count. Test-scoped page + +// per-test afterEach disconnect is the working shape. +test.describe.configure({ mode: 'parallel' }); + +const PGADMIN_URL = + process.env.PGADMIN_URL || 'http://127.0.0.1:5050/browser/'; + +const bootPage = async (page) => { + await page.setViewportSize({ width: 1600, height: 1000 }); + await autoDismissUnlockModal(page); + await page.goto(PGADMIN_URL, { waitUntil: 'load', timeout: 60_000 }); + await page.locator('.file-entry').first().waitFor({ + state: 'visible', timeout: 30_000, + }); + await page.waitForTimeout(1_000); + await enableAudit(page); + expect(await page.evaluate(() => window.__INCREMENTAL_AUDIT__)).toBe(true); +}; + +// eslint-disable-next-line no-empty-pattern +test.beforeEach(({}, testInfo) => { + auditDebug(`=== spec start === ${testInfo.title}`); +}); +test.afterEach(async ({ page }, testInfo) => { + await disconnectServerViaApi(page); + auditDebug( + `=== spec end === ${testInfo.title} ` + + `[${testInfo.status}] in ${testInfo.duration}ms` + ); +}); + +// Close button scoped to the SchemaView dialog panel — avoids matching +// transient toasts or the Unlock Saved Passwords dialog (rare race; +// has its own Close affordance) that may briefly co-exist. +const SCHEMA_DIALOG_CLOSE = + '.dock-panel.dock-style-dialogs button[data-test="Close"]'; + +// Asserts the canary build code actually ran during this dialog mount. +// Without this we'd only be asserting `__INCREMENTAL_AUDIT__` (a flag +// set by enableAudit itself) — which would pass vacuously on a +// non-CANARY_BUILD bundle where the canary is tree-shaken away. +// +// Caller passes the *baseline* count taken before opening the dialog; +// we assert it strictly increased. Delta-check (rather than > 0) +// defends against future configurations that share a page across +// tests (e.g. `test.describe.configure({ mode: 'serial' })` with a +// worker-scoped page fixture) where a stale leftover count would +// silently pass a > 0 check. +const readCanaryCount = (page) => + page.evaluate(() => window.__canary_entry_count__ || 0); +const expectCanaryExecuted = async (page, baselineCount) => { + const n = await readCanaryCount(page); + expect( + n, 'canary did not execute — likely a non-CANARY_BUILD bundle ' + + 'or the dialog never mounted' + ).toBeGreaterThan(baselineCount); +}; + +// Exercise the open dialog beyond just "did it mount" so the walker +// canary sees real dispatches. Mount-only smoke catches the narrow +// "schema crashes at construction" regression; the broader walker +// bugs (collection ADD_ROW, tab-switch field recomputation, +// cross-tab dep evaluation) only fire when the user actually +// interacts. Each interaction below maps to a real dispatch type: +// +// - fill Name → SET_VALUE on top-level scalar +// - click each tab → renders OTHER tab's fields, exercises their +// deps + initial validate pass +// - click add-row → ADD_ROW dispatch on a DataGridView +// - fill first cell → SET_VALUE on a collection row +// +// Best-effort: dialogs without a DataGrid skip the add-row step, +// dialogs without a Name skip the fill. Validation errors raised by +// the interactions are NOT confused with canary divergences — +// expectNoDivergence filters to canary-specific messages only. +const exerciseDialog = async (page, { editMode = false } = {}) => { + auditDebug('exerciseDialog: start'); + const dialog = page.locator('.dock-panel.dock-style-dialogs').first(); + + // 1. SET_VALUE on Name. Some dialogs may have a disabled or + // missing Name field — silent-skip rather than fail the helper. + // + // Defensive: skip the Name fill in EDIT mode. The seeded objects + // (audit_smoke_table, audit_smoke_view, etc.) are looked up by + // name by subsequent specs in the same iteration; if the close + // path ever shifts from "Discard changes" to "Save" (e.g. a future + // pgAdmin change to the confirm-prompt verb), filling Name in + // Edit mode would rename the seeded fixture mid-suite and break + // every subsequent Edit-mode test. Create mode is safe — the dialog + // mounts with no persisted record, save would create a NEW object. + auditDebug('exerciseDialog.1 Name fill: start'); + const _t1 = Date.now(); + if (!editMode) { + const nameBox = dialog.getByRole('textbox', { name: 'Name' }).first(); + if (await nameBox.isEditable().catch(() => false)) { + await nameBox.fill('audit_smoke_x').catch(() => {}); + await page.waitForTimeout(150); + } + } + auditDebug('exerciseDialog.1 Name fill: complete', `${Date.now() - _t1}ms`); + + // 2. Click each tab button. pgAdmin's SchemaView tabs render as + //