From 3796d5950285701e7d95d962cb25f0e255d0f0d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 22:23:50 +0000 Subject: [PATCH 01/36] chore(deps): resolve @objectstack/* 17.6.0 in pnpm-lock.yaml (objectui#11438) Regenerated with pnpm 10.31.0 only, by the raise-and-restore route the 17.5.0 bump used: every @objectstack/* specifier raised to ^17.6.0 for one `pnpm install`, every manifest restored byte-for-byte to HEAD, then a second `pnpm install` reconciles the importers to their declared ranges. Resolution census against the base lock: the seven @objectstack/* packages (spec, types, core, client, formula, lint, sdui-parser) move 17.5.0 -> 17.6.0 and nothing else moves. No importer specifier changes. The registry's new `deprecated` field on twelve @yuku-analyzer/binding-* 0.8.7 entries is written into the lock by the same tooling. Claude-Session: https://claude.ai/code/session_01HRYqpqGcWpJuJkDmbRF75w Co-authored-by: Claude --- pnpm-lock.yaml | 146 ++++++++++++++++++++++++++----------------------- 1 file changed, 79 insertions(+), 67 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ad17356ea4..246ecd68d7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,7 +35,7 @@ importers: version: link:packages/test-support '@objectstack/spec': specifier: ^17.0.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) '@playwright/test': specifier: ^1.62.1 version: 1.62.1 @@ -273,13 +273,13 @@ importers: version: link:../../packages/types '@objectstack/client': specifier: ^17.3.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) '@objectstack/lint': specifier: ^17.0.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) '@objectstack/spec': specifier: ^17.0.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) '@tailwindcss/postcss': specifier: ^4.3.3 version: 4.3.3 @@ -417,7 +417,7 @@ importers: version: link:../../packages/types '@objectstack/spec': specifier: ^17.0.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) fumadocs-core: specifier: 16.15.4 version: 16.15.4(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.43.0(react@19.2.8))(next@16.3.6(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react-router@7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(zod@4.6.5) @@ -775,13 +775,13 @@ importers: version: link:../types '@objectstack/formula': specifier: ^17.5.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) '@objectstack/lint': specifier: ^17.0.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) '@objectstack/spec': specifier: ^17.5.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) '@sentry/react': specifier: ^10.70.0 version: 10.70.0(react@19.2.8) @@ -872,7 +872,7 @@ importers: dependencies: '@objectstack/spec': specifier: ^17.0.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) better-auth: specifier: ^1.7.2 version: 1.7.2(@opentelemetry/api@1.9.1)(better-sqlite3@12.9.0)(mongodb@7.2.0(socks@2.8.9))(next@16.3.6(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.10) @@ -903,7 +903,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.1.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) '@tailwindcss/postcss': specifier: ^4.3.3 version: 4.3.3 @@ -971,7 +971,7 @@ importers: devDependencies: '@objectstack/spec': specifier: ^17.0.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) '@types/react': specifier: 19.2.18 version: 19.2.18 @@ -1007,7 +1007,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) '@radix-ui/react-accordion': specifier: ^1.2.20 version: 1.2.20(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -1191,10 +1191,10 @@ importers: version: link:../types '@objectstack/formula': specifier: ^17.5.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) '@objectstack/spec': specifier: ^17.5.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) devDependencies: '@object-ui/test-support': specifier: workspace:* @@ -1250,10 +1250,10 @@ importers: version: link:../types '@objectstack/client': specifier: ^17.3.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) '@objectstack/spec': specifier: ^17.5.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) devDependencies: '@object-ui/test-support': specifier: workspace:* @@ -1293,7 +1293,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.5.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) lucide-react: specifier: ^1.43.0 version: 1.43.0(react@19.2.8) @@ -1397,7 +1397,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) lucide-react: specifier: ^1.43.0 version: 1.43.0(react@19.2.8) @@ -1438,7 +1438,7 @@ importers: devDependencies: '@objectstack/spec': specifier: ^17.0.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) '@types/react': specifier: 19.2.18 version: 19.2.18 @@ -1619,7 +1619,7 @@ importers: version: link:../test-support '@objectstack/spec': specifier: ^17.0.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) '@types/react': specifier: 19.2.18 version: 19.2.18 @@ -1661,7 +1661,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) '@radix-ui/react-slot': specifier: ^1.3.3 version: 1.3.3(@types/react@19.2.18)(react@19.2.8) @@ -1752,7 +1752,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.5.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) lucide-react: specifier: ^1.43.0 version: 1.43.0(react@19.2.8) @@ -1822,7 +1822,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.4.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) clsx: specifier: ^2.1.1 version: 2.1.1 @@ -1874,7 +1874,7 @@ importers: version: link:../i18n '@objectstack/spec': specifier: ^17.5.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) lucide-react: specifier: ^1.43.0 version: 1.43.0(react@19.2.8) @@ -1999,7 +1999,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) lucide-react: specifier: ^1.43.0 version: 1.43.0(react@19.2.8) @@ -2060,7 +2060,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.5.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) lucide-react: specifier: ^1.43.0 version: 1.43.0(react@19.2.8) @@ -2130,7 +2130,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.5.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) '@tanstack/react-virtual': specifier: ^3.14.9 version: 3.14.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -2304,7 +2304,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) '@types/react': specifier: 19.2.18 version: 19.2.18 @@ -2481,7 +2481,7 @@ importers: version: link:../test-support '@objectstack/spec': specifier: ^17.0.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) '@types/node': specifier: ^26.2.0 version: 26.2.0 @@ -2529,7 +2529,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -2596,7 +2596,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) lucide-react: specifier: ^1.43.0 version: 1.43.0(react@19.2.8) @@ -2669,7 +2669,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -2734,7 +2734,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.1.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) react: specifier: 19.2.8 version: 19.2.8 @@ -2799,7 +2799,7 @@ importers: devDependencies: '@objectstack/spec': specifier: ^17.0.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) '@tailwindcss/postcss': specifier: ^4.3.3 version: 4.3.3 @@ -2838,13 +2838,13 @@ importers: version: link:../react '@objectstack/spec': specifier: ^17.0.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) packages/test-support: devDependencies: '@objectstack/spec': specifier: ^17.0.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) '@types/node': specifier: ^26.2.0 version: 26.2.0 @@ -2859,7 +2859,7 @@ importers: dependencies: '@objectstack/spec': specifier: ^17.5.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) zod: specifier: ^4.6.1 version: 4.6.5 @@ -4213,26 +4213,26 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@objectstack/client@17.5.0': - resolution: {integrity: sha512-RT8CoPw7v+ejADcK1NJRD/1rHOQJMKC+rL7SjTvEqI+Cfxulz3K4NSgILpyPZMaic72O6DWiWSNueNiFp/7W1g==} + '@objectstack/client@17.6.0': + resolution: {integrity: sha512-ZvhtjximG+nQNeC8v0Ed/FRdI5xcGQLJzxUtKlqvJJ6I3ueqBmOs8zHSoCa1vyMbctjBuzLRKxUeM9cUn9LTuA==} engines: {node: '>=22.0.0'} - '@objectstack/core@17.5.0': - resolution: {integrity: sha512-KYVVHoqDQZzqvCD7B1REAnQc80ZeimrkSryaGvPfQc2bdurvTOjtubGXw2IkQsh3g2Gh0RD9QzohteEU7C1DFg==} + '@objectstack/core@17.6.0': + resolution: {integrity: sha512-mLKGKYnbmp6ZNVZ26sC1ftDOIpKqiPjujJ+9avVDM06eQhL+O9r9nPFFjx+4RNfH7z+6r7T2LOu6nPdpRPaUWA==} engines: {node: '>=22.0.0'} - '@objectstack/formula@17.5.0': - resolution: {integrity: sha512-2vot7CS7++4vuxOJ3fyWVUPOut+/oejJT1YbLwyyyVDxj4WjZ3tJqEk+WGRqNunwYbd3CTk7jrtMo7SRzF7+Xg==} + '@objectstack/formula@17.6.0': + resolution: {integrity: sha512-5RpDNHLYJxqaivpDtM+QpltC5WlLRzwm58G9Y1us+6waZNnrV61eCDwV8JvaCsluneugcCVxp7oSDfjM8xZjAA==} - '@objectstack/lint@17.5.0': - resolution: {integrity: sha512-sXNgX4PFAMwo5q7xRqUfTEBUE5OKStYQwmFqdwDDN5Zir8JRJi9z2cN1D42h9PciR4Y9+Qum0z9gOKgS6o0jsw==} + '@objectstack/lint@17.6.0': + resolution: {integrity: sha512-72LncQPEk1BnO3HWdtp+3Q9tik2lntQ8/WNfxtB5jqPo/9XQqDLjYaxCXmN1+mLtEkjk1ph/g8mHaSGORq9/4g==} engines: {node: '>=22.0.0'} - '@objectstack/sdui-parser@17.5.0': - resolution: {integrity: sha512-GBn5BONhKuWfI/wmI+X4P0mh4a/cKx6Zo0rgZojWVBVC00fPpxuMb5obO74Do509Nm6o0h7m7OAq65Rb0td7TQ==} + '@objectstack/sdui-parser@17.6.0': + resolution: {integrity: sha512-mMJxAXscqVTruSdRdzBfC1EufAXfX2jNDuyo1k0XNLkClLdJAgjmQBZtyMARUSEbGg33PC30gPx9IiBt7qYSFA==} - '@objectstack/spec@17.5.0': - resolution: {integrity: sha512-G9IPkAnxbgk90wKTqyrIHsPm+kWyYDn2cUfmNnt5xwewdpvRtd5HqaHSjjn5wBCzO0OACerZULNR/f3gPtQu1w==} + '@objectstack/spec@17.6.0': + resolution: {integrity: sha512-i5il5q0vIYjYrkHznsCXuO8C6Ftrs2RU4MxRHWrW3TsR7t/VUoe3sSfVLHDaudV+LfVVUDD9QHsWex1MCdckrA==} engines: {node: '>=22.0.0'} peerDependencies: ai: ^7.0.0 @@ -4240,8 +4240,8 @@ packages: ai: optional: true - '@objectstack/types@17.5.0': - resolution: {integrity: sha512-QSwGeHxGmDd5SQbZFkHdoct9J6kOh7tijsU6BDE1k+zgTHqOnqUGNQkp6T8KggJTdPz2MHvRDBpmstHT8Rsgng==} + '@objectstack/types@17.6.0': + resolution: {integrity: sha512-yH4B4YZt0cdSkenjifKY5o8svaV+PMPCdV0Tm0FMo3w1o6ShhF5gQK/PvU9lwFaRsEAB9SuJ70nCURfIAVoLsw==} engines: {node: '>=22.0.0'} '@open-draft/deferred-promise@2.2.0': @@ -6082,67 +6082,79 @@ packages: resolution: {integrity: sha512-pzJ++UMCZEV4s6SP3Ryvj+snWP8s7aTXFkSXRyeBF4RSALftPzfqYssHdGOmOH2QnRAtyOhhg64tdjeSGJvYRg==} cpu: [arm64] os: [android] + deprecated: yuku-analyzer runs on yuku-core since 0.14 '@yuku-analyzer/binding-darwin-arm64@0.8.7': resolution: {integrity: sha512-vymR7Us3i/HJvUxA1N5sKCrrn2mXw/Xvzbt66zlRyGPDmkUzFCrn34OqodR/zctmD1JhaFlv+Ur6xNNPRIid/w==} cpu: [arm64] os: [darwin] + deprecated: yuku-analyzer runs on yuku-core since 0.14 '@yuku-analyzer/binding-darwin-x64@0.8.7': resolution: {integrity: sha512-aX1xy6wJeI2QN/ipu9B6/dzz6RmHQ5+Ty6uyf9csqYZDnY4/U2GsDskYQIRysc2Cuh+GNnvuO5xCXxiqkmVEcw==} cpu: [x64] os: [darwin] + deprecated: yuku-analyzer runs on yuku-core since 0.14 '@yuku-analyzer/binding-freebsd-x64@0.8.7': resolution: {integrity: sha512-5LT2KkjK0zBI2s3VoSyGPSs9Ey7rWPxWkCyFgZXoszQOkAk2z0biP+DLzb9zw6flmwwCCiyeZRqM65srfo8l1Q==} cpu: [x64] os: [freebsd] + deprecated: yuku-analyzer runs on yuku-core since 0.14 '@yuku-analyzer/binding-linux-arm-gnu@0.8.7': resolution: {integrity: sha512-4haNlVk624QoNSKIneoH9JKu5SvfD+Hkxg490HUS5pfFuWwoXT3zOmAdfwPMsSH0bNIkFO7GqtwDZ9EVpyzepw==} cpu: [arm] os: [linux] libc: [glibc] + deprecated: yuku-analyzer runs on yuku-core since 0.14 '@yuku-analyzer/binding-linux-arm-musl@0.8.7': resolution: {integrity: sha512-7HwJHVFtrufB5qHHL1PSDPr/j6uoNLwbwxa04QzsbpcbbzfDUbT37loHPu5u0NuetRUlV+TqXDlX6OpXcM8hKQ==} cpu: [arm] os: [linux] libc: [musl] + deprecated: yuku-analyzer runs on yuku-core since 0.14 '@yuku-analyzer/binding-linux-arm64-gnu@0.8.7': resolution: {integrity: sha512-yUEgxEPuDVBO+nkDw8qbssYA8oHu82Q0da+C7rGyVplmjlKa5DhBnMMagTEjFZx4jNDVWnGHJreUCSeGL0x/gQ==} cpu: [arm64] os: [linux] libc: [glibc] + deprecated: yuku-analyzer runs on yuku-core since 0.14 '@yuku-analyzer/binding-linux-arm64-musl@0.8.7': resolution: {integrity: sha512-2X7EwxPbgdNRqgMwtxOnNOGEmdm1RS8PD2Q5cOxj8cEZD4fy7yHHeSDoEdBOyrJtHzbG6jQB6CeReO1okb/S7Q==} cpu: [arm64] os: [linux] libc: [musl] + deprecated: yuku-analyzer runs on yuku-core since 0.14 '@yuku-analyzer/binding-linux-x64-gnu@0.8.7': resolution: {integrity: sha512-k/iQFK1gAvaHLzXXZ3/+g48wT5YB6MfikPb+juGCd9HzyPMUSBCy44rz6nT+xoWnnxmBoeBUywA4CvWCWZFTtg==} cpu: [x64] os: [linux] libc: [glibc] + deprecated: yuku-analyzer runs on yuku-core since 0.14 '@yuku-analyzer/binding-linux-x64-musl@0.8.7': resolution: {integrity: sha512-gTfYHx3cg8FERTYsg1dQrQFTutcWJ7wTp8YToyAJnZMbUCkcyuwUiWNYaEHyF0xIb+PsG7HfD+BLWhRRom5qKg==} cpu: [x64] os: [linux] libc: [musl] + deprecated: yuku-analyzer runs on yuku-core since 0.14 '@yuku-analyzer/binding-win32-arm64@0.8.7': resolution: {integrity: sha512-XDCWZZztOvdTtPNaU5EzrrV0dmMugdZ+Qdq5INeiGhGW5hD0TuCBIIXK7wTmRM6NcKarGlyBP5SYkiAuw6+slg==} cpu: [arm64] os: [win32] + deprecated: yuku-analyzer runs on yuku-core since 0.14 '@yuku-analyzer/binding-win32-x64@0.8.7': resolution: {integrity: sha512-VtSH1pWuk3bo+BiUAtzYa4Ku1r/10CO14QvkGpRhDcFck8sIf7ox+wiucyKNKcf+srWCYxR1hO+wn5N3BdtFdw==} cpu: [x64] os: [win32] + deprecated: yuku-analyzer runs on yuku-core since 0.14 '@yuku-toolchain/types@0.8.7': resolution: {integrity: sha512-2Z53dNxAJL6UvFoIrDZvYf3zlO8s4VJK4O2hhaB4mXVwwpX/7ajtss3cmfqKvamlNLWyt9FSWs4eoYdlbxpnHA==} @@ -12306,33 +12318,33 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 - '@objectstack/client@17.5.0(ai@7.0.65(zod@4.6.5))': + '@objectstack/client@17.6.0(ai@7.0.65(zod@4.6.5))': dependencies: - '@objectstack/core': 17.5.0(ai@7.0.65(zod@4.6.5)) - '@objectstack/spec': 17.5.0(ai@7.0.65(zod@4.6.5)) + '@objectstack/core': 17.6.0(ai@7.0.65(zod@4.6.5)) + '@objectstack/spec': 17.6.0(ai@7.0.65(zod@4.6.5)) transitivePeerDependencies: - ai - '@objectstack/core@17.5.0(ai@7.0.65(zod@4.6.5))': + '@objectstack/core@17.6.0(ai@7.0.65(zod@4.6.5))': dependencies: - '@objectstack/spec': 17.5.0(ai@7.0.65(zod@4.6.5)) - '@objectstack/types': 17.5.0(ai@7.0.65(zod@4.6.5)) + '@objectstack/spec': 17.6.0(ai@7.0.65(zod@4.6.5)) + '@objectstack/types': 17.6.0(ai@7.0.65(zod@4.6.5)) zod: 4.6.5 transitivePeerDependencies: - ai - '@objectstack/formula@17.5.0(ai@7.0.65(zod@4.6.5))': + '@objectstack/formula@17.6.0(ai@7.0.65(zod@4.6.5))': dependencies: '@marcbachmann/cel-js': 8.0.0 - '@objectstack/spec': 17.5.0(ai@7.0.65(zod@4.6.5)) + '@objectstack/spec': 17.6.0(ai@7.0.65(zod@4.6.5)) transitivePeerDependencies: - ai - '@objectstack/lint@17.5.0(ai@7.0.65(zod@4.6.5))': + '@objectstack/lint@17.6.0(ai@7.0.65(zod@4.6.5))': dependencies: - '@objectstack/formula': 17.5.0(ai@7.0.65(zod@4.6.5)) - '@objectstack/sdui-parser': 17.5.0 - '@objectstack/spec': 17.5.0(ai@7.0.65(zod@4.6.5)) + '@objectstack/formula': 17.6.0(ai@7.0.65(zod@4.6.5)) + '@objectstack/sdui-parser': 17.6.0 + '@objectstack/spec': 17.6.0(ai@7.0.65(zod@4.6.5)) ajv: 8.20.0 ajv-formats: 3.0.1(ajv@8.20.0) sucrase: 3.35.1 @@ -12340,18 +12352,18 @@ snapshots: transitivePeerDependencies: - ai - '@objectstack/sdui-parser@17.5.0': {} + '@objectstack/sdui-parser@17.6.0': {} - '@objectstack/spec@17.5.0(ai@7.0.65(zod@4.6.5))': + '@objectstack/spec@17.6.0(ai@7.0.65(zod@4.6.5))': dependencies: pg-connection-string: 2.14.0 zod: 4.6.5 optionalDependencies: ai: 7.0.65(zod@4.6.5) - '@objectstack/types@17.5.0(ai@7.0.65(zod@4.6.5))': + '@objectstack/types@17.6.0(ai@7.0.65(zod@4.6.5))': dependencies: - '@objectstack/spec': 17.5.0(ai@7.0.65(zod@4.6.5)) + '@objectstack/spec': 17.6.0(ai@7.0.65(zod@4.6.5)) transitivePeerDependencies: - ai From b6684fd3c5922becdde8b25db03a63019afe322e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 23:01:50 +0000 Subject: [PATCH 02/36] chore(scripts): spec-symbols allows @object-ui/components' EmptyState, a component under a name @objectstack/spec 17.6.0 now exports (objectui#11438) @objectstack/spec 17.6.0 exports `EmptyState` from `@objectstack/spec/ui`: the authoring type of a list view's empty-state config (title, message, icon). @object-ui/components publishes `EmptyState` as an alias of the `DataEmptyState` React component. A component is not a config shape, so neither import nor derivation fits; the alias is a published name, so its retirement or rename belongs to its own card. Same disposition objectui#11073 gave the four `Object*Props` collisions 17.5.0 surfaced. Claude-Session: https://claude.ai/code/session_01HRYqpqGcWpJuJkDmbRF75w Co-authored-by: Claude --- scripts/check-spec-symbol-derivation.mjs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/scripts/check-spec-symbol-derivation.mjs b/scripts/check-spec-symbol-derivation.mjs index 7ba715ce6f..1e7223641b 100644 --- a/scripts/check-spec-symbol-derivation.mjs +++ b/scripts/check-spec-symbol-derivation.mjs @@ -476,6 +476,19 @@ const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); // // Key format: ":". const ALLOW = { + "@object-ui/components:EmptyState": { + reason: + "Two LAYERS under one name, measured when `@objectstack/spec` 17.6.0 began exporting " + + "`EmptyState` from `@objectstack/spec/ui` (objectui#11438). The spec's type is a list " + + "view's AUTHORED empty-state config (`z.input` of `EmptyStateSchema`: title, message, icon); " + + "this one is a React COMPONENT, the published alias `DataEmptyState as EmptyState` in " + + "`view-states.tsx`, which draws an empty state from host props (title, description, icon, " + + "illustration, action). A component is not a config shape, so neither import nor derivation fits. " + + "It is a PUBLISHED export of the package, so retiring or renaming the alias changes a public " + + "name and belongs to its own card, not to the dependency bump that surfaced the collision " + + "(the same disposition objectui#11073 gave the four `Object*Props` collisions below).", + issue: 11438, + }, "@object-ui/plugin-gantt:ObjectGanttProps": { reason: "Two LAYERS under one name, measured when `@objectstack/spec` 17.5.0 began exporting " + From c2dc334c8e13d343b699681ae2c97ba7a7fb2c7a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 23:01:59 +0000 Subject: [PATCH 03/36] refactor(app-shell,data-objectstack): retire the `| 'computed'` widening now that @objectstack/spec 17.6.0 carries the arm (objectui#11438) objectui#11206 widened `STRIPPED_LINE`'s key type by `| 'computed'` so it compiled against both a three-arm pin and objectstack `main`, and left a tripwire in `writeWarningToast.test.ts` for the bump that carries the arm. 17.6.0's `DroppedFieldsEventSchema.reason` lists `computed`, so this does what the tripwire says: the key type is the spec union again, the `computed` case is spelled as the literal, the spent tripwire is deleted, and spec-symbol-batch6's `_ReasonIsTheEnum` pins the exact four-member union. Claude-Session: https://claude.ai/code/session_01HRYqpqGcWpJuJkDmbRF75w Co-authored-by: Claude --- .../src/providers/writeWarningToast.test.ts | 25 +++---------------- .../src/providers/writeWarningToast.ts | 24 ++++++------------ .../src/spec-symbol-batch6.test.ts | 17 ++++++------- 3 files changed, 19 insertions(+), 47 deletions(-) diff --git a/packages/app-shell/src/providers/writeWarningToast.test.ts b/packages/app-shell/src/providers/writeWarningToast.test.ts index 9bbf4d4d97..3918d9a57e 100644 --- a/packages/app-shell/src/providers/writeWarningToast.test.ts +++ b/packages/app-shell/src/providers/writeWarningToast.test.ts @@ -179,10 +179,6 @@ describe('emitWriteWarning (#3484)', () => { }); it('uses the formula wording for reason `computed`, which is NOT read-only (objectui#11206)', async () => { - // Spelled through `string`: the pinned spec (17.5.0) does not name this arm - // yet, while objectstack `main` does (`b2805465`), and this file compiles - // against both. - const computed: string = 'computed'; const keys: string[] = []; const recordingT = (key: string, opts?: Record) => { keys.push(key); @@ -194,7 +190,7 @@ describe('emitWriteWarning (#3484)', () => { { ...EVENT, droppedFields: [ - { object: 'andon', fields: ['type'], reason: computed as DroppedFieldsEvent['reason'] }, + { object: 'andon', fields: ['type'], reason: 'computed' }, ], }, recordingT, @@ -209,21 +205,6 @@ describe('emitWriteWarning (#3484)', () => { expect(calls[0].description).not.toMatch(/^Not applied by the server/); }); - /** - * The tripwire for the `| 'computed'` widening on `STRIPPED_LINE` - * (objectui#11206). That widening exists only because the published pin - * predates objectstack `b2805465`; on the bump to a release that carries the - * arm it becomes a silent no-op, which no compiler reports. This makes that - * bump say so instead, in the suite a bump is run through. - */ - it('reminds the pin bump that carries `computed` to retire the widening (objectui#11206)', () => { - expect( - DroppedFieldsEventSchema.shape.reason.options, - "the spec pin now declares `computed`: delete `| 'computed'` from STRIPPED_LINE's key type " + - "and make spec-symbol-batch6.test.ts's `_ReasonIsTheEnum` pin the four-member union", - ).not.toContain('computed'); - }); - it('keeps one line per reason when a save was stripped for several', async () => { const { sink, calls } = makeSink(); @@ -252,8 +233,8 @@ describe('emitWriteWarning (#3484)', () => { /** * The exhaustiveness pin (objectui#3935), read off the SPEC rather than a hand * list that would drift: `STRIPPED_LINE` is keyed by - * `DroppedFieldsEvent['reason']` (widened by `'computed'` until the pin - * carries it, objectui#11206), so a missing key is a type error — but + * `DroppedFieldsEvent['reason']` (widened by `'computed'` from objectui#11206 + * until the 17.6.0 bump, objectui#11438), so a missing key is a type error — but * `type-check` and `vitest` are different gates, * and the reason the ternary this replaced survived so long is that nothing in * the test suite could see the gap at all. diff --git a/packages/app-shell/src/providers/writeWarningToast.ts b/packages/app-shell/src/providers/writeWarningToast.ts index d6cc9914cd..3e2bd2c947 100644 --- a/packages/app-shell/src/providers/writeWarningToast.ts +++ b/packages/app-shell/src/providers/writeWarningToast.ts @@ -116,23 +116,15 @@ type StrippedLine = (t: TranslateFn, fields: string) => string; * `DROPPED_REASON_LABEL`), and the shape the spec's own schema comment asks every * consumer that branches on `reason` to use. * - * ⭐ Keyed by the spec union WIDENED BY `'computed'`, and the widening is what - * lets one spelling compile against both specs this repository is checked - * against (objectui#11206). objectstack `b2805465` added the `computed` arm (a - * `formula` field's caller-supplied value, stripped on every write path); the - * published pin, `@objectstack/spec` 17.5.0, predates it, while the - * `Spec Main Shape Gate` compiles against objectstack `main`, which carries it. - * A bare `Record` is exact in both directions, - * so the row is a missing key against `main` and an excess key (TS2353) against - * the pin; `satisfies` does not help, because it runs the same excess-property - * check on an object literal. The union with the one literal instead makes the - * required key set IDENTICAL on both sides — the pin's three plus `computed` — - * and still names the spec's union as the thing being covered, so the promise - * above holds on both: the NEXT arm upstream adds is a missing key here. When - * the pin reaches a release carrying `computed`, the `| 'computed'` is a no-op - * and is deleted; `writeWarningToast.test.ts` fails on that bump to say so. + * The `computed` row (a `formula` field's caller-supplied value, stripped on + * every write path) was added by objectui#11206 ahead of the published pin: + * objectstack `b2805465` declared the arm while the pin of the day predated it, + * so the key type was widened by `| 'computed'` to compile against both. Since + * `@objectstack/spec` 17.6.0 the pin's own union carries `computed`, so the + * widening was a no-op and was deleted at that bump (objectui#11438), as its + * tripwire asked. The key type is the spec union again, exact in both directions. */ -const STRIPPED_LINE: Record = { +const STRIPPED_LINE: Record = { readonly: (t: TranslateFn, fields: string) => t('detail.writeStrippedReadonly', { fields, diff --git a/packages/data-objectstack/src/spec-symbol-batch6.test.ts b/packages/data-objectstack/src/spec-symbol-batch6.test.ts index aa21dd3fe6..8b34eae160 100644 --- a/packages/data-objectstack/src/spec-symbol-batch6.test.ts +++ b/packages/data-objectstack/src/spec-symbol-batch6.test.ts @@ -242,16 +242,15 @@ describe('DroppedFieldsEvent IS the spec type, not a mirror of it', () => { // IT FIRED AGAIN at objectstack `b2805465`, which added `computed` (a // formula field's caller-supplied value, stripped on every write path) — // this time in the `Spec Main Shape Gate`, before any pin moved - // (objectui#11206). This file compiles against BOTH the published pin - // (17.5.0, three arms) and objectstack `main` (four), so the member list is - // stated with `computed` subtracted. That is still exact on each side: - // `computed` is the only member the `Exclude` can absorb, so any other new - // arm is a remainder `Equal` refuses, and `_ReasonIsNotString` below still - // refuses the widening. When the pin moves past 17.5.0 to a release - // carrying `computed`, this becomes the four-member union with no `Exclude` - // (`writeWarningToast.test.ts` fails on that bump to say so). + // (objectui#11206). Until the pin carried it, this file compiled against + // BOTH a three-arm pin and objectstack `main` (four), so the member list + // was stated with `computed` subtracted. `@objectstack/spec` 17.6.0 carries + // `computed`, so at that bump (objectui#11438) the pin and `main` agree and + // this is the exact four-member union again, with no `Exclude`: any new arm + // is a remainder `Equal` refuses, and `_ReasonIsNotString` below still + // refuses the widening. type _ReasonIsTheEnum = Assert< - Equal, 'readonly' | 'readonly_when' | 'primary_key'> + Equal >; type _ReasonIsNotString = Assert, false>>; From 3b3cc6bb5fa95c7701b3f43fd01350a7e75eb392 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 23:01:59 +0000 Subject: [PATCH 04/36] test(plugin-dashboard): read the ADR-0087 registry from @objectstack/spec/migrations, where 17.6.0 moved it (objectui#11438) 17.6.0 carries the spec's migrations-entry-split: `MIGRATIONS_BY_MAJOR` is exported from `@objectstack/spec/migrations` and no longer from the root. The test read the root dynamically so it compiled against both the old pin and objectstack `main`, with no fallback, and its docblock asked the bump that moves the pin past the split to replace that with a static import from the subpath. This does that. Claude-Session: https://claude.ai/code/session_01HRYqpqGcWpJuJkDmbRF75w Co-authored-by: Claude --- ...setWidget.unrenderedMeasures-8894.test.tsx | 33 ++++++++----------- 1 file changed, 13 insertions(+), 20 deletions(-) diff --git a/packages/plugin-dashboard/src/__tests__/DatasetWidget.unrenderedMeasures-8894.test.tsx b/packages/plugin-dashboard/src/__tests__/DatasetWidget.unrenderedMeasures-8894.test.tsx index eef06d77a1..c735d45482 100644 --- a/packages/plugin-dashboard/src/__tests__/DatasetWidget.unrenderedMeasures-8894.test.tsx +++ b/packages/plugin-dashboard/src/__tests__/DatasetWidget.unrenderedMeasures-8894.test.tsx @@ -54,6 +54,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, cleanup, waitFor } from '@testing-library/react'; +import { MIGRATIONS_BY_MAJOR } from '@objectstack/spec/migrations'; import { ChartTypeSchema as SpecChartTypeSchema } from '@objectstack/spec/ui'; import { DashboardWidgetSchema } from '@object-ui/types/zod'; import { DatasetWidget } from '../DatasetWidget'; @@ -91,20 +92,17 @@ type SemanticEntry = { id: string; replacement: string }; type MigrationRegistry = Readonly>; /** - * The installed spec's ADR-0087 registry, read off the package ROOT, where the - * pinned `@objectstack/spec` 17.5.0 exports `MIGRATIONS_BY_MAJOR` (it has no - * `./migrations` subpath). objectstack `main` moved the chain to - * `@objectstack/spec/migrations` (its `migrations-entry-split` entry: same - * names, new path), and the Spec Main Shape Gate compiles this file against - * `main` — so the root is imported dynamically and read as a plain record, - * which type-checks against both shapes without naming a module either lacks. - * ⛔ No fallback to the subpath: on a spec whose root no longer carries the - * registry the lookup below fails by name, and the pull request that moves the - * pin past the split replaces this with a static import from the subpath. + * The installed spec's ADR-0087 registry, read off the `./migrations` subpath. + * Through 17.5.0 the chain was exported from the package ROOT and this file + * read it there dynamically, so it would compile against both that and + * objectstack `main`, which had moved it (its `migrations-entry-split` entry: + * same names, new path). `@objectstack/spec` 17.6.0 carries the split and its + * root no longer exports the registry, so the bump that moved the pin past it + * (objectui#11438) replaced the dynamic root read with this static import, as + * the previous docblock asked. ⛔ No fallback to the root. */ -async function installedMigrationRegistry(): Promise { - const root = (await import('@objectstack/spec')) as Record; - return root.MIGRATIONS_BY_MAJOR as MigrationRegistry | undefined; +function installedMigrationRegistry(): MigrationRegistry { + return MIGRATIONS_BY_MAJOR; } /** The duly#109 tile, verbatim from the card's repro block — a stored three-measure metric. */ @@ -172,13 +170,8 @@ describe('objectui#8894 — the dropped measures speak', () => { // The pointer cannot dangle: the id is read back out of the installed // spec's migration registry, and the entry carries the `replacement` the // message sends the reader to. - const registry = await installedMigrationRegistry(); - expect( - registry, - 'the installed spec no longer exports MIGRATIONS_BY_MAJOR from its root — the pin has passed the ' - + 'migrations-entry-split: import it from the `/migrations` subpath here', - ).toBeDefined(); - const entry = Object.values(registry!) + const registry = installedMigrationRegistry(); + const entry = Object.values(registry) .flatMap((step) => step.semantic) .find((e) => e.id === ENTRY_ID); expect(entry, `${ENTRY_ID} is not in the installed spec's ADR-0087 registry`).toBeDefined(); From 1eff255448d189b94a860b32311a2c4fa56b5b4f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 23:05:28 +0000 Subject: [PATCH 05/36] fix(components): page:header stops publishing `breadcrumb`, which @objectstack/spec 17.6.0 refuses by name (objectui#11438) 17.6.0 carries objectstack#20758: `PageHeaderProps.breadcrumb` is a tombstone refused by name. objectui's renderer has not read it since objectui#11166, and its `inputs` entry said it stayed only while the spec accepted the key and would leave with the retirement. It leaves now, so the registry no longer publishes an input the contract refuses (`registry-inputs-spec-parity.test.ts`, "page:header declares no top-level input the spec does not accept"). The slotted-pages guide example drops `breadcrumb: true`, which the contract now refuses (its pin parses the example against the spec row), and the page-header doc and the renderer comment say the key is refused rather than "accepted until retired". Claude-Session: https://claude.ai/code/session_01HRYqpqGcWpJuJkDmbRF75w Co-authored-by: Claude --- content/docs/guide/slotted-pages.md | 1 - content/docs/layout/page-header.mdx | 5 +++-- ...slotted-pages-header-example-11165.test.ts | 7 ++++--- .../src/renderers/layout/containers.tsx | 19 ++++++++----------- 4 files changed, 15 insertions(+), 17 deletions(-) diff --git a/content/docs/guide/slotted-pages.md b/content/docs/guide/slotted-pages.md index 7b75df583f..42d20aa459 100644 --- a/content/docs/guide/slotted-pages.md +++ b/content/docs/guide/slotted-pages.md @@ -111,7 +111,6 @@ export const AccountDetailPage: Page = { properties: { title: '{name}', subtitle: '{industry} · {type}', - breadcrumb: true, }, }, }, diff --git a/content/docs/layout/page-header.mdx b/content/docs/layout/page-header.mdx index d2ebcd3115..5bb7ecbdc5 100644 --- a/content/docs/layout/page-header.mdx +++ b/content/docs/layout/page-header.mdx @@ -67,8 +67,9 @@ renders none of them and says so once per id in the console. That is why the dem carries no actions. Placement (`locations: ['record_header']` or `record_more`), ordering and the overflow menu are covered in [Slotted pages](/docs/guide/slotted-pages). -**`breadcrumb` draws nothing:** the contract accepts it until objectstack#20758 retires it, -and the renderer ignores it, so `true`, `false` and an absent key render the same header. +**`breadcrumb` is not a key of this node.** `@objectstack/spec` 17.6.0 retired it +(objectstack#20758) and the contract refuses it by name. It never drew anything: the renderer +ignores it, so a stored `true` or `false` renders the same header as an absent key. **`maxVisible` / `mobileMaxVisible`** must be positive integers: the contract rejects `0` and fractional values, and the renderer falls back to its default for a value the contract diff --git a/examples/schema-catalog/test/slotted-pages-header-example-11165.test.ts b/examples/schema-catalog/test/slotted-pages-header-example-11165.test.ts index a89f6bafd9..eb9b5d9ae6 100644 --- a/examples/schema-catalog/test/slotted-pages-header-example-11165.test.ts +++ b/examples/schema-catalog/test/slotted-pages-header-example-11165.test.ts @@ -46,9 +46,10 @@ * exactly those two keys. Without it, a judge that stopped judging would * pass 1 and 2 as well. * - * `breadcrumb: true` stays in the example: its fate is a separate card, and - * this file only asks that every key the example writes is one the contract - * accepts today. + * The example carried `breadcrumb: true` until the `@objectstack/spec` 17.6.0 + * bump (objectui#11438), when the contract began refusing it by name + * (objectstack#20758); it left the example then. This file only asks that + * every key the example writes is one the contract accepts today. */ import { describe, it, expect } from 'vitest'; diff --git a/packages/components/src/renderers/layout/containers.tsx b/packages/components/src/renderers/layout/containers.tsx index 6fe937ae63..9b88e6b28f 100644 --- a/packages/components/src/renderers/layout/containers.tsx +++ b/packages/components/src/renderers/layout/containers.tsx @@ -1507,9 +1507,10 @@ const PageHeaderRenderer: React.FC = ({ schema, className, ...props }) => { // `breadcrumb` is deliberately NOT read (objectui#11166, ruling RETIRE). All // it ever drew was an empty `data-page-breadcrumb-slot` div that nothing // filled, and the console's app header already draws the trail. The spec - // still accepts `PageHeaderProps.breadcrumb` until objectstack#20758 retires - // it, so an authored value renders exactly as an absent one: ignored, no - // error. ⛔ Do not add a trail here; that is an ENFORCE ruling, not a fix. + // refuses `PageHeaderProps.breadcrumb` by name since 17.6.0 + // (objectstack#20758); a value that still reaches this renderer renders + // exactly as an absent one: ignored, no error. ⛔ Do not add a trail here; + // that is an ENFORCE ruling, not a fix. // Schema-level opt-outs let authors keep the historic "bare h1" header // when they don't want a record chip (e.g. a non-record landing page). @@ -2419,14 +2420,10 @@ ComponentRegistry.register('header', PageHeaderRenderer, { { name: 'title', type: ['string', 'object'], description: 'Supports {field} interpolation and inline translation maps; falls back to the record title' }, { name: 'subtitle', type: ['string', 'object'], description: 'Same interpolation as Title' }, { name: 'actions', type: 'array', of: 'string', description: "Action IDS — the names of actions declared on the object's own metadata — rendered in the header before any host-injected system actions. An id whose action declares neither record_header nor record_more in its locations renders nowhere." }, - // Declared but NOT read (objectui#11166). It stays in `inputs` only while - // the installed spec still accepts the key: the reverse direction of - // `registry-inputs-spec-parity.test.ts` asks for every accepted spec key, - // and the manifest would otherwise warn `unknown-prop` on a value the - // contract accepts. It leaves with the spec retirement (objectstack#20758): - // once the pin carries the tombstone, this entry fails the forward - // direction. The description tells an author reading the manifest it is inert. - { name: 'breadcrumb', type: 'boolean', description: 'Ignored: the header draws no breadcrumb, and the key is being retired from the contract. Leave it out.' }, + // No `breadcrumb`: it was declared but NOT read (objectui#11166), and stayed + // here only while the spec accepted the key. `@objectstack/spec` 17.6.0 + // carries the retirement (objectstack#20758, a tombstone refused by name), + // so the entry left at that bump (objectui#11438), as this comment asked. { name: 'recordChrome', type: 'boolean', description: 'Set false for the bare h1 header on non-record pages' }, { name: 'showStar', type: 'boolean' }, { name: 'showCopyId', type: 'boolean' }, From 6e76e906a878b907da72a24bbea1ca18902089c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 23:05:29 +0000 Subject: [PATCH 06/36] test(console): strike the two `endpoint` bookings @objectstack/spec 17.6.0 made stale (objectui#11438) objectui#11111 decision 3 = B booked `action:button.endpoint` and `action:icon.endpoint` as spec keys held unpublished, owed to objectui#11168. 17.6.0 refuses `endpoint` on both blocks (objectstack b3917d90, the rename to `target`), so neither entry names a key the spec declares, and the dangling-exemption check reds on them. Both are struck and the ledger cap falls from 3 to 1 (`undoable` stays, still owed to objectui#11168). Claude-Session: https://claude.ai/code/session_01HRYqpqGcWpJuJkDmbRF75w Co-authored-by: Claude --- .../registry-inputs-spec-parity.test.ts | 24 +++++++------------ 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts index 66e10a6e3f..6c72242ed3 100644 --- a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts +++ b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts @@ -792,7 +792,7 @@ const owedIdsOf = (ledger: Record): string[] => const OBJECTUI_11111_LEDGER_CAPS = { unjudgedBlocks: 0, // objectui#11168 loaded and judged all four: slice 3 object-map and object-tree, slice 4 object-gantt, slice 5 object-timeline offSpecInputs: 0, // objectui#11168 slice 1 retired action:group.name - unpublishedKeys: 3, // objectui#11168: 3 (action:button endpoint + undoable, action:icon endpoint); objectui#8652: 0 and objectui#8649: 0 (each struck by its landing) + unpublishedKeys: 1, // objectui#11168: 1 (action:button undoable; the two `endpoint` entries left at the 17.6.0 bump, objectui#11438, when the spec stopped declaring the key); objectui#8652: 0 and objectui#8649: 0 (each struck by its landing) refusedArms: 0, // objectui#11168: slice 2 narrowed element:definition-list.columns, slice 3 object-form.layout memberPins: 0, // objectui#11168 slice 2 pinned element:definition-list.items and element:repeater ×3 } as const; @@ -1408,28 +1408,20 @@ const UNPUBLISHED_EXEMPTIONS: Record = { */ // objectui#11168 slice 1 measured every `action:button` / `action:icon` key // below through the real `SchemaRenderer` and `ActionRunner` and DECLARED the - // ones honoured on the block path — all but the three still listed here, - // which the slice held back with its measurement and a question for the - // card. The pins are + // ones honoured on the block path — all but three, which the slice held + // back with its measurement and a question for the card. The pins are // `packages/components/src/renderers/action/__tests__/action-button-icon-inputs-11168.test.tsx`. - ...owedEntries( - 'action:button', - ['endpoint'], - 'objectui#11168', - 'A SPEC KEY HELD UNPUBLISHED AFTER MEASUREMENT (slice 1): the block forwards `endpoint` and the runner\'s built-in `api` executor reads it, but the console registers its own `api` handler, which reads `target` and never `endpoint` — so on the console an `api` action with an `endpoint` sends nothing there.', - ), + // Two of the three, `endpoint` on `action:button` and on `action:icon`, left + // at the 17.6.0 bump (objectui#11438): 17.6.0 refuses `endpoint` on both + // blocks (objectstack `b3917d90`, the rename to `target`), so the entries no + // longer named a key the spec declares and `every unpublished-key exemption + // names a key the spec really declares` went red on them. One is left. ...owedEntries( 'action:button', ['undoable'], 'objectui#11168', 'A SPEC KEY HELD UNPUBLISHED AFTER MEASUREMENT (slice 1): the block forwards `undoable`, but the runner\'s `operation: update` path and the console runtime offer Undo only with a host `_rowRecord` stash this block never writes; only the record page\'s own `api` handler honours it.', ), - ...owedEntries( - 'action:icon', - ['endpoint'], - 'objectui#11168', - 'A SPEC KEY HELD UNPUBLISHED AFTER MEASUREMENT (slice 1): the block forwards `endpoint` and the runner\'s built-in `api` executor reads it, but the console registers its own `api` handler, which reads `target` and never `endpoint` — so on the console an `api` action with an `endpoint` sends nothing there.', - ), // `action:group`'s `location` / `visible` and `action:menu`'s `size` / // `visible` stood here until objectui#11168 slice 1 measured each against its // renderer through the real `SchemaRenderer` and DECLARED all four — the From 7ca5a3649e8735db398a707870a1f90a3e2c30d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 23:25:45 +0000 Subject: [PATCH 07/36] fix(i18n): recognise a served translation document by its `picklists` group, which @objectstack/spec 17.6.0 adds (objectui#11438) 17.6.0's `GetTranslationsResponseSchema.translations` carries a `picklists` group (objectstack addbbf02, the `picklist` metadata kind). The recogniser's group list is written out, and the console's `loadLanguage.test.tsx` walks the spec's list so a group the spec adds turns it red instead of a bundle carrying only that group being returned untransformed and read by nothing. Added; the transform already copies an unlisted group through onto `app`. Claude-Session: https://claude.ai/code/session_01HRYqpqGcWpJuJkDmbRF75w Co-authored-by: Claude --- packages/i18n/src/utils/spec-translations.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/i18n/src/utils/spec-translations.ts b/packages/i18n/src/utils/spec-translations.ts index d0e0fe3476..960eff7287 100644 --- a/packages/i18n/src/utils/spec-translations.ts +++ b/packages/i18n/src/utils/spec-translations.ts @@ -85,6 +85,10 @@ const SPEC_TRANSLATION_GROUPS = [ 'settings', 'metadataForms', 'settingsCommon', + // `@objectstack/spec` 17.6.0 serves `picklists` (objectstack `addbbf02`, the + // `picklist` metadata kind); added at that bump (objectui#11438). The + // transform below copies it through onto `app` like any other group. + 'picklists', ] as const; function isPlainObject(value: unknown): value is Record { From 5ef3050f16a7f663d33e99e1c44f57bf63626ae2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 23:26:18 +0000 Subject: [PATCH 08/36] test(plugin-designer): the `constructor` field-name refusal is read at its key, as @objectstack/spec 17.6.0 raises it (objectui#11438) Through 17.5.0 the spec raised the reserved-name refusal on the `fields` map (`custom @ fields`). objectstack#20997 names the key, and 17.6.0 carries it (`custom @ fields.constructor`). The pin said only this reading moves when that lands; it moves, in the instrument row and in the banner row, which now names `fields.constructor`. Claude-Session: https://claude.ai/code/session_01HRYqpqGcWpJuJkDmbRF75w Co-authored-by: Claude --- ...etadataFieldsPage.refusalIssues-11302.test.tsx | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/plugin-designer/src/MetadataFieldsPage.refusalIssues-11302.test.tsx b/packages/plugin-designer/src/MetadataFieldsPage.refusalIssues-11302.test.tsx index 9e23acd7ad..74366910dd 100644 --- a/packages/plugin-designer/src/MetadataFieldsPage.refusalIssues-11302.test.tsx +++ b/packages/plugin-designer/src/MetadataFieldsPage.refusalIssues-11302.test.tsx @@ -204,12 +204,13 @@ describe('the instrument — what the installed spec says about each probe name' expect(issues[0].message).toMatch(/Rename the key/); }); - it('`constructor`: refused today with a FIELD-LESS issue (objectstack#20997 names the key)', () => { - // The upstream pair: until objectstack#20997 lands, the reserved-name - // refusal is raised on the map, not on the key. The page renders whatever - // path it is given, so only this reading moves when that lands. + it('`constructor`: refused at its key since objectstack#20997', () => { + // The upstream pair: through 17.5.0 the reserved-name refusal was raised on + // the map, not on the key (`custom @ fields`). objectstack#20997 names the + // key, and `@objectstack/spec` 17.6.0 carries it, so this reading moved at + // that bump (objectui#11438). The page renders whatever path it is given. const issues = specIssuesFor('constructor'); - expect(issues.map((i) => `${i.code} @ ${i.path}`)).toEqual(['custom @ fields']); + expect(issues.map((i) => `${i.code} @ ${i.path}`)).toEqual(['custom @ fields.constructor']); }); }); @@ -235,13 +236,13 @@ describe('objectui#11302 · a spec-refused save shows the per-field prescription expect(shown).toMatch(/^• fields\.__proto__ — .*Rename the key\.$/); }); - it('`constructor`: today\'s field-less issue shows at the map, with its prescription', async () => { + it('`constructor`: the banner names the key, with its prescription', async () => { await addFieldThroughDrawer('constructor'); const shown = await banner(); expect(refusal).not.toBeNull(); expect(shown).toBe(refusal!.issues.map(formatMetadataIssue).join('\n')); - expect(shown).toMatch(/^• fields — .*constructor/); + expect(shown).toMatch(/^• fields\.constructor — .*constructor/); }); it('the multi-line list keeps its newlines on screen', async () => { From 74fd7ebed638fdec7442c0aba899751bc29b4791 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 23:48:24 +0000 Subject: [PATCH 09/36] docs(types,core,app-shell,plugins): restamp the installed-pin claims @objectstack/spec 17.6.0 leaves true, and ledger the four that name a past release (objectui#11438) `check:installed-pin-claims` reads every present-tense claim about the installed `@objectstack/*` version against the resolved artifact. 38 sites named 17.5.0. Each was decided, never find-and-replaced: - RESTAMPED after re-measuring the fact the sentence states against the installed 17.6.0 (each probe was also run against a 17.5.0 tarball as the control, and answered the same on both): `FieldSchema`'s `dependsOn`, `rows` and option `description` readings; `object-calendar.data` and `.calendar`; `record:details.columns`; `record:highlights` `fields[]` and `layout`; `SelectOptionSchema` labels; the `@objectstack/lint` field-rule verdict text; `referenceTo` and its four alias spellings; `CalendarConfigSchema`'s required key, options and refusal hints; `record:alert.visible`; `object-kanban`'s fifteen keys; the screen node's `waitForInput`; `object-calendar`'s ten flat members; `ObjectListViewSchema`'s members; the `view.list.navigation.view` tombstone in the gantt README pin; and the sites whose own tests measure the fact and pass on 17.6.0. - REWORDED where the fact moved: the retired-key type is the branded `[REMOVED]` mark at the pinned 17.6.0 (bare `undefined` through 17.5.0), on `retired-key-type.ts` and the five pins that cite its spellings. - LEDGERED as `historical` (four files, seven sites): lines whose version says WHEN something changed or names a release's record set, where `pinned` is an unrelated verb or the sentence sits under a dated update heading. Claude-Session: https://claude.ai/code/session_01HRYqpqGcWpJuJkDmbRF75w Co-authored-by: Claude --- .../src/views/metadata-admin/celAuthoring.ts | 2 +- ...FlowNodeInspector.declaredDefault.test.tsx | 2 +- ...low-canvas-seeds.saveErrors-10948.test.tsx | 2 +- packages/core/src/evaluator/optionRules.ts | 2 +- .../plugin-calendar/src/ObjectCalendar.tsx | 2 +- .../calendarUnionReads-8651.test.tsx | 2 +- .../src/MetadataFieldsPage.tsx | 2 +- packages/plugin-detail/src/index.tsx | 4 +-- .../formSectionGroupReference-7051.test.tsx | 2 +- .../src/readme-navigation-example.test.ts | 5 +-- packages/plugin-kanban/src/index.tsx | 3 +- .../chartconfig-aria-retired-4044.test.ts | 5 +-- ...hboard-aria-retired-contract-twins.test.ts | 9 +++--- .../object-view-unmirrored-keys-7779.test.ts | 2 +- .../record-details-top-level-9040.test.ts | 5 +-- .../types/src/__tests__/retired-key-type.ts | 18 +++++++---- .../twins-spec-by-reference-9736.test.ts | 5 +-- packages/types/src/field-types.ts | 6 ++-- packages/types/src/objectql.ts | 2 +- packages/types/src/record-components.ts | 6 ++-- packages/types/src/zod/objectql.zod.ts | 12 ++++--- scripts/check-installed-spec-pin-claims.mjs | 32 +++++++++++++++++++ 22 files changed, 87 insertions(+), 43 deletions(-) diff --git a/packages/app-shell/src/views/metadata-admin/celAuthoring.ts b/packages/app-shell/src/views/metadata-admin/celAuthoring.ts index 761ee036ae..d62c47f26b 100644 --- a/packages/app-shell/src/views/metadata-admin/celAuthoring.ts +++ b/packages/app-shell/src/views/metadata-admin/celAuthoring.ts @@ -428,7 +428,7 @@ function loadRowCanon(): Promise { * not re-derived — and the engine's own message ships with it. * * ⚠️ The reason is NOT that the engine's message refuses a `record.` - * rewrite. Measured against the installed 17.5.0, it mostly PRESCRIBES one: the + * rewrite. Measured against the installed 17.6.0, it mostly PRESCRIBES one: the * `current_user` text CONTAINS "To gate on record state, rewrite the predicate * against `record`." — one of the three remedies it offers, and NOT where it * ends (measured: `includes` true, `endsWith` false; every covered slot's text diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.declaredDefault.test.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.declaredDefault.test.tsx index 02da178e8f..768719ba0e 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.declaredDefault.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.declaredDefault.test.tsx @@ -381,7 +381,7 @@ describe('boolean: a declared defaultValue seeds the control (objectui#8451, arm // ⇒ `screen.waitForInput` is the offline undeclared boolean that sentence // says does not exist — and it is undeclared CORRECTLY, so ⛔ do not read // this as an objectui#9277-class omission waiting to be declared. Measured - // on the installed `@objectstack/spec` (17.5.0): `waitForInput` is typed + // on the installed `@objectstack/spec` (17.6.0): `waitForInput` is typed // `z.boolean().optional()` with no `.default(...)`, so an omitted key // materialises nothing and there is no spec answer for a declaration to // mirror. Declaring one here would invent a default the runtime does not diff --git a/packages/app-shell/src/views/metadata-admin/previews/flow-canvas-seeds.saveErrors-10948.test.tsx b/packages/app-shell/src/views/metadata-admin/previews/flow-canvas-seeds.saveErrors-10948.test.tsx index 72955a5a9d..54cea89d58 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/flow-canvas-seeds.saveErrors-10948.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/flow-canvas-seeds.saveErrors-10948.test.tsx @@ -2,7 +2,7 @@ /** * objectui#10948 — what the designer's live `FlowSchema` pass says about a node - * the author has ADDED but not yet configured, on the installed spec (17.5.0). + * the author has ADDED but not yet configured, on the installed spec (17.6.0). * * Spec 17.5.0 carries objectstack#20316 (a node config key its executor * contract requires, left out, and a decision branch with no `label`, are diff --git a/packages/core/src/evaluator/optionRules.ts b/packages/core/src/evaluator/optionRules.ts index 9713ef7661..0d0aa7f4ea 100644 --- a/packages/core/src/evaluator/optionRules.ts +++ b/packages/core/src/evaluator/optionRules.ts @@ -118,7 +118,7 @@ export function resolveVisibleOptions( * This is deliberately NOT the lenient renderer fallback AGENTS.md #0.1 * forbids. That rule governs metadata the contract REFUSES ("if the metadata * is off-spec, fix it at the producer"); an empty label is metadata the - * contract ACCEPTS. Measured on the installed `@objectstack/spec` 17.5.0, + * contract ACCEPTS. Measured on the installed `@objectstack/spec` 17.6.0, * `SelectOptionSchema`: * * { value: 'low', label: 'Low' } -> ACCEPT (lit control) diff --git a/packages/plugin-calendar/src/ObjectCalendar.tsx b/packages/plugin-calendar/src/ObjectCalendar.tsx index cc05ab395a..b7f1e6f997 100644 --- a/packages/plugin-calendar/src/ObjectCalendar.tsx +++ b/packages/plugin-calendar/src/ObjectCalendar.tsx @@ -1247,7 +1247,7 @@ export const ObjectCalendar: React.FC = ({ * * `@objectstack/spec`'s `CalendarConfigSchema` is a `strictObject` whose ONE * required key is `startDateField`; `titleField` is optional. Re-measured on - * the installed 17.5.0, three legs: `{}` and `{ titleField: 't' }` both fail + * the installed 17.6.0, three legs: `{}` and `{ titleField: 't' }` both fail * `invalid_type` at `startDateField`, and `{ startDateField: 'd' }` parses * CLEAN. The spec's own note on that schema names THIS renderer as the * reason — `resolveTitle` above takes an explicit `titleField` when present diff --git a/packages/plugin-calendar/src/__tests__/calendarUnionReads-8651.test.tsx b/packages/plugin-calendar/src/__tests__/calendarUnionReads-8651.test.tsx index cdeae8716c..34e64ac512 100644 --- a/packages/plugin-calendar/src/__tests__/calendarUnionReads-8651.test.tsx +++ b/packages/plugin-calendar/src/__tests__/calendarUnionReads-8651.test.tsx @@ -177,7 +177,7 @@ const LEDGERED_OTHER_CARD_READS: readonly string[] = []; * `ObjectCalendarSchema` and must stay so. * * ⚠️ The ground is NOT that the spec singles these two out. MEASURED on - * installed `@objectstack/spec` 17.5.0: `ComponentPropsMap['object-calendar']` + * installed `@objectstack/spec` 17.6.0: `ComponentPropsMap['object-calendar']` * is STRICT and declares exactly ten flat members — `calendar` `data` * `defaultView` `filter` `loading` `locale` `navigation` `objectName` `sort` * `staticData` (nine through 17.4.0; 17.5.0 added `navigation`) — diff --git a/packages/plugin-designer/src/MetadataFieldsPage.tsx b/packages/plugin-designer/src/MetadataFieldsPage.tsx index e91b206437..8accb079d8 100644 --- a/packages/plugin-designer/src/MetadataFieldsPage.tsx +++ b/packages/plugin-designer/src/MetadataFieldsPage.tsx @@ -183,7 +183,7 @@ function isDesignerAuthorableType(raw: unknown): raw is DesignerFieldType { * ## Why reading the retired spelling is a RENAME here and not a laundering * * `@objectstack/spec` treats the two as one key under two spellings and says - * so in the refusal itself. Measured on the installed 17.5.0: + * so in the refusal itself. Measured on the installed 17.6.0: * * FieldSchema.safeParse({ type: 'lookup', label: 'L', referenceTo: 'account' }) * => success = false diff --git a/packages/plugin-detail/src/index.tsx b/packages/plugin-detail/src/index.tsx index 3af75c0f1e..2f4fd90089 100644 --- a/packages/plugin-detail/src/index.tsx +++ b/packages/plugin-detail/src/index.tsx @@ -942,7 +942,7 @@ ComponentRegistry.register('alert', RecordAlertRenderer, { // through `pickLocalized`, which is exactly what these descriptions teach. // Declaring the map arm therefore adds no shape the block does not already // honour; it stops the manifest gate warning `type-mismatch` on the - // recommended write. (The row DOES exist as of the installed 17.5.0 — read + // recommended write. (The row DOES exist as of the installed 17.6.0 — read // for `visible` below, objectui#9100 — so the "no entry" reading is stale; // these two arms are unaffected either way.) { name: 'title', type: ['string', 'object'], description: 'Accepts an inline translation map ({ en, "zh-CN", … })' }, @@ -950,7 +950,7 @@ ComponentRegistry.register('alert', RecordAlertRenderer, { // objectui#9100 — the spec accepts three arms here and the renderer now // resolves all three, so a single `'string'` was the declaration-narrower- // than-the-contract family of objectui#4581, one layer up. Measured on the - // INSTALLED `@objectstack/spec` 17.5.0 (`dist/ui/index.d.ts`, the + // INSTALLED `@objectstack/spec` 17.6.0 (`dist/ui/index.d.ts`, the // `ComponentPropsMap['record:alert']` row): `visible` is // `boolean | string | { dialect: 'cel'|'cron'|'template', source?, … }`, // and `renderers/record-alert.tsx` hands whichever arrives to diff --git a/packages/plugin-form/src/__tests__/formSectionGroupReference-7051.test.tsx b/packages/plugin-form/src/__tests__/formSectionGroupReference-7051.test.tsx index f4cea796bd..2456fc3714 100644 --- a/packages/plugin-form/src/__tests__/formSectionGroupReference-7051.test.tsx +++ b/packages/plugin-form/src/__tests__/formSectionGroupReference-7051.test.tsx @@ -448,7 +448,7 @@ describe('objectui#7051 — resolveSectionGroupReferences', () => { /** * ⭐ The second axis. The card asks that group-owned presentation keys beside * `group`, and `group` on a wizard step, not be SILENTLY accepted. Measured - * against the installed `@objectstack/spec` (17.5.0): both are refused at PARSE + * against the installed `@objectstack/spec` (17.6.0): both are refused at PARSE * — a stronger door than the lint that objectui#8497 concluded owns EXISTENCE * checking, and a different one. So authored metadata carrying either never * reaches this renderer at all; the renderer's own reports above exist for the diff --git a/packages/plugin-gantt/src/readme-navigation-example.test.ts b/packages/plugin-gantt/src/readme-navigation-example.test.ts index 659fdb8198..9060060c41 100644 --- a/packages/plugin-gantt/src/readme-navigation-example.test.ts +++ b/packages/plugin-gantt/src/readme-navigation-example.test.ts @@ -79,8 +79,9 @@ * * WARNING: `declaredMembers()` cannot derive that key's retirement. When this * was written the spec in the lockfile (17.4.0) still DECLARED `view` as a live - * member. The installed `@objectstack/spec` 17.5.0 lands it as a tombstone - * (objectui#11073 re-measured: typed `never`, and `{ view: 'detail' }` is + * member. The installed `@objectstack/spec` 17.6.0 lands it as a tombstone, + * as 17.5.0 did (objectui#11073 measured it, objectui#11438 again: a `never` + * schema, and `{ view: 'detail' }` is * refused at `view` with "`view.list.navigation.view` was removed in * @objectstack/spec 17.5.0") -- but a tombstone is still a KEY of the shape, * so the key-set reading below lists it exactly as it listed the live member. diff --git a/packages/plugin-kanban/src/index.tsx b/packages/plugin-kanban/src/index.tsx index 66e0f1bd5a..4feaef6ebd 100644 --- a/packages/plugin-kanban/src/index.tsx +++ b/packages/plugin-kanban/src/index.tsx @@ -530,7 +530,8 @@ export const ObjectKanbanRenderer: React.FC<{ schema: any; [key: string]: any }> * ## Why these keys were added * * `@objectstack/spec`'s `ComponentPropsMap['object-kanban']` carries FIFTEEN - * top-level keys on the installed 17.5.0 pin, fourteen of them live: 17.5.0 + * top-level keys on the installed 17.6.0 pin, fourteen of them live, the + * same set as 17.5.0: that release * added `navigation` and turned `quickAdd` into a tombstone (typed `never`, * refused by name — objectui#8285's ruled retirement, landed). This list * published three until objectui#8186 added `filter`. ⚠️ The shape carried diff --git a/packages/types/src/__tests__/chartconfig-aria-retired-4044.test.ts b/packages/types/src/__tests__/chartconfig-aria-retired-4044.test.ts index d57757b7b5..5d1b453ebb 100644 --- a/packages/types/src/__tests__/chartconfig-aria-retired-4044.test.ts +++ b/packages/types/src/__tests__/chartconfig-aria-retired-4044.test.ts @@ -29,8 +29,9 @@ * copied here. * - **TypeScript.** `complex.ts` re-typed `chartConfig` as `any`, so an * authored `aria` compiled. The member now takes `aria` from the spec's own - * input type, which is the spec's retired-key type (`undefined` at the - * pinned 17.5.0, the branded `[REMOVED]` mark on objectstack `main`; both + * input type, which is the spec's retired-key type (the branded `[REMOVED]` + * mark at the pinned 17.6.0 and on objectstack `main`, bare `undefined` + * through 17.5.0; both * spellings are `retired-key-type.ts`'s), so an authored value is a compile * error. * The `@ts-expect-error` below is the real enforcement: this package's diff --git a/packages/types/src/__tests__/dashboard-aria-retired-contract-twins.test.ts b/packages/types/src/__tests__/dashboard-aria-retired-contract-twins.test.ts index a03e2eff95..c8ac799e20 100644 --- a/packages/types/src/__tests__/dashboard-aria-retired-contract-twins.test.ts +++ b/packages/types/src/__tests__/dashboard-aria-retired-contract-twins.test.ts @@ -29,8 +29,8 @@ * objectui#9736 closed that half. The interface now extends the spec's own * `Dashboard` input type (`Omit< Dashboard, … >` over the exclusion list its * mirror reads), so it inherits the spec's tombstone as a DECLARED member typed - * as the spec's retired key (`undefined` at the pinned 17.5.0, the branded - * `[REMOVED]` mark on objectstack `main`) — `aria` is back in the declared key + * as the spec's retired key (the branded `[REMOVED]` mark at the pinned 17.6.0 + * and on objectstack `main`, bare `undefined` through 17.5.0) — `aria` is back in the declared key * set, but only as a refusal, * and an authored value is a compile error: the same verdict the Zod twin gives * at parse. The pins below assert exactly that, with the former neighbours as @@ -52,8 +52,9 @@ describe('the TS interface declares `aria` only as the spec tombstone (objectui# it('`aria` is declared, typed as the spec\'s retired key; the neighbours it stood beside still are', () => { // Type-level pins, erased at runtime. `aria` is a DECLARED key again — the // spec projection carries the tombstone — and its type admits no value: - // `undefined` at the pinned 17.5.0, the branded `[REMOVED]` mark on - // objectstack `main` (both spellings are `retired-key-type.ts`'s, objectui#11330). + // the branded `[REMOVED]` mark at the pinned 17.6.0 and on objectstack + // `main`, bare `undefined` through 17.5.0 (both spellings are + // `retired-key-type.ts`'s, objectui#11330). const ariaDeclared: 'aria' extends Declared ? true : false = true; const ariaAdmitsNoValue: IsRetiredKeyType extends true ? true : false = true; // The control for the line above, through the same helper: a live diff --git a/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts b/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts index 667e9822a2..8abb5fdd9c 100644 --- a/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts +++ b/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts @@ -337,7 +337,7 @@ const NAMED_VIEW_ABSENT_CONTROL = 'stickyHeader'; /** * The protocol's own member set for a named list view — `ObjectListViewSchema`'s - * shape, off `@objectstack/spec` as installed (17.5.0). ⛔ Not a copy of the + * shape, off `@objectstack/spec` as installed (17.6.0). ⛔ Not a copy of the * objectui face and ⛔ not a hand list: it is read off the schema object, so a * spec bump that moves a key moves this. */ diff --git a/packages/types/src/__tests__/record-details-top-level-9040.test.ts b/packages/types/src/__tests__/record-details-top-level-9040.test.ts index 648f175a07..222bdf1a85 100644 --- a/packages/types/src/__tests__/record-details-top-level-9040.test.ts +++ b/packages/types/src/__tests__/record-details-top-level-9040.test.ts @@ -173,8 +173,9 @@ type _KeyofStillResolvesOnThisInterface = Expect< /** * The contract's face, untouched by this removal: the tombstone accepts nothing. - * Its type is the spec's retired-key type — `undefined` at the pinned 17.5.0, - * the branded `[REMOVED]` mark on objectstack `main` — and the helper accepts + * Its type is the spec's retired-key type — the branded `[REMOVED]` mark at the + * pinned 17.6.0 and on objectstack `main`, + * bare `undefined` through 17.5.0 — and the helper accepts * exactly those two spellings (objectui#11330). */ type _LayoutOnTheContractFace = Expect>; diff --git a/packages/types/src/__tests__/retired-key-type.ts b/packages/types/src/__tests__/retired-key-type.ts index 1e4c433200..ecab582f8d 100644 --- a/packages/types/src/__tests__/retired-key-type.ts +++ b/packages/types/src/__tests__/retired-key-type.ts @@ -12,13 +12,15 @@ * package's build, and nothing re-exports this module. * * `@objectstack/spec` declares a removed key with `retiredKey()`, and the type - * that tombstone gives the key has two spellings, both of which these pins - * must compile against — the pinned spec in every ordinary job, and the spec - * built from objectstack `main` in `Spec Main Shape Gate`: + * that tombstone gives the key has had two spellings. These pins were written + * to compile against both — the pinned spec in every ordinary job, and the + * spec built from objectstack `main` in `Spec Main Shape Gate` — while the two + * disagreed: * - * - **The pinned 17.5.0 spelling: bare `undefined`** — the input type of + * - **Through 17.5.0: bare `undefined`** — the input type of * `z.never().optional()`. The key admits nothing but absence. - * - **objectstack `main` since `d830d71f` (objectstack#21023): a branded + * - **objectstack `main` since `d830d71f` (objectstack#21023), and the pinned + * 17.6.0 (objectui#11438): a branded * mark** — an inline, anonymous object type whose ONE property is named by * the retirement sentence and typed `never`, under the optional wrapper: * MARK or `undefined`, where MARK reads @@ -38,8 +40,10 @@ * prefix). The direction proofs at the foot of this module are what hold that * line: loosen the helper toward "anything" and one of them stops compiling. * - * When the pin moves past a release that carries the mark, the `undefined` - * arm describes no installed spec any more and can be dropped. + * The pin moved past a release that carries the mark at 17.6.0, so the bare + * `undefined` arm describes no installed spec any more and can be dropped. The + * bump that moved the pin (objectui#11438) kept it: dropping it tightens the + * helper every retirement pin reads, which is a change of its own. */ /** Invariant type equality. `A extends B` is NOT this: `never` and `any` pass that. */ diff --git a/packages/types/src/__tests__/twins-spec-by-reference-9736.test.ts b/packages/types/src/__tests__/twins-spec-by-reference-9736.test.ts index 588fd8b009..5f31bf2698 100644 --- a/packages/types/src/__tests__/twins-spec-by-reference-9736.test.ts +++ b/packages/types/src/__tests__/twins-spec-by-reference-9736.test.ts @@ -123,8 +123,9 @@ describe('spec tombstones surface on the twin as a refusal — the verdict the m // (ADR-0090 D2 deleted the Profile concept). Both faces take it by // reference, so the twin's member IS the spec's, and it admits no value. const isSpecMember: Equal = true; - // The spec's retired-key type: `undefined` at the pinned 17.5.0, the branded - // `[REMOVED]` mark on objectstack `main` (objectui#11330). + // The spec's retired-key type: the branded `[REMOVED]` mark at the pinned + // 17.6.0 and on objectstack `main`, bare `undefined` through 17.5.0 + // (objectui#11330). const admitsNoValue: Equal, true> = true; // The control for the line above, through the same helper: an admitted // spec key on the same twin is NOT a retired-key type. diff --git a/packages/types/src/field-types.ts b/packages/types/src/field-types.ts index 00c46b2e92..64be46b213 100644 --- a/packages/types/src/field-types.ts +++ b/packages/types/src/field-types.ts @@ -25,7 +25,7 @@ import type { Field as SpecField } from '@objectstack/spec/data'; * sibling field names, or `{ field, param }` entries mapping a sibling onto the * remote query parameter a dependent lookup filters by. * - * Measured on the installed `@objectstack/spec` (17.5.0), `FieldSchema` declares + * Measured on the installed `@objectstack/spec` (17.6.0), `FieldSchema` declares * `dependsOn` as an OPTIONAL ARRAY of `string | { field, param? }` — never a * bare string. That is deliberately narrower than `DependsOnInput` (`form.ts`), * the shape the widget prop `FieldWidgetComponentProps.dependsOn` and @@ -288,7 +288,7 @@ export interface MarkdownFieldMetadata extends BaseFieldMetadata { * annotated literal rejected. Follows the `TextareaFieldMetadata` precedent. * * A DECLARED spec key as of `@objectstack/spec` 17.3.0, which implements that - * same ruling. Measured on the installed `@objectstack/spec` 17.5.0: + * same ruling. Measured on the installed `@objectstack/spec` 17.6.0: * `FieldSchema` ACCEPTS `rows` on all four of textarea/markdown/html/ * richtext, as an integer of at least 1 — a non-integer answers * `invalid_type` and 0 answers `too_small`, so "declared" does not mean "any @@ -572,7 +572,7 @@ export interface SelectOptionMetadata extends SelectOptionBase { * * A DECLARED `SelectOptionSchema` key as of `@objectstack/spec` 17.3.0, * which implements the objectui#6153 half of that ruling. Measured on the - * installed `@objectstack/spec` 17.5.0: an option carrying `description` + * installed `@objectstack/spec` 17.6.0: an option carrying `description` * is ACCEPTED, as a string (a non-string answers `invalid_type`; the empty * string is valid), and a field whose `options` carry it parses whole. ⇒ it * may now be written into authored object metadata, which is the point of diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index c234996590..4a43b34ac1 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -4028,7 +4028,7 @@ export interface ObjectCalendarSchema extends BaseSchema { * (the spec's own `ObjectCalendarPropsSchema`) declares * `z.array(z.unknown()).optional()`, described *"Pre-fetched records — skips * the internal fetch"*. - * MEASURED on the installed artifact at `@objectstack/spec` 17.5.0 — the + * MEASURED on the installed artifact at `@objectstack/spec` 17.6.0 — the * version this repository's `pnpm-lock.yaml` resolves — through the published * `@objectstack/spec/ui` entry point: the provider block returns * `success=false` with `expected: 'array'` at `path: ['data']`, the array diff --git a/packages/types/src/record-components.ts b/packages/types/src/record-components.ts index 53e50e9845..3e43567ed4 100644 --- a/packages/types/src/record-components.ts +++ b/packages/types/src/record-components.ts @@ -107,7 +107,7 @@ export interface RecordDetailsComponentProps { * It was `number` here until objectui#8604, which is the wrong PRIMITIVE * TYPE, not merely a wider range: `{ columns: 2 }` compiled locally and the * contract refused it at publish with `invalid_value` at `columns` (measured - * on the installed pin, 17.5.0, against a control — `columns: '2'` — that + * on the installed pin, 17.6.0, against a control — `columns: '2'` — that * parses green on the same instrument). Contract-first (Commandment #0.1): * the code moves to the contract's spelling, and today's `columns: 2` * authors are the defect surfacing rather than collateral damage. @@ -334,7 +334,7 @@ export interface RecordHighlightsComponentProps { * `RecordHighlightsProps.fields[]`'s object arm declares exactly * `name`/`label`/`type`/`readonly` and carries a `never` catchall, i.e. it is * `$strict`: an unlisted key is REFUSED, not stripped, and the refusal takes - * the WHOLE document with it. Measured on the installed pin, 17.5.0, + * the WHOLE document with it. Measured on the installed pin, 17.6.0, * `RecordHighlightsProps.safeParse({ fields: [{ name: 'x', icon: 'star' }] })` * is RED with `invalid_union` at `fields.0`. So `{ name: 'amount', icon: * 'dollar-sign' }` type-checked here and was refused at the door — a green @@ -366,7 +366,7 @@ export interface RecordHighlightsComponentProps { * `z.enum(['horizontal','vertical'])` behind a `.default('horizontal')`). * * It offered a third value, `grid`, until objectui#9187, and the contract - * never accepted it: measured on the installed pin, 17.5.0, + * never accepted it: measured on the installed pin, 17.6.0, * `RecordHighlightsProps.safeParse({ fields: ['name'], layout: 'grid' })` is * RED with `invalid_value` at `layout`. So `{ layout: 'grid' }` type-checked * here and was refused at the door — a green local build and a rejection at diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index d416cad0c6..dad807927c 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -1511,8 +1511,9 @@ const KanbanConfig = stripImportedDefaults(SpecKanbanConfigSchema).partial().ext * alias table points this spelling at the END of the event" and that was WRONG * about the protocol; the corrected mechanism, re-derived by RUNNING * `@objectstack/spec` 17.4.0 (the version in the lockfile then) rather than - * reading it — and RE-RUN on the installed 17.5.0 (objectui#11073), whose - * answer moved, as the last bullet says: + * reading it — RE-RUN on 17.5.0 (objectui#11073), whose answer moved, as the + * last bullet says, and RE-RUN again on the installed 17.6.0 (objectui#11438), + * which answers every bullet below as 17.5.0 did: * * - `CalendarConfigSchema`'s `strictObject` options carry `surface` and * `history` and NOTHING ELSE. There is no `aliases` entry, so upstream holds @@ -1535,7 +1536,8 @@ const KanbanConfig = stripImportedDefaults(SpecKanbanConfigSchema).partial().ext * not say which end of the range it binds … Write the one you mean") and * prescribes neither. `endField` still draws no hint, and a one-char typo * still resolves by distance (`titleFeld` → `titleField`) — all three - * measured on the installed 17.5.0. + * measured on 17.5.0, and again on the + * installed 17.6.0 (objectui#11438). * * ⇒ through 17.4.0 a generic typo-distance suggester picked the wrong sibling. * It was not a declaration, it contradicted no declaration, and ⛔ no upstream @@ -1732,8 +1734,8 @@ const CalendarConfig = stripImportedDefaults(SpecCalendarConfigSchema).partial() * gate's own failure mode one layer in. * * ⚠️ THE MEMBER LIST IS objectui's OWN, and the spec does NOT supply it. - * MEASURED on the installed `@objectstack/spec` 17.5.0 (the same answer - * 17.4.0 gave): + * MEASURED on the installed `@objectstack/spec` 17.6.0 (the same answer + * 17.5.0 and 17.4.0 gave): * `ComponentPropsMap['object-calendar'].calendar` is NOT `CalendarConfigSchema` * — it is `z.unknown().optional()` (wrapper chain `["optional","unknown"]`, and * not the same object reference), so at THIS position the protocol accepts diff --git a/scripts/check-installed-spec-pin-claims.mjs b/scripts/check-installed-spec-pin-claims.mjs index 90ba27f32c..ea9e11c178 100644 --- a/scripts/check-installed-spec-pin-claims.mjs +++ b/scripts/check-installed-spec-pin-claims.mjs @@ -470,6 +470,14 @@ export const LEDGER = [ class: "stale", why: "\"MEASURED on the installed @objectstack/spec 17.3.0 artifact\" \u2014 re-parse `record:activity.types` against the resolved artifact before moving the stamp.", }, + { + file: "apps/console/src/__tests__/registry-inputs-spec-parity.test.ts", + package: "@objectstack/spec", + version: "17.5.0", + sites: 4, + class: "historical", + why: "Four lines about the release, not the install. Two ask whether the installed spec carries \"the 17.5.0 set\" / \"the loaded 17.5.0 blocks\" (the version names a record SET, as the 17.1.0 entry above does). Two are `pins` prose whose `pinned` is an unrelated verb (\"pinned narrowly\", \"pinned as THE RESIDUAL LEG\") beside a WHEN sentence (`allDayField` \"since `@objectstack/spec` 17.5.0\"; `redactFields` \"until `@objectstack/spec` 17.5.0 declared it\"). None restamps (objectui#11438).", + }, { file: "apps/console/src/components/FormPage.tsx", package: "@objectstack/spec", @@ -574,6 +582,14 @@ export const LEDGER = [ class: "historical", why: "Same line, the other version: a verbatim quotation of the justification this code REPLACED \u2014 \"`I18nLabel` was plain `string` through 17.0.0-rc.5\". Quoting a retired justification is history.", }, + { + file: "packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.declaredDefault.test.tsx", + package: "@objectstack/spec", + version: "17.5.0", + sites: 1, + class: "historical", + why: "\"the nine the installed spec applied; `@objectstack/spec` 17.5.0 began applying `screen.mode`'s\" says WHEN the tenth declaration returned (objectui#11073). The installed-spec reading on the same line has no version. Never restamp (objectui#11438).", + }, { file: "packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.sectionName.test.tsx", package: "@objectstack/spec", @@ -750,6 +766,22 @@ export const LEDGER = [ class: "historical", why: "\"`publicPicker` arrives in @objectstack/spec 17.0.0 GA\" \u2014 the arrival release. \u26a0\ufe0f The stale claim in the SAME docblock (\"this repo is pinned to `^17.0.0-rc.6`\") is a RANGE and therefore outside this gate's predicate by construction; it is recorded in objectui#8924 rather than silently covered here.", }, + { + file: "packages/plugin-grid/src/__tests__/gridNavigationMembers-8071.test.tsx", + package: "@objectstack/spec", + version: "17.5.0", + sites: 1, + class: "historical", + why: "\"the row that pinned this**: `@objectstack/spec` 17.5.0 retired `view.list.navigation.view`\" names the release that DID the retirement (objectui#9874); `pinned` is the row's verb, not a claim about the install. Never restamp (objectui#11438).", + }, + { + file: "packages/plugin-tree/src/ObjectTree.schemaTyped-8655.test.ts", + package: "@objectstack/spec", + version: "17.5.0", + sites: 1, + class: "historical", + why: "\"Re-measured on the installed 17.5.0:\" sits under the dated heading \"UPDATE at `@objectstack/spec` 17.5.0 (objectui#11073)\" and records that bump's re-measurement; the rows below it re-derive the readings from the installed spec at run time. Never restamp (objectui#11438).", + }, { file: "packages/react/src/hooks/__tests__/offline-nav-performance-spec-parity.test.ts", package: "@objectstack/spec", From d73f74e9bfb1b01b167af98cfc079eec6935e294 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 23:57:14 +0000 Subject: [PATCH 10/36] chore(changeset): declare the @objectstack/* 17.6.0 bump (objectui#11438) Claude-Session: https://claude.ai/code/session_01HRYqpqGcWpJuJkDmbRF75w Co-authored-by: Claude --- .changeset/11438-objectstack-17-6-bump.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .changeset/11438-objectstack-17-6-bump.md diff --git a/.changeset/11438-objectstack-17-6-bump.md b/.changeset/11438-objectstack-17-6-bump.md new file mode 100644 index 0000000000..c581d96aee --- /dev/null +++ b/.changeset/11438-objectstack-17-6-bump.md @@ -0,0 +1,11 @@ +--- +'@object-ui/components': patch +'@object-ui/i18n': patch +'@object-ui/console': patch +--- + +objectui now resolves `@objectstack/*` 17.6.0 (objectui#11438). No declared range moves: every `@objectstack/*` range already admits 17.6.0, and `check:spec-floors` finds no floor that has to rise. Everything below is a contract move 17.6.0 made, and it already reaches any consumer that resolves `@objectstack/spec ^17.6`. + +- `@object-ui/components`: the `page:header` registration no longer publishes a `breadcrumb` input. 17.6.0 retires the key (objectstack#20758) and refuses it by name; the renderer has ignored it since objectui#11166. +- `@object-ui/i18n`: a served translation document is recognised as a spec payload when it carries only the `picklists` group, which 17.6.0's `GetTranslationsResponseSchema` adds. Such a bundle used to be returned untransformed, so nothing read it. The group is now namespaced under `app` like every other group. +- `@object-ui/console`: the bundle inlines the 17.6.0 packages, so its client-side validation answers as a 17.6.0 server does. From 8879fe507e495ee456f2b8b630dd2551e02badd1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 00:07:27 +0000 Subject: [PATCH 11/36] test(scripts,app-shell,console): re-pin the counts and keys @objectstack/spec 17.6.0 moves, and pair the live-e2e backend with it (objectui#11438) Each pin names what moved, measured against a 17.5.0 control: - `ObjectSchema`'s accepted keys are 44, not 43: 17.6.0 adopts `imageField` (gained set exactly that one, lost set empty). Both corpus-guard counts are re-pointed as the 17.3.0 adoption was. - The spec's exports map has 21 entries, not 20: `./migrations` is added (no `browser` arm), nothing removed. Every count and its prose follow. - `check-lockfile-integrity`'s two lockfile samples are rebuilt on the 17.6.0 snapshot keys their own tripwire named. - The objectui#11111 owner total for objectui#11168 follows the cap to 1 (the two `endpoint` entries struck earlier on this branch). - `e2e/live/ci/backend.env` pins `OBJECTSTACK_VERSION=17.6.0`, the version the lockfile resolves, as the pin requires (`@objectstack/cli@17.6.0` resolves to the 17.6.0 tag commit). - The page-header doc drops the version literal it gained on this branch; `doc-version-claims` holds docs to pointers, not version numbers. Claude-Session: https://claude.ai/code/session_01HRYqpqGcWpJuJkDmbRF75w Co-authored-by: Claude --- .../registry-inputs-spec-parity.test.ts | 2 +- content/docs/layout/page-header.mdx | 4 +-- e2e/live/ci/backend.env | 2 +- ...tadataService.retiredObjectEnabled.test.ts | 5 +++- ...tadataService.specKeyObjectPayload.test.ts | 5 +++- .../check-lockfile-integrity.test.ts | 14 +++++----- .../vite-objectstack-spec-dist.test.ts | 26 +++++++++++-------- 7 files changed, 34 insertions(+), 24 deletions(-) diff --git a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts index 6c72242ed3..2217cb08b6 100644 --- a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts +++ b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts @@ -5787,6 +5787,6 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide) reasons.filter((reason) => reason.startsWith(`${OWED_PREFIX}${owner}.`)).length, ]), ), - ).toEqual({ 'objectui#11168': 3, 'objectui#8652': 0, 'objectui#8649': 0 }); + ).toEqual({ 'objectui#11168': 1, 'objectui#8652': 0, 'objectui#8649': 0 }); }); }); diff --git a/content/docs/layout/page-header.mdx b/content/docs/layout/page-header.mdx index 5bb7ecbdc5..bb7d561b11 100644 --- a/content/docs/layout/page-header.mdx +++ b/content/docs/layout/page-header.mdx @@ -67,8 +67,8 @@ renders none of them and says so once per id in the console. That is why the dem carries no actions. Placement (`locations: ['record_header']` or `record_more`), ordering and the overflow menu are covered in [Slotted pages](/docs/guide/slotted-pages). -**`breadcrumb` is not a key of this node.** `@objectstack/spec` 17.6.0 retired it -(objectstack#20758) and the contract refuses it by name. It never drew anything: the renderer +**`breadcrumb` is not a key of this node.** The contract retired it (objectstack#20758) and +refuses it by name. It never drew anything: the renderer ignores it, so a stored `true` or `false` renders the same header as an absent key. **`maxVisible` / `mobileMaxVisible`** must be positive integers: the contract rejects `0` diff --git a/e2e/live/ci/backend.env b/e2e/live/ci/backend.env index 95c134de73..790433aa58 100644 --- a/e2e/live/ci/backend.env +++ b/e2e/live/ci/backend.env @@ -14,7 +14,7 @@ # # This file declares exactly ONE @objectstack pin, and that is the whole # design: the commit is derived from it, see below. -OBJECTSTACK_VERSION=17.5.0 +OBJECTSTACK_VERSION=17.6.0 # There is deliberately no OBJECTSTACK_REF key here. The objectstack-ai/objectstack # commit the showcase app metadata is sparse-checked-out from is DERIVED at diff --git a/packages/app-shell/src/services/MetadataService.retiredObjectEnabled.test.ts b/packages/app-shell/src/services/MetadataService.retiredObjectEnabled.test.ts index 88c7aca355..856c4d5eaa 100644 --- a/packages/app-shell/src/services/MetadataService.retiredObjectEnabled.test.ts +++ b/packages/app-shell/src/services/MetadataService.retiredObjectEnabled.test.ts @@ -143,8 +143,11 @@ describe('objectui#6238 · the schema oracle — both tombstone keys are refused // `packages/types/src/__tests__/object-schema-metadata-spec-derivation.test.ts`; // the count rides here only as the corpus guard for the membership claims // below, so it is re-pointed, not weakened. + // 44 at `@objectstack/spec` 17.6.0, which adopted `imageField` (measured against + // a 17.5.0 control: gained set exactly `['imageField']`, lost set empty; + // objectui#11438). Re-pointed as the 17.3.0 adoption was, not weakened. const accept = new Set(Object.keys(ObjectSchema.shape as Record)); - expect(accept.size).toBe(43); + expect(accept.size).toBe(44); expect(accept.has('enable')).toBe(true); expect(accept.has('enabled')).toBe(false); expect(accept.has('_deleted')).toBe(false); diff --git a/packages/app-shell/src/services/MetadataService.specKeyObjectPayload.test.ts b/packages/app-shell/src/services/MetadataService.specKeyObjectPayload.test.ts index 15d7eeec33..c1633d2aad 100644 --- a/packages/app-shell/src/services/MetadataService.specKeyObjectPayload.test.ts +++ b/packages/app-shell/src/services/MetadataService.specKeyObjectPayload.test.ts @@ -157,8 +157,11 @@ describe('the instrument', () => { // gained set exactly `['editMode']`, lost set empty). The count is this // file's corpus guard, not its subject; every claim it guards is below and // unchanged. + // 44 at `@objectstack/spec` 17.6.0, which adopted `imageField` (measured against + // a 17.5.0 control: gained set exactly `['imageField']`, lost set empty; + // objectui#11438). Re-pointed as the 17.3.0 adoption was, not weakened. const accept = new Set(Object.keys(ObjectSchema.shape as Record)); - expect(accept.size).toBe(43); + expect(accept.size).toBe(44); // `fieldGroups` is the only grouping key on the object, and it groups the // FIELDS INSIDE one object — it is not a category for objects themselves, // so `group` has no mapping target here. diff --git a/scripts/__tests__/check-lockfile-integrity.test.ts b/scripts/__tests__/check-lockfile-integrity.test.ts index 23774c4575..08f41d675e 100644 --- a/scripts/__tests__/check-lockfile-integrity.test.ts +++ b/scripts/__tests__/check-lockfile-integrity.test.ts @@ -89,17 +89,17 @@ describe('green on main — the negative control', () => { * * ⛔ It cannot be produced by running the resolver, and that impossibility is * the card's central claim: every workspace range on `@objectstack/*` is a - * floating `^17.x` whose floor is at or above the resolved 17.5.0 (objectui#11073; - * 17.4.0 when this sample was written), so no fresh resolve can pick 17.2.0. The sample is therefore a transformation of the real lockfile + * floating `^17.x` that a fresh resolve satisfies with the newest release, 17.6.0 today + * (objectui#11438; 17.4.0 when this sample was written), so no fresh resolve can pick 17.2.0. The sample is therefore a transformation of the real lockfile * reproducing the identity facts measured on PRs #7053 / #7058 — the family * down to 17.2.0, and `@objectstack/spec` resolved at BOTH versions. */ function sampleA(): string { let out = realLock; for (const pkg of ['client', 'core', 'formula', 'lint', 'sdui-parser']) { - out = out.replace(new RegExp(`(@objectstack/${pkg}@)17\\.5\\.0`, 'g'), '$117.2.0'); + out = out.replace(new RegExp(`(@objectstack/${pkg}@)17\\.6\\.0`, 'g'), '$117.2.0'); } - const specKey = " '@objectstack/spec@17.5.0(ai@7.0.65(zod@4.6.5))':"; + const specKey = " '@objectstack/spec@17.6.0(ai@7.0.65(zod@4.6.5))':"; expect(out, 'the spec snapshot key moved — rebuild this sample before trusting it').toContain( specKey, ); @@ -123,8 +123,8 @@ function sampleA(): string { function sampleB(): string { let out = realLock; const forked = [ - " '@objectstack/spec@17.5.0(ai@7.0.65(zod@4.6.5))':", - " '@objectstack/formula@17.5.0(ai@7.0.65(zod@4.6.5))':", + " '@objectstack/spec@17.6.0(ai@7.0.65(zod@4.6.5))':", + " '@objectstack/formula@17.6.0(ai@7.0.65(zod@4.6.5))':", " ai@7.0.65(zod@4.6.5):", ]; for (const key of forked) { @@ -190,7 +190,7 @@ describe('cause 2 — a declared dependency forks with no version moving (object expect(spec?.headCount).toBe(2); // This is #8326's exact bundling mechanism — two real paths, nothing // dedupes them — arriving with no version change anywhere. - expect(spec?.head.every((k) => k.includes('@17.5.0'))).toBe(true); + expect(spec?.head.every((k) => k.includes('@17.6.0'))).toBe(true); }); }); diff --git a/scripts/__tests__/vite-objectstack-spec-dist.test.ts b/scripts/__tests__/vite-objectstack-spec-dist.test.ts index 086e214c1a..e8d937a04a 100644 --- a/scripts/__tests__/vite-objectstack-spec-dist.test.ts +++ b/scripts/__tests__/vite-objectstack-spec-dist.test.ts @@ -30,7 +30,7 @@ import { * * 1. **Set → every subpath is mapped.** The client hook this mirrors is one * prefix alias, which is safe only because `@objectstack/client` exports a - * single entry. The spec's map has 20 and redirects each into `dist/`, so a + * single entry. The spec's map has 21 and redirects each into `dist/`, so a * copied client line rewrites `@objectstack/spec/ui` to a path that does not * exist — measured as 214 broken import sites for `/ui` alone. The * reconciliation case below therefore checks the derivation against Node's @@ -260,7 +260,7 @@ describe('objectui#4854: OBJECTSTACK_SPEC_DIST is subpath-aware', () => { ) as { exports: Record }; const declared = Object.keys(manifest.exports); - // Anti-vacuity: the map this is reconciled against is the measured 20-entry + // Anti-vacuity: the map this is reconciled against is the measured 21-entry // one, not an empty object a silently-changed reader would also "cover". // 18 -> 19 on the @objectstack/spec 17.2.0 refresh (objectui#5668): the // one added subpath, measured by diffing 17.1.0's exports map against @@ -272,7 +272,11 @@ describe('objectui#4854: OBJECTSTACK_SPEC_DIST is subpath-aware', () => { // REMOVED (the last step of the objectstack#16325 chain objectui#8225 names // below), `./api-assembled` and `./marketplace` were ADDED. The pin did its // job again: the un-updated 19 turned this red on the bump's CI. - expect(declared.length).toBe(20); + // 20 -> 21 on the @objectstack/spec 17.6.0 refresh (objectui#11438), + // measured by diffing 17.5.0's exports map against 17.6.0's: `./migrations` + // was ADDED (the spec's migrations-entry-split; it declares no `browser` + // arm), nothing was removed. The un-updated 20 turned this red on the bump. + expect(declared.length).toBe(21); expect(Object.keys(injection!.aliases).length).toBe(declared.length); // objectui#9408 — the population splits, and WHICH oracle applies is the @@ -294,7 +298,7 @@ describe('objectui#4854: OBJECTSTACK_SPEC_DIST is subpath-aware', () => { // `./system`, `./kernel`, `./api-assembled`). Still 5 after the 17.5.0 // refresh (objectui#11073) but NOT the same 5: 17.4.0's set carried // `./cloud`, which left with its subpath, and the new `./api-assembled` - // arrived browser-first. Pinned exactly, like the 20 above, + // arrived browser-first. Pinned exactly, like the 21 above, // because an unpinned browser set is precisely how this went unnoticed: // objectui#9408 was FILED naming `./api` as browser-first, and by the time // it was worked `./api` had lost its browser arm upstream with nothing @@ -338,8 +342,8 @@ describe('objectui#4854: OBJECTSTACK_SPEC_DIST is subpath-aware', () => { it('agrees with a REAL Vite build on every entry, `browser` arm included', async () => { // The oracle that actually counts. Vite is the resolver this hook MODELS, - // and unlike Node it satisfies `browser`, so it can answer for all 20 - // entries where Node can only answer for 15. Run through a real build + // and unlike Node it satisfies `browser`, so it can answer for all 21 + // entries where Node can only answer for 16. Run through a real build // rather than a transcription, for the same reason the alias-matcher cases // below bundle for real: a transcribed algorithm agrees with its own // transcription, not with Vite. @@ -365,7 +369,7 @@ describe('objectui#4854: OBJECTSTACK_SPEC_DIST is subpath-aware', () => { expect(disagreed, 'entries where the hook and Vite pick different files').toEqual([]); // Anti-vacuity: the sweep really did exercise the browser arm, i.e. the - // agreement above is not agreement about 20 Node-arm files. + // agreement above is not agreement about 21 Node-arm files. const browserArm = specifiers.filter((s) => resolved.get(s)?.includes(`${path.sep}browser${path.sep}`)); expect(browserArm.length).toBe(5); }); @@ -861,7 +865,7 @@ describe('objectui#9408: the exports map declares precedence, not this module', // would surface as a bundler error at a pin bump nobody connects to this // file. A report that omitted the decided entries would leave that intact. const injection = inject(installedSpecDir)!; - expect(injection.resolutions).toHaveLength(20); + expect(injection.resolutions).toHaveLength(21); const report = formatConditionReport(injection).join('\n'); expect(report).toContain(injection.packageDir); @@ -890,7 +894,7 @@ describe('objectui#9408: the exports map declares precedence, not this module', // Anti-vacuity on the other half: entries with no choice to make are still // reported, with their arm, rather than silently dropped. const forced = injection.resolutions.filter((r) => r.passedOver.length === 0); - expect(forced.length).toBe(20 - decided.length); + expect(forced.length).toBe(21 - decided.length); expect(forced.length).toBeGreaterThan(0); }); @@ -967,7 +971,7 @@ describe('objectui#4854: the four flagged surfaces in the console config', () => const injectedSpecKeys = Object.keys(injected.resolve.alias).filter((k: string) => k === SPEC_PACKAGE_NAME || k.startsWith(`${SPEC_PACKAGE_NAME}/`) ); - expect(injectedSpecKeys).toHaveLength(20); + expect(injectedSpecKeys).toHaveLength(21); expect(injectedSpecKeys[injectedSpecKeys.length - 1]).toBe(SPEC_PACKAGE_NAME); expect(injected.resolve.alias[`${SPEC_PACKAGE_NAME}/ui`]).toBe( path.join(fs.realpathSync(installedSpecDir), 'dist/ui/index.mjs') @@ -1214,7 +1218,7 @@ function consoleShapedBundle( * (`/…/objectstack/packages/spec`, or `/home/runner/work/objectstack/objectstack/ * packages/spec` on CI) has no `@objectstack` segment anywhere, and that is the * one property this fixture has to reproduce. It stays minimal on purpose: the - * exports-map derivation is covered above against the real 20-entry map, and + * exports-map derivation is covered above against the real 21-entry map, and * what these cases need is a legal package at a path of the wrong SHAPE. */ function makeOutOfTreeSpecPackage(): string { From 31c6905a3bbcb8de0b8cadfe9cffcad6fd8ae3d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 00:07:27 +0000 Subject: [PATCH 12/36] test(types): object-grid's bag `grouping` is judged since @objectstack/spec 17.6.0, so it joins the covered arms (objectui#11438) 17.6.0 types `ComponentPropsMap['object-grid'].grouping` as the grouping config instead of `z.unknown()`. The authored arm's `properties` is that row by reference, so a padded grouping field name is now refused at its entry on all four faces. The census's recorded reason for leaving the bag member uncovered stopped holding; it moves to the declaring table and is pinned like every other arm. The node-level `grouping` stays uncovered: it is the flat spelling's by-name refusal. Claude-Session: https://claude.ai/code/session_01HRYqpqGcWpJuJkDmbRF75w Co-authored-by: Claude --- ...grouping-field-padded-refused-7347.test.ts | 40 +++++++++++-------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/packages/types/src/__tests__/grouping-field-padded-refused-7347.test.ts b/packages/types/src/__tests__/grouping-field-padded-refused-7347.test.ts index 7498d201a5..94d9aa4357 100644 --- a/packages/types/src/__tests__/grouping-field-padded-refused-7347.test.ts +++ b/packages/types/src/__tests__/grouping-field-padded-refused-7347.test.ts @@ -66,6 +66,7 @@ import { GroupingFieldSchema as SpecGroupingFieldSchema } from '@objectstack/spe import { AnyComponentSchema, ListViewSchema, + ObjectGridBlockSchema, ObjectGallerySchema, ObjectViewSchema, StrictAnyComponentSchema, @@ -135,6 +136,18 @@ const DECLARING: Readonly> = { doc: (fields) => ({ type: 'object-view', objectName: 'account', table: { grouping: { fields } } }), fieldPath: (i) => ['table', 'grouping', 'fields', i, 'field'], }, + // `@objectstack/spec` 17.6.0 types `ComponentPropsMap['object-grid'].grouping` as + // the grouping config instead of `z.unknown()`, and the authored arm's `properties` + // is that row by reference, so the bag now judges the shape: a padded name is + // refused at its entry on every face. It moved here from `NOT_COVERED` at that + // bump (objectui#11438), when the row below stopped holding. + 'object-grid properties': { + type: 'object-grid', + declaredAt: 'properties.grouping', + arm: ObjectGridBlockSchema, + doc: (fields) => ({ type: 'object-grid', properties: { objectName: 'account', grouping: { fields } } }), + fieldPath: (i) => ['properties', 'grouping', 'fields', i, 'field'], + }, }; /** @@ -142,16 +155,15 @@ const DECLARING: Readonly> = { * with the reason and the paths the census records — so an arm cannot slip in * here silently, and a reason that stops holding turns the rows below red. * - * `object-grid` (objectui#11276's `object-grid` batch): the authored arm's - * `properties` is `@objectstack/spec`'s `ComponentPropsMap['object-grid']` row - * by reference, and the row types its `grouping` member as `z.unknown()`, so no - * grouping shape is judged in the bag and a padded field name is not refused - * there — the row's own reading, recorded rather than restated here. The - * node-level `grouping` is no declaration at all: it is the flat spelling's - * by-name refusal (`flatPropRefusals`), pointing at `properties.grouping`. + * `object-grid` (objectui#11276's `object-grid` batch): the node-level `grouping` + * is no declaration at all: it is the flat spelling's by-name refusal + * (`flatPropRefusals`), pointing at `properties.grouping`. The bag member + * `properties.grouping` stood here too while the spec row typed it `z.unknown()`; + * since `@objectstack/spec` 17.6.0 the row judges it, so it is a `DECLARING` row + * above (objectui#11438). */ const NOT_COVERED: Readonly> = { - 'object-grid': ['grouping', 'properties.grouping'], + 'object-grid': ['grouping'], }; /** The faces an authored document meets. `objectui validate` runs `safeValidateSchema`. */ @@ -277,16 +289,10 @@ describe('objectui#7347 — census: the table above is every arm that declares ` expect(found).toEqual(expected); }); - it('`object-grid` is not covered for the recorded reasons: a flat refusal on the node, the row\'s `unknown` in the bag', () => { - const doc = (grouping: unknown, at: 'node' | 'bag') => (at === 'node' - ? { type: 'object-grid', properties: { objectName: 'account' }, grouping } - : { type: 'object-grid', properties: { objectName: 'account', grouping } }); - const padded = { fields: [{ field: PADDED[0], order: 'asc' }] }; + it('`object-grid` node-level `grouping` is not covered for the recorded reason: a flat refusal on the node', () => { + const node = { type: 'object-grid', properties: { objectName: 'account' }, grouping: { fields: [{ field: CLEAN, order: 'asc' }] } }; // On the node, every value is refused by name, toward the bag member. - const flat = issuesOf(safeValidateSchema(doc({ fields: [{ field: CLEAN, order: 'asc' }] }, 'node'))); + const flat = issuesOf(safeValidateSchema(node)); expect(flat.map((i) => i.path.join('.'))).toEqual(['grouping']); - // In the bag the row judges nothing about the shape: a padded name parses. - expect(safeValidateSchema(doc(padded, 'bag')).success).toBe(true); - expect(StrictAnyComponentSchema.safeParse(doc(padded, 'bag')).success).toBe(true); }); }); From 2ba091c3cb873e508236fd159e31a5563923c21b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 00:07:27 +0000 Subject: [PATCH 13/36] test(types): the card footer is a judged child position since @objectstack/spec 17.6.0's walk descends it (objectui#11438) objectui#11223's block (e) held `page:card`'s `properties.footer` as the one slot the spec's exported walk did not descend, and said to retire itself when that changed. 17.6.0's walk descends `properties.footer[]`, objectui's faces now judge a footer child, and block (d) asked for the position to be added to (a). It is: the footer is a row of (a) on both faces, (e) is deleted, and the CLI doc no longer names the footer as an unjudged slot. Claude-Session: https://claude.ai/code/session_01HRYqpqGcWpJuJkDmbRF75w Co-authored-by: Claude --- content/docs/utilities/cli.mdx | 4 +- .../nested-page-children-11223.test.ts | 41 ++++++++----------- 2 files changed, 18 insertions(+), 27 deletions(-) diff --git a/content/docs/utilities/cli.mdx b/content/docs/utilities/cli.mdx index aaea3ab1d4..6fd3c2529a 100644 --- a/content/docs/utilities/cli.mdx +++ b/content/docs/utilities/cli.mdx @@ -141,9 +141,7 @@ reported at the nested node's real path: Nearest of the … accepted types: element:text, element:button, … ``` -A slot that walk does not descend is not judged here. One is live today: -`page:card`'s `properties.footer`, which the card draws — a component written -there is accepted unexamined until the spec's walk reaches it. +A slot that walk does not descend is not judged here. #### Reading a failure diff --git a/packages/types/src/__tests__/nested-page-children-11223.test.ts b/packages/types/src/__tests__/nested-page-children-11223.test.ts index 7f7c92c50a..d6236cb78d 100644 --- a/packages/types/src/__tests__/nested-page-children-11223.test.ts +++ b/packages/types/src/__tests__/nested-page-children-11223.test.ts @@ -33,8 +33,9 @@ * (d) parity: the positions are read off the installed spec's walk, here, * without naming one; objectui's walk reaches every one of them, and * each is a position a row of (a) judges on both faces; - * (e) the one slot the walks disagree on (`page:card`'s `footer`), held as a - * row that turns red the day the exported walk descends it; + * (e) retired: it held `page:card`'s `footer`, the one slot the walks + * disagreed on, until `@objectstack/spec` 17.6.0's walk descended it + * (objectui#11438); the footer is a row of (a) since; * (f) a JavaScript value that nests a component inside itself still gets a * verdict. * @@ -45,7 +46,6 @@ import { describe, expect, it } from 'vitest'; import { walkAddressedPageComponents } from '@objectstack/spec/system'; -import { PageCardProps } from '@objectstack/spec/ui'; import { safeValidateSchema, StrictAnyComponentSchema } from '../zod/index.zod.js'; import { nestedComponentsOf } from '../zod/nested-component-walk.js'; @@ -92,6 +92,14 @@ const CONTAINERS: ReadonlyArray ({ type: 'page:card', properties: { footer: [text('first'), child] } }), + ['properties', 'footer', 1], + ] as const, ]; const ROWS = CONTAINERS.flatMap(([type, , place, path]) => @@ -100,11 +108,13 @@ const ROWS = CONTAINERS.flatMap(([type, , place, path]) => /* ── (a) the refusals ───────────────────────────────────────────────────── */ describe('objectui#11223 (a) — a bag child is judged by the node union, at its real path', () => { - it('the population is the six containers on both faces — a row dropped from the table fails here', () => { - expect(CONTAINERS.map(([type]) => type)).toEqual([ - 'page:card', 'page:section', 'page:footer', 'page:sidebar', 'page:tabs', 'page:accordion', + it('the population is the seven child positions on both faces — a row dropped from the table fails here', () => { + expect(CONTAINERS.map(([type, position]) => `${type} ${position}`)).toEqual([ + 'page:card properties.children[]', 'page:section properties.children[]', 'page:footer properties.children[]', + 'page:sidebar properties.children[]', 'page:tabs properties.items[].children[]', + 'page:accordion properties.items[].children[]', 'page:card properties.footer[]', ]); - expect(ROWS).toHaveLength(12); + expect(ROWS).toHaveLength(14); }); it.each(ROWS)('%s refuses a malformed child, each issue under the child', (label, place, path, judge) => { @@ -293,23 +303,6 @@ describe('objectui#11223 (d) — objectui walks every position the spec\'s walk }); }); -/* ── (e) the slot the walks disagree on ─────────────────────────────────── */ - -describe('objectui#11223 (e) — `page:card`\'s `properties.footer`: declared, drawn, and not yet walked', () => { - it('the row declares it and the exported walk does not descend it — turn this red, and the gap closes itself', () => { - expect(Object.keys(PageCardProps.shape)).toContain('footer'); - // ⚠️ When this goes red, the spec's walk has started descending the card's - // footer, and every row of (d) now judges it: delete this block and record - // the close on objectui#11223, with the `page:card` footer note in - // `content/docs/utilities/cli.mdx`. Nothing else needs to change. - expect(POSITIONS).not.toContain('properties.footer[]'); - }); - - it.each(FACES)('so a malformed footer child still parses on the %s face', (_face, judge) => { - expect(judge({ type: 'page:card', properties: { footer: [MALFORMED] } }).success).toBe(true); - }); -}); - /* ── (f) a self-nesting value ───────────────────────────────────────────── */ describe('objectui#11223 (f) — a value that nests a component inside itself gets a verdict', () => { From 060cec806e8f9c93bd2158b1cc35c12817afe400 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 00:18:29 +0000 Subject: [PATCH 14/36] chore(scripts): lower the eager-closure ceilings by the 250,096 bytes @objectstack/spec 17.6.0's root entry shed (objectui#11438) The second payback of objectui#11073's ruled raise, objectstack#20646, reaches objectui with the 17.6.0 bump: the spec root no longer exports its migration chain. Measured with two console builds in one lock hold (`CI=true pnpm exec vite build` after the packages build): `main` at `2d576e46` (17.5.0) weighs 3,636,289 gzipped eager bytes, this branch at `2ba091c` weighs 3,386,193; `vendor-objectstack` alone is -250,108, the other 329 chunks +12. Left alone, the ceiling would sit 3.01x the regression above the payload and the gate's own sensitivity check exits 2 (a blind gauge). So, as objectui#11101 did for the first payback: `MAX_EAGER_CLOSURE_GZIP_BYTES` 3,660,380 -> 3,410,284 and `vendor-objectstack` 1,689,265 -> 1,439,157, each lowered by exactly what it recovered; BASELINE and PER_CHUNK_BASELINE move to the `2ba091c` reading; headroom 0.26x and 0.20x, the bands `main` already had. The test's rendered literal is re-taken (3306.8). The changeset says what the console gains. Claude-Session: https://claude.ai/code/session_01HRYqpqGcWpJuJkDmbRF75w Co-authored-by: Claude --- .changeset/11438-objectstack-17-6-bump.md | 2 +- .../check-eager-closure-budget.test.ts | 14 ++- scripts/check-eager-closure-budget.mjs | 111 ++++++++++++++---- 3 files changed, 97 insertions(+), 30 deletions(-) diff --git a/.changeset/11438-objectstack-17-6-bump.md b/.changeset/11438-objectstack-17-6-bump.md index c581d96aee..f7b2f223b2 100644 --- a/.changeset/11438-objectstack-17-6-bump.md +++ b/.changeset/11438-objectstack-17-6-bump.md @@ -8,4 +8,4 @@ objectui now resolves `@objectstack/*` 17.6.0 (objectui#11438). No declared rang - `@object-ui/components`: the `page:header` registration no longer publishes a `breadcrumb` input. 17.6.0 retires the key (objectstack#20758) and refuses it by name; the renderer has ignored it since objectui#11166. - `@object-ui/i18n`: a served translation document is recognised as a spec payload when it carries only the `picklists` group, which 17.6.0's `GetTranslationsResponseSchema` adds. Such a bundle used to be returned untransformed, so nothing read it. The group is now namespaced under `app` like every other group. -- `@object-ui/console`: the bundle inlines the 17.6.0 packages, so its client-side validation answers as a 17.6.0 server does. +- `@object-ui/console`: the bundle inlines the 17.6.0 packages, so its client-side validation answers as a 17.6.0 server does. The first screen is smaller: 17.6.0's spec root no longer carries the migration chain, so the eager closure is 250,096 gzipped bytes lighter (measured against a `main` build), and the bundle budget comes down by that amount. diff --git a/scripts/__tests__/check-eager-closure-budget.test.ts b/scripts/__tests__/check-eager-closure-budget.test.ts index baa67b7390..d2aa8e66cb 100644 --- a/scripts/__tests__/check-eager-closure-budget.test.ts +++ b/scripts/__tests__/check-eager-closure-budget.test.ts @@ -807,9 +807,11 @@ describe('ceiling sensitivity, judged live (objectui#5924)', () => { // to 3104.5 on the authorised re-pin over `main`'s own drift, objectui#11073 // UP to 3563.2 on the ruled raise for `@objectstack/*` 17.5.0, // objectui#11088 decision 1 = A, objectui#11101 down to 3552.8 on the first - // payback of that raise) — a rendering derived in the test would agree with - // the renderer by construction and pin nothing. - expect(result.message).toContain('3552.8'); + // payback of that raise, objectui#11438 down to 3306.8 on the second, when + // `@objectstack/*` 17.6.0's spec root shed its migration chain) — a rendering + // derived in the test would agree with the renderer by construction and pin + // nothing. + expect(result.message).toContain('3306.8'); }); it('is exactly one regression wide, from either side of the line', () => { @@ -1639,7 +1641,7 @@ describe('main', () => { // about the FIXTURE while the gate under test behaved correctly. The number // this case is actually about is "the report's chunk count, echoed". expect(outputs.closure_chunks).toBe(String(fixture.files.length)); - expect(outputs.closure_gzip_kb).toBe('3552.8'); + expect(outputs.closure_gzip_kb).toBe('3306.8'); }); it('exits 1 — a verdict about the BUNDLE — when over budget', () => { @@ -2617,8 +2619,8 @@ describe('the prose attached to the baselines (objectui#7046)', () => { * * objectui#10996 is the first re-baseline to meet it, and re-pinned it to the * shape it left: `squashMerge` null, one commit carried. objectui#11073 and - * objectui#11101 each re-pinned the constant onto a branch tip of their own - * and left the same shape. ⛔ Still exact and positional — a back-fill of the + * objectui#11101, and objectui#11438 after them, each re-pinned the constant + * onto a branch tip of their own and left the same shape. ⛔ Still exact and positional — a back-fill of the * squash reds here again, and is re-pinned to two strings the same way. */ it('records what each baseline carries as data, so the pin cannot go vacuous', () => { diff --git a/scripts/check-eager-closure-budget.mjs b/scripts/check-eager-closure-budget.mjs index 2806b18f44..425381a5c1 100644 --- a/scripts/check-eager-closure-budget.mjs +++ b/scripts/check-eager-closure-budget.mjs @@ -417,10 +417,12 @@ import { isEntrypoint } from './invoked-as.mjs'; * header for both builds and the three control rows that show the bytes * LEFT rather than moved. * - * Headroom above {@link BASELINE} is 22,276 bytes — 0.24x - * {@link REGRESSION_THIS_GATE_MUST_CATCH_BYTES}, on the pair objectui#11101 - * re-pinned when it paid back part of objectui#11073's raise; the header's - * objectui#11101 entry says why that is under the 0.50x design point. ⚠️ That + * Headroom above {@link BASELINE} is 24,091 bytes — 0.26x + * {@link REGRESSION_THIS_GATE_MUST_CATCH_BYTES}, on the pair objectui#11438 + * re-pinned when the `@objectstack/*` 17.6.0 bump paid back the rest of the + * spec-root part of objectui#11073's raise; the header's objectui#11101 entry + * says why that band is under the 0.50x design point, and objectui#11438's + * entry kept it to the byte. ⚠️ That * is arithmetic on two * constants in this file, so it stays true while they do — it is NOT what the * closure has left today, which is smaller by every byte the payload has @@ -741,8 +743,47 @@ import { isEntrypoint } from './invoked-as.mjs'; * and neither did {@link REGRESSION_THIS_GATE_MUST_CATCH_BYTES}. * objectstack#20646 is the second payback, and it lowers both lines again by * what it recovers. + * + * ## ⭐ LOWERED BY THE SECOND PAYBACK (objectstack#20646, carried by objectui#11438) + * + * From 3,660,380 to 3,410,284: down by 250,096 bytes, exactly what the + * `@objectstack/*` 17.6.0 bump took out of the closure. 17.6.0 carries the + * spec's migrations-entry-split: the root entry no longer exports the + * migration chain (`MIGRATIONS_BY_MAJOR` moved to `@objectstack/spec/migrations`, + * which nothing on the first screen imports). Lowered by the amount recovered, + * as objectui#11101's payback was: ⛔ not re-derived as the new reading plus + * half a regression, and ⛔ not rounded. + * + * WHAT LEFT — two console builds, one container, one instrument, one hold of + * `scripts/pm/os-verify-lock.sh`: `pnpm turbo run build --filter='./packages/*'`, + * then `CI=true pnpm exec vite build` in `apps/console`, reading + * `apps/console/dist/eager-closure.json`. `2d576e46` is `main` with + * `@objectstack/*` 17.5.0 resolved; `2ba091c` is objectui#11438's branch, the + * same tree with 17.6.0 resolved and the bump's own edits. + * + * | chunk | `2d576e46` | `2ba091c` | delta | + * |-----------------------------|------------:|-----------:|---------:| + * | `vendor-objectstack` | 1,671,066 | 1,420,958 | -250,108 | + * | the other 329 eager chunks | 1,965,223 | 1,965,235 | +12 | + * | ⇒ aggregate | 3,636,289 | 3,386,193 | -250,096 | + * + * Both builds weigh 330 of 2450 chunks. The +12 is spread over the other + * chunks, ⛔ not attributed here. Left at 3,660,380, the ceiling would have + * stood 274,187 bytes (3.01x the regression) above this payload, and + * {@link evaluateHeadroomSensitivity} calls that a blind gauge (exit 2). + * + * Headroom 24,091 bytes = 0.26x {@link REGRESSION_THIS_GATE_MUST_CATCH_BYTES} + * over {@link BASELINE}: exactly the headroom `main` had on `2d576e46` before + * the payback (3,660,380 − 3,636,289), because the ceiling came down by the + * aggregate's own delta. No build that passed before this edit and measures + * under 3,410,284 fails after it. + * + * ⛔ What moved with it, and what did not. `vendor-objectstack`'s per-chunk + * ceiling came down the same way, by the 250,108 bytes that chunk lost, with + * {@link PER_CHUNK_BASELINE}. The other three per-chunk rows did not move, + * and neither did {@link REGRESSION_THIS_GATE_MUST_CATCH_BYTES}. */ -export const MAX_EAGER_CLOSURE_GZIP_BYTES = 3_660_380; +export const MAX_EAGER_CLOSURE_GZIP_BYTES = 3_410_284; /** * The measurement the ceiling above was derived from. Exported so the two @@ -755,6 +796,21 @@ export const BASELINE = Object.freeze({ /** * `emitEagerClosureReport`'s `eagerGzipBytes` on this commit. * + * `2ba091c` is objectui#11438's branch tip: `main` at `2d576e46` with + * `@objectstack/*` 17.6.0 resolved and the bump's own edits, the second + * payback recorded on {@link MAX_EAGER_CLOSURE_GZIP_BYTES} (its table and its + * `2d576e46` control are there, ⛔ not restated here). It is a branch tip for + * the reason the paragraph below gives for `4acbea07`: the pull request's own + * diff is what moved the figure. Measured by `CI=true pnpm exec vite build` + * in `apps/console` (exit 0) after `pnpm turbo run build --filter='./packages/*'`, + * reading `apps/console/dist/eager-closure.json`, under + * `scripts/pm/os-verify-lock.sh`, in the same container and the same lock + * hold as the `2d576e46` control build. The commit that re-pins this file + * reaches no bundler input, so it cannot move the figure it pins. + * + * ⚠️ The paragraphs below describe the reading this one superseded, `4acbea07` + * (3,638,104 across 330 of 2450 chunks), kept as its provenance. + * * `4acbea07` is the payback commit of objectui#11101's branch: `main` at * `5262f7d` plus the one line in `apps/console/vite.config.ts` that keeps an * `import()`ed `@objectstack/*` module out of `vendor-objectstack`, under the @@ -786,10 +842,10 @@ export const BASELINE = Object.freeze({ * and not from `main` once it is squash-merged — the dead end objectui#9355 * added `squashMerge` below to route around. */ - gzipBytes: 3_638_104, + gzipBytes: 3_386_193, chunks: 330, totalChunks: 2450, - commit: '4acbea07', + commit: '2ba091c', /** * The squash merge that carried the reading above onto `main`, recorded when @@ -797,8 +853,8 @@ export const BASELINE = Object.freeze({ * (objectui#9355): a sha that DOES resolve, so a reader re-checking the * measurement gets a handle rather than a dead end. * - * `null` since objectui#10996, and `null` again after objectui#11101, - * deliberately. This field can only ever be BACK-FILLED: a squash sha does + * `null` since objectui#10996, and `null` again after objectui#11101 and + * objectui#11438, deliberately. This field can only ever be BACK-FILLED: a squash sha does * not exist until the pull request merges, so the change that re-pins the * field above ⛔ cannot write its own here. ⛔ Do not guess one: a wrong sha in * this position is worse than an absent one, because it RESOLVES, and a @@ -1314,7 +1370,13 @@ export const PER_CHUNK_GZIP_CEILINGS = Object.freeze({ // key had on `main` at `5262f7d` before the payback, kept to the byte — and // it comes down again with the aggregate when objectstack#20646 pays back the // rest. - 'vendor-objectstack': 1_689_265, + // + // ⭐ LOWERED again by that second payback, carried by objectui#11438's + // `@objectstack/*` 17.6.0 bump, from 1,689,265 by exactly the 250,108 bytes + // this chunk lost (the table under objectui#11438's entry on + // MAX_EAGER_CLOSURE_GZIP_BYTES). Headroom 18,199 bytes = 0.20x + // REGRESSION_THIS_GATE_MUST_CATCH_BYTES over the baseline below. + 'vendor-objectstack': 1_439_157, // ⭐ LOWERED, and RE-KEYED, by objectui#7479 — this line used to read // `'i18n-locales': 465_000` and budget TEN catalogues. Nine of them are // `import()`ed on demand now, so the chunk that name pointed at no longer @@ -1385,10 +1447,12 @@ export const PER_CHUNK_GZIP_CEILINGS = Object.freeze({ * per file, and saying so is the point — a comment that names one commit for * three numbers taken on two is the drift objectui#6631 is open about: * - * - `vendor-objectstack` — `4acbea07` (objectui#11101), the payback build - * the aggregate also reads, re-measured when this chunk's group stopped - * claiming `import()`ed modules and its ceiling came down with the - * aggregate's. It supersedes `048e7f6` (objectui#11073, the `@objectstack/*` + * - `vendor-objectstack` — `2ba091c` (objectui#11438), the 17.6.0 bump's + * build the aggregate also reads, re-measured when the spec's root entry + * shed its migration chain and this chunk's ceiling came down with the + * aggregate's. It supersedes `4acbea07` (objectui#11101), the payback build + * re-measured when this chunk's group stopped claiming `import()`ed + * modules, which superseded `048e7f6` (objectui#11073, the `@objectstack/*` * 17.5.0 raise), which superseded `34a1578ef` (objectui#7122, the 17.3.0 * family bump) and `2c8474c04` (objectui#5490) before that. This bullet * still named `34a1578ef` after objectui#11073 had re-pinned the key, and @@ -1450,10 +1514,10 @@ export const PER_CHUNK_GZIP_CEILINGS = Object.freeze({ * objectui#9251, and `ui-components` from objectui#9251 until objectui#10996 * re-pinned the aggregate onto a `main` commit; today neither does — and * WHICH ONE IS LATER flips every time either side is re-baselined, so read the - * commit names, never a direction asserted here. As of objectui#11101, - * `vendor-objectstack` SHARES the aggregate's reading: BASELINE's `4acbea07`, - * the payback build, re-pinned both, as objectui#11073's `048e7f6` had before - * it. The aggregate is the later reading for the + * commit names, never a direction asserted here. As of objectui#11438, + * `vendor-objectstack` SHARES the aggregate's reading: BASELINE's `2ba091c`, + * the 17.6.0 bump's build, re-pinned both, as objectui#11101's `4acbea07` and + * objectui#11073's `048e7f6` had before it. The aggregate is the later reading for the * other keys: `bbf6b02d9` (2026-09-13, objectui#9251) for `ui-components`, * and `3f775eeb8` for `framework`. ⚠️ `i18n-locale-en`'s commit was `755d34a5f` when it * was taken and the aggregate has moved on since, which is exactly why the two @@ -1514,11 +1578,12 @@ export const PER_CHUNK_GZIP_CEILINGS = Object.freeze({ * measurement with no ceiling weighs nothing. */ export const PER_CHUNK_BASELINE = Object.freeze({ - // `4acbea07`, the same build as BASELINE above (objectui#11101), re-pinned - // with its ceiling by the payback. It supersedes objectui#11073's `048e7f6` - // reading of 1,703,690 (landed on `main` as `81f8498`), which superseded - // objectui#7122's `34a1578ef` reading of 1,235,029. - 'vendor-objectstack': 1_670_964, + // `2ba091c`, the same build as BASELINE above (objectui#11438), re-pinned + // with its ceiling by the second payback. It supersedes objectui#11101's + // `4acbea07` reading of 1,670,964, which superseded objectui#11073's + // `048e7f6` reading of 1,703,690 (landed on `main` as `81f8498`), which + // superseded objectui#7122's `34a1578ef` reading of 1,235,029. + 'vendor-objectstack': 1_420_958, // `755d34a5f` (objectui#7479) — the SAME console build as // BASELINE above, so the two are directly comparable, and the same instrument // and container as the control build it is subtracted from. It supersedes From 00c13e7946e9e753cd3427bff213242299198a09 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 04:09:17 +0000 Subject: [PATCH 15/36] =?UTF-8?q?test(console):=20book=20record:line=5Fite?= =?UTF-8?q?ms'=2017.6.0=20differences=20to=20objectui#11536=20under=20obje?= =?UTF-8?q?ctui#11438=20ruling=20A=E2=80=B2=20(objectui#11438)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @objectstack/spec 17.6.0 gives `record:line_items` a ComponentPropsMap row (objectstack 24c554da, #21142 / #21244), so registry-inputs-spec-parity judges it from that pin on. objectui#11438 ruling A′ (record 5965062249) applies objectui#11111 decision 3 = B to the bump: the population is re-pinned and every difference is booked to the owner card filed for the group, objectui#11536, with an expiry. Nothing is declared or pinned here. - EXPECTED_COVERED gains `MINOR_17_6_BLOCKS` (`record:line_items`), armed when the installed spec carries it; the census goes 55 -> 56 carried and 39 -> 40 judged. - UNPUBLISHED_EXEMPTIONS books the ten keys `inputs` omit; MEMBER_PIN_EXEMPTIONS books `columns` and `dataSource`, and MEMBER_PIN_EXEMPTION_CEILING goes 0 -> 2 with the booking. - The objectui#11111 ledger gains objectui#11536 as a fourth owner, routed by block. Its entries cite ruling A′ and decision 3 = B and expire 2026-11-02 (thirty days after the ruling, as the 17.5.0 entries' 2026-10-30 is after theirs). Caps: unpublishedKeys 1 -> 11, memberPins 0 -> 2, owner total 12. Claude-Session: https://claude.ai/code/session_01HRYqpqGcWpJuJkDmbRF75w Co-authored-by: Claude --- .../registry-inputs-spec-parity.test.ts | 164 ++++++++++++++++-- 1 file changed, 148 insertions(+), 16 deletions(-) diff --git a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts index 4a53ae2717..7679290fd7 100644 --- a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts +++ b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts @@ -643,6 +643,23 @@ const MINOR_17_5_LOADED_BLOCKS = ['object-gantt', 'object-map', 'object-timeline /** Does the installed `@objectstack/spec` carry the loaded 17.5.0 blocks? `every`, for the same reason. */ const specCarries175LoadedBlocks = MINOR_17_5_LOADED_BLOCKS.every((type) => type in ComponentPropsMap); +/** + * The one block `@objectstack/spec` 17.6.0 newly carries (objectui#11438): the + * map went from 55 entries to 56 when `record:line_items` gained its row + * (objectstack `24c554da`, #21142 / #21244). `@object-ui/plugin-form` + * registers it eagerly with `inputs`, so this file JUDGES it from that pin on. + * Its differences are booked to objectui#11536 under objectui#11438 ruling A′ + * (see `OWED_TO` above), not declared in the bump. + * + * Same shape as `MINOR_17_5_BLOCKS`: this repo registered the block with + * `inputs` before the spec described it, so what moved at the bump is the + * SPEC's side. It enters `covered` the moment the installed spec carries it. + */ +const MINOR_17_6_BLOCKS = ['record:line_items']; + +/** Does the installed `@objectstack/spec` carry the 17.6.0 set? `every`, for the reason `specCarriesGaBlocks` gives. */ +const specCarries176Blocks = MINOR_17_6_BLOCKS.every((type) => type in ComponentPropsMap); + const EXPECTED_COVERED = [ ...PINNED_EXPECTED_COVERED, ...LAZY_REGISTERED_BLOCKS, @@ -650,6 +667,7 @@ const EXPECTED_COVERED = [ ...(specCarries171Blocks ? MINOR_17_1_BLOCKS : []), ...(specCarries175Blocks ? MINOR_17_5_BLOCKS : []), ...(specCarries175LoadedBlocks ? MINOR_17_5_LOADED_BLOCKS : []), + ...(specCarries176Blocks ? MINOR_17_6_BLOCKS : []), ].sort(); /** @@ -722,6 +740,18 @@ function isRetiredUpstream(type: string): boolean { * - objectui#8649 — `enforceFieldSecurity`, `redactFields` and * `requiredPermissions` on the three `record:*` blocks. * + * The 17.6.0 bump (objectui#11438) books under the same decision, with a + * fourth owner. objectui#11438 was ruled A′ (record 5965062249): its one group + * with no owner card, `record:line_items` — which 17.6.0 gives a + * `ComponentPropsMap` row (objectstack `24c554da`, #21142 / #21244), so this + * file judges it from that pin on — "follows objectui#11111 decision three B + * (5902351047): an owner card is filed and ledgered on the trunk with an + * expiry". The card filed for it is: + * + * - objectui#11536 — every entry on `record:line_items`: the keys its spec + * row declares that `inputs` does not publish, and the member pins its + * array/object-armed inputs owe. + * * Each owner card decides every key by its own measurement: declare what the * renderer honours, refuse or retire what it does not. ⛔ Batch-declaring an * input a renderer does not honour is the failure decision 3 = B exists to @@ -733,21 +763,58 @@ function isRetiredUpstream(type: string): boolean { * new difference beyond the listed entries is still red: every direction below * subtracts exact ids, never a block or a prefix. Each owner card's landing * strikes its entries and lowers the matching cap in the same change; the last - * of the three to land restores the empty ledgers and the zero caps this file - * held before the bump. + * of the four to land restores the empty ledgers and the zero caps this file + * held before the 17.5.0 bump. * * EXPIRY. None of these ledgers has a date field, and this repo's SUNSET * convention is a condition re-derived from the installed spec, not a date, so * nothing here can enforce a calendar deadline. The date is stated in every * entry's reason and asserted PRESENT; it is read by the owner cards, not by a - * clock. + * clock. The 17.6.0 entries carry their own date, set the same distance from + * their ruling as the 17.5.0 date is from its own: record 5902351047 was ruled + * 2026-09-30 and its entries expire 2026-10-30; record 5965062249 was ruled + * 2026-10-03, so objectui#11536's entries expire 2026-11-02. */ const OBJECTUI_11111_EXPIRES = '2026-10-30'; -/** The three owner cards the ruling names, and nothing else may own an entry. */ -const OBJECTUI_11111_OWNERS = ['objectui#11168', 'objectui#8652', 'objectui#8649'] as const; +/** The 17.6.0 bookings' date: objectui#11438 ruling A′ (record 5965062249), plus thirty days. */ +const OBJECTUI_11438_EXPIRES = '2026-11-02'; + +/** + * The owner cards, and nothing else may own an entry: the three decision 3 = B + * names, and objectui#11536, which objectui#11438 ruling A′ filed for the 17.6.0 + * bump's one group with no owner. + */ +const OBJECTUI_11111_OWNERS = [ + 'objectui#11168', + 'objectui#8652', + 'objectui#8649', + 'objectui#11536', +] as const; type Objectui11111Owner = (typeof OBJECTUI_11111_OWNERS)[number]; +/** The ruling every entry cites, whichever bump booked it. */ +const DECISION_3_B = 'objectui#11111 decision 3 = B (record 5902351047)'; + +/** + * Where each owner's entries were booked: the bump that surfaced them, the + * record that booked them, and their expiry. The 17.5.0 owners were booked by + * decision 3 = B itself; objectui#11536 by the ruling that applies it to 17.6.0. + */ +const OBJECTUI_11111_BOOKINGS: Record< + Objectui11111Owner, + { bump: string; bookedBy: string; expires: string } +> = { + 'objectui#11168': { bump: '17.5.0', bookedBy: DECISION_3_B, expires: OBJECTUI_11111_EXPIRES }, + 'objectui#8652': { bump: '17.5.0', bookedBy: DECISION_3_B, expires: OBJECTUI_11111_EXPIRES }, + 'objectui#8649': { bump: '17.5.0', bookedBy: DECISION_3_B, expires: OBJECTUI_11111_EXPIRES }, + 'objectui#11536': { + bump: '17.6.0', + bookedBy: `objectui#11438 ruling A′ (record 5965062249), which applies ${DECISION_3_B} to the 17.6.0 bump`, + expires: OBJECTUI_11438_EXPIRES, + }, +}; + /** The security triple 17.5.0 declares on exactly the three record blocks (objectui#8649's). */ const FIELD_SECURITY_TRIPLE = ['enforceFieldSecurity', 'redactFields', 'requiredPermissions']; @@ -755,10 +822,14 @@ const FIELD_SECURITY_TRIPLE = ['enforceFieldSecurity', 'redactFields', 'required const OWED_PREFIX = 'OWED TO '; /** One ruled entry's reason: owner first, then what is owed, then the ruling and the expiry. */ -const OWED_TO = (owner: Objectui11111Owner, what: string): string => - `${OWED_PREFIX}${owner}. ${what} Booked by objectui#11111 decision 3 = B (record 5902351047): ` + - `the 17.5.0 bump re-pins and declares nothing; ${owner} decides it by its own measurement. ` + - `Expires ${OBJECTUI_11111_EXPIRES}, or when ${owner} lands, whichever is first.`; +const OWED_TO = (owner: Objectui11111Owner, what: string): string => { + const { bump, bookedBy, expires } = OBJECTUI_11111_BOOKINGS[owner]; + return ( + `${OWED_PREFIX}${owner}. ${what} Booked by ${bookedBy}: ` + + `the ${bump} bump re-pins and declares nothing; ${owner} decides it by its own measurement. ` + + `Expires ${expires}, or when ${owner} lands, whichever is first.` + ); +}; /** `BLOCK.KEY` entries for every listed key of one block, all with the same owner and reason. */ const owedEntries = ( @@ -771,6 +842,8 @@ const owedEntries = ( /** Which owner the ruling routes an entry id to — asserted against every entry's reason. */ function objectui11111OwnerOf(id: string): Objectui11111Owner { + // objectui#11438 ruling A′ routes by BLOCK: every entry on `record:line_items` is objectui#11536's. + if (id.slice(0, id.indexOf('.')) === 'record:line_items') return 'objectui#11536'; const key = id.slice(id.indexOf('.') + 1).split(':')[0]; if (FIELD_SECURITY_TRIPLE.includes(key)) return 'objectui#8649'; if (key === 'navigation') return 'objectui#8652'; @@ -792,9 +865,9 @@ const owedIdsOf = (ledger: Record): string[] => const OBJECTUI_11111_LEDGER_CAPS = { unjudgedBlocks: 0, // objectui#11168 loaded and judged all four: slice 3 object-map and object-tree, slice 4 object-gantt, slice 5 object-timeline offSpecInputs: 0, // objectui#11168 slice 1 retired action:group.name - unpublishedKeys: 1, // objectui#11168: 1 (action:button undoable; the two `endpoint` entries left at the 17.6.0 bump, objectui#11438, when the spec stopped declaring the key); objectui#8652: 0 and objectui#8649: 0 (each struck by its landing) + unpublishedKeys: 11, // objectui#11168: 1 (action:button undoable; the two `endpoint` entries left at the 17.6.0 bump, objectui#11438, when the spec stopped declaring the key); objectui#8652: 0 and objectui#8649: 0 (each struck by its landing); objectui#11536: 10 (record:line_items, booked at the 17.6.0 bump) refusedArms: 0, // objectui#11168: slice 2 narrowed element:definition-list.columns, slice 3 object-form.layout - memberPins: 0, // objectui#11168 slice 2 pinned element:definition-list.items and element:repeater ×3 + memberPins: 2, // objectui#11168 slice 2 pinned element:definition-list.items and element:repeater ×3; objectui#11536: 2 (record:line_items columns and dataSource, booked at the 17.6.0 bump) } as const; /** @@ -1436,6 +1509,27 @@ const UNPUBLISHED_EXEMPTIONS: Record = { // declared all three keys on the three blocks' `inputs`, with the contract's // types and describe text, so each entry went stale and `carries no stale // unpublished-key exemption` would refuse it. + + /* + * ⚠️ THE 17.6.0 BOOKINGS — objectui#11438 ruling A′ (record 5965062249), + * which applies objectui#11111 decision 3 = B to the 17.6.0 bump. + * + * The spec keys `record:line_items` declares from 17.6.0 on that its + * `inputs` do not publish. The same bar and the same ruling as the 17.5.0 + * bookings above: the bump declares nothing, and objectui#11536 measures + * each key against the line-items renderer, declares what is honoured and + * leaves the rest out. Capped with the 17.5.0 entries at + * `OBJECTUI_11111_LEDGER_CAPS.unpublishedKeys`. + */ + ...owedEntries( + 'record:line_items', + [ + 'parentObject', 'parentId', 'recordId', 'title', 'readonly', 'minRows', 'maxRows', 'filter', + 'sort', 'limit', + ], + 'objectui#11536', + 'A SPEC KEY NOT PUBLISHED: `record:line_items` entered `covered` with 17.6.0 and its `inputs` omit this key its spec row declares.', + ), }; /** @@ -3384,6 +3478,19 @@ const MEMBER_PIN_EXEMPTIONS: Record = { // slice 1, and `element:definition-list.items` and `element:repeater`'s // `fields`, `filter` and `sort` by slice 2, each registered in // `MEMBER_PINS`. Header kept as a landmark for a future grep. + + // ⚠️ 17.6.0 BOOKINGS — objectui#11438 ruling A′ (record 5965062249), which + // applies objectui#11111 decision 3 = B to the 17.6.0 bump. The + // array/object-armed inputs of `record:line_items`, the block 17.6.0 newly + // judges, owed a member pin by objectui#11536. Counted into + // `MEMBER_PIN_EXEMPTION_CEILING` (0 -> 2) and capped at these two; that + // card's pins strike them. + ...owedEntries( + 'record:line_items', + ['columns', 'dataSource'], + 'objectui#11536', + 'A MEMBER PIN OWED: this `record:line_items` input is array/object-armed on a block 17.6.0 newly judges, and no pin states its members.', + ), }; /** @@ -4198,8 +4305,19 @@ const NEWLY_JUDGED_UNPINNED_MEMBERS: string[] = []; * real renderer, and deleted their four booked entries. Nothing else stood in * the list, so the ceiling follows it to 0 and this direction now runs with no * exemption at all. A new array/object-armed key is answered with a pin. + * + * ## 0 -> 2 (objectui#11438, the @objectstack/spec 17.6.0 bump) — a RULING, + * not room, the same rise the 17.5.0 bump took + * + * 17.6.0 newly judges `record:line_items`, which brings two array/object-armed + * inputs with no pin: `columns` and `dataSource`. objectui#11438 ruling A′ + * (record 5965062249) applies objectui#11111 decision 3 = B to the bump, so + * both are booked OWED to objectui#11536 rather than pinned in it. 0 + 2 booked + * = 2, the exact count, and `the objectui#11111 ledger is capped at exactly the + * entries it lists` caps the two and routes each to objectui#11536. + * objectui#11536's pins take this back to 0. */ -const MEMBER_PIN_EXEMPTION_CEILING = 0; +const MEMBER_PIN_EXEMPTION_CEILING = 2; /** * Every test file a member pin can live in, as LAZY `?raw` loaders. @@ -4366,8 +4484,15 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide) // `object-gantt`: 37 -> 38 judged, 11 -> 10 ledgered. 38 + 7 + 10 = 55. // Slice 5 loads `object-timeline`, the last of the four: 38 -> 39 judged, // 10 -> 9 ledgered. 39 + 7 + 9 = 55. - specCarried: 55, - judged: 39, + // + // 55 -> 56 on the `@objectstack/spec` 17.6.0 pin (objectui#11438): one + // new block, `record:line_items`, registered eagerly with `inputs`, so it + // is judged (`MINOR_17_6_BLOCKS`, 39 -> 40) and its differences are + // booked to objectui#11536 under objectui#11438 ruling A′. + // `registeredPropless` and `ledgeredUnjudgeable` do not move. + // 40 + 7 + 9 = 56. + specCarried: 56, + judged: 40, registeredPropless: 7, ledgeredUnjudgeable: 9, }); @@ -5748,6 +5873,8 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide) // entries must number EXACTLY its cap (so one more is red, and a struck one // demands the cap come down with it), each must be routed to the card the // ruling names for it, and each must carry the ruling and its expiry. + // objectui#11438 ruling A′ (record 5965062249) books the 17.6.0 bump's + // `record:line_items` entries the same way, to objectui#11536. const ledgers: Array<[keyof typeof OBJECTUI_11111_LEDGER_CAPS, Record]> = [ ['unjudgedBlocks', UNJUDGED_SPEC_BLOCKS], ['offSpecInputs', OFF_SPEC_EXEMPTIONS], @@ -5773,8 +5900,13 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide) expect(reason, `${id} does not cite the ruling`).toContain( 'objectui#11111 decision 3 = B (record 5902351047)', ); + // The record that booked it: decision 3 = B itself for the 17.5.0 + // owners, objectui#11438 ruling A′ for objectui#11536. + expect(reason, `${id} does not cite the record that booked it`).toContain( + `Booked by ${OBJECTUI_11111_BOOKINGS[owner].bookedBy}: `, + ); expect(reason, `${id} carries no expiry`).toContain( - `Expires ${OBJECTUI_11111_EXPIRES}, or when ${owner} lands`, + `Expires ${OBJECTUI_11111_BOOKINGS[owner].expires}, or when ${owner} lands`, ); reasons.push(reason); } @@ -5787,6 +5919,6 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide) reasons.filter((reason) => reason.startsWith(`${OWED_PREFIX}${owner}.`)).length, ]), ), - ).toEqual({ 'objectui#11168': 1, 'objectui#8652': 0, 'objectui#8649': 0 }); + ).toEqual({ 'objectui#11168': 1, 'objectui#8652': 0, 'objectui#8649': 0, 'objectui#11536': 12 }); }); }); From c9121f92f2e813446fda684e4eb696ba619a3c57 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 04:11:44 +0000 Subject: [PATCH 16/36] test(console): the group-5 booking's prose names no installed version beside a pin marker (objectui#11438) `check:installed-pin-claims` read two of the lines the booking added as present-tense claims about the installed spec: the `MINOR_17_6_BLOCKS` guard's docblock ("Does the installed ... carry the 17.6.0 set?") and the `memberPins` cap comment, where the version sat beside the objectui#11168 slice's "pinned". Both were true at 17.6.0 and would go stale at the next bump. The guard's docblock now asks about the spec this tree resolves, and the two cap comments name the ruling that booked the entries instead of the version. No assertion moves. Claude-Session: https://claude.ai/code/session_01HRYqpqGcWpJuJkDmbRF75w Co-authored-by: Claude --- .../src/__tests__/registry-inputs-spec-parity.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts index 7679290fd7..9c275605be 100644 --- a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts +++ b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts @@ -657,7 +657,7 @@ const specCarries175LoadedBlocks = MINOR_17_5_LOADED_BLOCKS.every((type) => type */ const MINOR_17_6_BLOCKS = ['record:line_items']; -/** Does the installed `@objectstack/spec` carry the 17.6.0 set? `every`, for the reason `specCarriesGaBlocks` gives. */ +/** Does the spec this tree resolves carry the 17.6.0 set? `every`, for the reason `specCarriesGaBlocks` gives. */ const specCarries176Blocks = MINOR_17_6_BLOCKS.every((type) => type in ComponentPropsMap); const EXPECTED_COVERED = [ @@ -865,9 +865,9 @@ const owedIdsOf = (ledger: Record): string[] => const OBJECTUI_11111_LEDGER_CAPS = { unjudgedBlocks: 0, // objectui#11168 loaded and judged all four: slice 3 object-map and object-tree, slice 4 object-gantt, slice 5 object-timeline offSpecInputs: 0, // objectui#11168 slice 1 retired action:group.name - unpublishedKeys: 11, // objectui#11168: 1 (action:button undoable; the two `endpoint` entries left at the 17.6.0 bump, objectui#11438, when the spec stopped declaring the key); objectui#8652: 0 and objectui#8649: 0 (each struck by its landing); objectui#11536: 10 (record:line_items, booked at the 17.6.0 bump) + unpublishedKeys: 11, // objectui#11168: 1 (action:button undoable; the two `endpoint` entries left at the 17.6.0 bump, objectui#11438, when the spec stopped declaring the key); objectui#8652: 0 and objectui#8649: 0 (each struck by its landing); objectui#11536: 10 (record:line_items, booked by objectui#11438 ruling A′) refusedArms: 0, // objectui#11168: slice 2 narrowed element:definition-list.columns, slice 3 object-form.layout - memberPins: 2, // objectui#11168 slice 2 pinned element:definition-list.items and element:repeater ×3; objectui#11536: 2 (record:line_items columns and dataSource, booked at the 17.6.0 bump) + memberPins: 2, // objectui#11168 slice 2 pinned element:definition-list.items and element:repeater ×3; objectui#11536: 2 (record:line_items columns and dataSource, booked by objectui#11438 ruling A′) } as const; /** From acd62a3a1bb2c37bf439fad232a4d8904a33b356 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 05:00:38 +0000 Subject: [PATCH 17/36] fix(core): ValueDataSource executes is_empty / is_not_empty and $empty on @objectstack/spec 17.6.0 (objectui#11094) 17.6.0 stops folding the empty pair onto is_null / is_not_null and lowers it to $empty, which joins FILTER_OPERATORS (objectstack#20570, objectstack#20446). The matcher had arms only for the null pair, so a stored is_empty rule on a provider: 'value' source reached the refusal arm and selected no row. The AST arm gains is_empty / is_not_empty and the object arm gains $empty, both through the spec's isEmptyFilterValue with no declared row: this adapter holds no field declarations, so it judges by value (null, absent, '' and [] are empty). A non-boolean $empty flag is refused. ValueDataSource.astFilterVocabulary is rewritten to the new fold, the dollar-vocabulary parity table gains $empty, and the conformance file's staged-$empty partition moves into the executed table as its expiry row instructed. Claude-Session: https://claude.ai/code/session_01YLg8XqWGJ785fwQ5v4pH37 Co-authored-by: Claude --- packages/core/src/adapters/README.md | 18 +- packages/core/src/adapters/ValueDataSource.ts | 86 +++++++++- ...alueDataSource.astFilterVocabulary.test.ts | 154 +++++++++++++++++- ...eDataSource.dollarFilterVocabulary.test.ts | 40 +++++ ...Source.filterLogicConformance-8513.test.ts | 82 +++++----- 5 files changed, 325 insertions(+), 55 deletions(-) diff --git a/packages/core/src/adapters/README.md b/packages/core/src/adapters/README.md index 09f5c80122..20ba1ddaf2 100644 --- a/packages/core/src/adapters/README.md +++ b/packages/core/src/adapters/README.md @@ -117,10 +117,13 @@ The object dialect executes one arm per member of the spec's `FILTER_OPERATORS`: ```text $eq $ne $gt $gte $lt $lte $in $nin $between -$contains $icontains $notContains $startsWith $endsWith $null $exists +$contains $icontains $notContains $startsWith $endsWith $null $exists $empty ``` -That is **all sixteen** — nothing the spec declares is refused by name. +That is **every member** — nothing the spec declares is refused by name. What is held +to the spec is the matcher's case table, not this list: +`ValueDataSource.dollarFilterVocabulary.test.ts` reddens when a release adds an +operator it has no case for. Nothing re-checks the list above. `$contains`, `$notContains`, `$startsWith` and `$endsWith` are **case-sensitive**; `$icontains` is the one case-insensitive member, its fold is **ASCII-only**, and its @@ -130,6 +133,17 @@ table below rather than folded (objectui#8748). is IS NOT NULL. `$exists` is its exact inverse — `$exists: true` is IS NOT NULL — which is the lowering `convertFiltersToAST` already performs, not a reading invented here. +`$empty` (objectui#11094) is not a null test. `$empty: true` selects a row whose value +is null, absent, `''` or `[]`, and `$empty: false` is its exact complement; a flag that +is not a boolean is refused. The AST twins are `is_empty` / `is_not_empty`, which +`@objectstack/spec` 17.6.0 stopped folding onto `is_null` / `is_not_null`. The answer is +the spec's own `isEmptyFilterValue`, called without a field declaration, because this +adapter holds none: the spec's ruled table is keyed on a field's declared type (a +text-like field is empty when null or `''`, a multi-value field when null or `[]`, +every other type when null), and a face with no declarations judges by value, as +`@objectstack/formula`'s matcher does. The two readings differ only on a value the +declared type does not predict, such as `''` stored in a number column. + A stored value that is **not a string** never satisfies `$contains`, `$icontains`, `$startsWith` or `$endsWith`, and always satisfies `$notContains` — the number `5` does not contain the substring `"5"`, so it is on the negation side. That is diff --git a/packages/core/src/adapters/ValueDataSource.ts b/packages/core/src/adapters/ValueDataSource.ts index 0b8eef295c..904c94d02b 100644 --- a/packages/core/src/adapters/ValueDataSource.ts +++ b/packages/core/src/adapters/ValueDataSource.ts @@ -22,6 +22,7 @@ import { asciiCaseInsensitiveContains, canonicalAstOperator, isAcceptedFilterComparand, + isEmptyFilterValue, RETIRED_FILTER_OPERATORS, } from '@objectstack/spec/data'; import { emulateBatchTransaction } from './batchTransaction.js'; @@ -111,11 +112,44 @@ const AST_LIST_COMPARAND_OPERATORS = new Set(['in', 'nin', 'between']); /** * The AST operators that never read the value slot at all, so nothing sitting - * there is a comparand. `matchesComparisonNode`'s null arms take their - * direction from the operator NAME and the ObjectUI client sends a truthy + * there is a comparand. `matchesComparisonNode`'s null and emptiness arms take + * their direction from the operator NAME and the ObjectUI client sends a truthy * placeholder in the third position, so the array guard must not judge it. + * The spec discards that slot on all four: `parseFilterAST(['x', 'is_empty', + * ['a']])` is `{ x: { $empty: true } }`. */ -const AST_NO_COMPARAND_OPERATORS = new Set(['is_null', 'is_not_null']); +const AST_NO_COMPARAND_OPERATORS = new Set(['is_null', 'is_not_null', 'is_empty', 'is_not_empty']); + +/** + * Is this stored value EMPTY, in the sense the spec's `$empty` operator gives + * the word (objectui#11094)? + * + * The answer is the spec's own `isEmptyFilterValue` (`@objectstack/spec/data`), + * called WITHOUT an expansion. That is the by-value reading: null, `undefined` + * (an absent key reads as `undefined` here), `''` and `[]` are empty, and + * nothing else is. + * + * ## Why no field type is read + * + * The spec's ruled 「is empty」 table (ruling B on objectstack#20311) is keyed + * on a field's DECLARED type and `multiple`: a text-like field is empty when + * null or `''`, a multi-value field when null or `[]`, every other type when + * null. A face that holds the declaration turns it into a row with + * `expandEmptyOperator` and passes that row to `isEmptyFilterValue`. This face + * holds none. `ValueDataSourceConfig` carries rows and an id field, and the + * spec's `value` data source declares only `items`. So this face judges by + * value, as ruling A on objectstack#20399 gives the faces with no field + * declarations. `@objectstack/formula`'s matcher and `driver-memory`'s + * reference matcher make the same call to the same function. + * + * ⛔ No per-type table is kept here, and none is inferred from the rows. The + * by-value reading differs from a declared face only on a stored state the + * declaration does not predict: `''` in a non-text column, or `[]` in a scalar + * one. The spec calls the first a write-door defect rather than a stored state. + */ +function isEmptyStoredValue(value: unknown): boolean { + return isEmptyFilterValue(value); +} /** * An ARRAY where a single-value comparand belongs — `{ tags: ['a', 'b'] }`, @@ -503,14 +537,28 @@ function matchesComparisonNode( // -- Null-ness. Direction comes from the operator NAME; the value slot is // never read, so the 2-tuple `['x', 'is_not_null']` and the 3-tuple // `['x', 'isnotnull', null]` are the same predicate. `canonicalAstOperator` - // folds all eight spellings (`is_null` / `isnull` / `is_empty` / `isempty` - // and their four negatives) onto these two arms — including `is_empty`, - // which the spec lowers to `$null` rather than to an emptiness test. + // folds `is_null` / `isnull` onto the first arm and their two negatives + // onto the second. case 'is_null': return value === null || value === undefined; case 'is_not_null': return value !== null && value !== undefined; + // -- Emptiness (objectui#11094). The spec stopped folding `is_empty` / + // `isempty` onto `is_null` (objectstack#20570, in `@objectstack/spec` + // 17.6.0): `canonicalAstOperator` now answers `is_empty` for those two + // spellings and `is_not_empty` for their two negatives, and the spec + // lowers them to `$empty: true` / `$empty: false`. So they get their own + // arms, the twins of the `$empty` arm in `matchesDollarOperator`. Without + // these arms, on that release a stored `is_empty` rule reached the refusal + // below and selected no row. Direction comes from the operator NAME, as + // for null-ness, and the meaning is the spec's emptiness test, not a null + // test: see {@link isEmptyStoredValue}. + case 'is_empty': + return isEmptyStoredValue(value); + case 'is_not_empty': + return !isEmptyStoredValue(value); + // objectui#10829 — equality and membership through `comparandEquals`, so // two `Date`s compare their instant; the ordering arms below are untouched. case '=': @@ -686,8 +734,11 @@ function matchesASTFilter(record: any, filterNode: any, refusals: Set): * The vocabulary is the spec's own `FILTER_OPERATORS` (`@objectstack/spec/data`) * and each arm answers the same question its AST twin answers in * {@link matchesComparisonNode} — `$eq`/`=`, `$nin`/`nin`, `$startsWith`/ - * `starts_with`, and so on, one-to-one across ALL SIXTEEN: every declared - * operator is executed here, none is refused by name. That pairing IS the + * `starts_with`, `$empty`/`is_empty`, and so on, one-to-one across EVERY + * member: every declared operator is executed here, none is refused by name. + * How many members there are is not written here: the case table in + * `ValueDataSource.dollarFilterVocabulary.test.ts` is held equal to + * `FILTER_OPERATORS`, and it is what re-derives that. That pairing IS the * fix for objectui#8447: `find()` picks between the two matchers on nothing * more than whether `$filter` arrived as an array or an object, so any operator * one of them executes and the other waves through is a result that changes @@ -829,6 +880,25 @@ function matchesDollarOperator( ? value !== null && value !== undefined : value === null || value === undefined; + // objectui#11094 — `$empty`, which `@objectstack/spec` 17.6.0 admitted to + // `FILTER_OPERATORS` (objectstack#20446) and to which it lowers `is_empty` / + // `is_not_empty`. `true` selects the empty rows and `false` is the exact + // complement, by value (see {@link isEmptyStoredValue}); the AST twins are + // the `is_empty` / `is_not_empty` arms in `matchesComparisonNode`. + // + // The flag is a BOOLEAN by declaration (`SpecialOperatorSchema`), so + // anything else is refused here rather than read for its truthiness. That + // is the answer the spec gives at its save door and on every query face, + // and `@objectstack/formula`'s matcher denies a non-boolean flag too. + case '$empty': + if (target === true) return isEmptyStoredValue(value); + if (target === false) return !isEmptyStoredValue(value); + return refuseFilterNode( + refusals, + `filter operator '$empty' on field '${field}' takes a boolean flag (true or false); ` + + `received ${describeComparand(target)}`, + ); + // objectui#8515 — the hand-authored IMPLICIT form `{ amount: { $field: 'x' } }`. // It is not a reference comparand: an object whose only key starts with `$` // reads as an OPERATOR SPEC named `$field`, which is why this arrives here diff --git a/packages/core/src/adapters/__tests__/ValueDataSource.astFilterVocabulary.test.ts b/packages/core/src/adapters/__tests__/ValueDataSource.astFilterVocabulary.test.ts index bcb254979a..00ddd40558 100644 --- a/packages/core/src/adapters/__tests__/ValueDataSource.astFilterVocabulary.test.ts +++ b/packages/core/src/adapters/__tests__/ValueDataSource.astFilterVocabulary.test.ts @@ -33,10 +33,28 @@ * never read the value slot, in every spelling the spec folds onto them. * 3. An operator or shape the matcher cannot execute excludes the row and says * so, instead of passing every row silently. + * + * ## The fold moved under §2 (objectui#11094) + * + * Until `@objectstack/spec` 17.6.0, `canonicalAstOperator` folded `is_empty` / + * `isempty` onto `is_null` and their negatives onto `is_not_null`, and §2 pinned + * all eight spellings as null tests. objectstack#20570 gave the empty pair its + * own names and lowers them to the `$empty` operator, whose meaning is an + * emptiness test: `''` and `[]` count as empty beside null. On that release this + * matcher had no arm for them and refused every stored `is_empty` rule, which + * selected no row. §2 is rewritten to the new fold: the null pair stays a null + * test, and the empty pair is pinned in §2b over text, multi-value and number + * columns, `''` and `[]` included. */ import { describe, it, expect, vi, afterEach } from 'vitest'; import { VIEW_FILTER_OPERATORS } from '@objectstack/spec/ui'; +import { + canonicalAstOperator, + EMPTY_OPERATOR_ARMS, + isEmptyFilterValue, + parseFilterAST, +} from '@objectstack/spec/data'; import { ValueDataSource } from '../ValueDataSource'; import { mergeFilterNodes, toFilterNode } from '../../utils/filter-converter'; @@ -127,9 +145,34 @@ const NULL_ROWS = [ ]; /** Every spelling the spec's `canonicalAstOperator` folds onto `is_null`. */ -const IS_NULL_SPELLINGS = ['is_null', 'isnull', 'is_empty', 'isempty']; -/** …and onto `is_not_null`. `is_empty` folds to `$null` in the spec too. */ -const IS_NOT_NULL_SPELLINGS = ['is_not_null', 'isnotnull', 'is_not_empty', 'isnotempty']; +const IS_NULL_SPELLINGS = ['is_null', 'isnull']; +/** …and onto `is_not_null`. */ +const IS_NOT_NULL_SPELLINGS = ['is_not_null', 'isnotnull']; +/** + * The two spellings it folds onto `is_empty`, and the two onto `is_not_empty`. + * They folded onto the null pair above until `@objectstack/spec` 17.6.0. + */ +const IS_EMPTY_SPELLINGS = ['is_empty', 'isempty']; +const IS_NOT_EMPTY_SPELLINGS = ['is_not_empty', 'isnotempty']; + +describe('objectui#11094 — the fold this file is written against', () => { + it('the null pair and the empty pair are four canonical operators, not two', () => { + // The spec's answer, read rather than restated, so a release that moves the + // fold again reddens HERE first, beside the reason, instead of only in the + // row sets below. + for (const op of IS_NULL_SPELLINGS) expect(canonicalAstOperator(op)).toBe('is_null'); + for (const op of IS_NOT_NULL_SPELLINGS) expect(canonicalAstOperator(op)).toBe('is_not_null'); + for (const op of IS_EMPTY_SPELLINGS) expect(canonicalAstOperator(op)).toBe('is_empty'); + for (const op of IS_NOT_EMPTY_SPELLINGS) expect(canonicalAstOperator(op)).toBe('is_not_empty'); + }); + + it('and the spec lowers the empty pair to `$empty`, not to `$null`', () => { + expect(parseFilterAST(['x', 'is_empty'])).toEqual({ x: { $empty: true } }); + expect(parseFilterAST(['x', 'is_not_empty'])).toEqual({ x: { $empty: false } }); + // Lit control: the null pair still lowers to `$null`. + expect(parseFilterAST(['x', 'is_null'])).toEqual({ x: { $null: true } }); + }); +}); describe('objectui#7349 — null-ness takes direction from the operator NAME', () => { it.each(IS_NULL_SPELLINGS)('`%s` selects null, undefined and the absent key', async (op) => { @@ -164,6 +207,111 @@ describe('objectui#7349 — null-ness takes direction from the operator NAME', ( }); }); +// --------------------------------------------------------------------------- +// 2b. Emptiness — the empty pair is its own test since spec 17.6.0 +// --------------------------------------------------------------------------- + +/** + * A text column (`name`), a multi-value column (`tags`) and a number column + * (`score`), each holding a value, its type's "blank" (`''`, `[]`, `0`), null, + * and no key at all. The spec's ruled table makes `''` empty on a text field and + * `[]` empty on a multi-value field, and leaves `0` a value, so the three + * columns give three different answers to one operator — and the text column's + * answer differs from `is_null`'s, which is the flip itself. + */ +const EMPTY_ROWS = [ + { id: 'full', name: 'ada', tags: ['x'], score: 7 }, + { id: 'blank', name: '', tags: [], score: 0 }, + { id: 'null', name: null, tags: null, score: null }, + { id: 'missing' }, +]; +const EMPTY_ROW_IDS = EMPTY_ROWS.map((r) => r.id); + +/** Per column: the rows `is_empty` selects. `is_not_empty` selects the rest. */ +const EMPTY_BY_COLUMN: Record = { + name: ['blank', 'null', 'missing'], + tags: ['blank', 'null', 'missing'], + score: ['null', 'missing'], +}; + +const complementOf = (ids: string[]) => EMPTY_ROW_IDS.filter((id) => !ids.includes(id)); + +describe('objectui#11094 — `is_empty` / `is_not_empty` are an emptiness test', () => { + describe.each(Object.entries(EMPTY_BY_COLUMN))('on the `%s` column', (column, empty) => { + it.each(IS_EMPTY_SPELLINGS)('`%s` selects the empty rows', async (op) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + expect(await selectedIds([[column, op]], EMPTY_ROWS)).toEqual(empty); + // Executed, not refused: on 17.6.0 without the arm this was `[]` plus + // one logged refusal. + expect(warn).not.toHaveBeenCalled(); + }); + + it.each(IS_NOT_EMPTY_SPELLINGS)('`%s` selects exactly the rest', async (op) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + expect(await selectedIds([[column, op]], EMPTY_ROWS)).toEqual(complementOf(empty)); + expect(warn).not.toHaveBeenCalled(); + }); + + it('answers what the spec\'s own `isEmptyFilterValue` answers, row by row', async () => { + // The by-value reading, called with no declared row: the function itself + // is the reference, so this table cannot drift into a second copy of it. + const expected = EMPTY_ROWS + .filter((row) => isEmptyFilterValue((row as Record)[column])) + .map((row) => row.id); + expect(await selectedIds([column, 'is_empty'], EMPTY_ROWS)).toEqual(expected); + }); + + it('the object dialect\'s `$empty` gives the same rows, both directions', async () => { + expect(await selectedIds({ [column]: { $empty: true } }, EMPTY_ROWS)).toEqual(empty); + expect(await selectedIds({ [column]: { $empty: false } }, EMPTY_ROWS)) + .toEqual(complementOf(empty)); + }); + }); + + it('is not a null test any more — the flip, on the text column', async () => { + // `''` is the row that moves. Before 17.6.0 both operators were one arm. + expect(await selectedIds([['name', 'is_null']], EMPTY_ROWS)).toEqual(['null', 'missing']); + expect(await selectedIds([['name', 'is_empty']], EMPTY_ROWS)).toEqual(['blank', 'null', 'missing']); + expect(await selectedIds([['name', 'is_not_null']], EMPTY_ROWS)).toEqual(['full', 'blank']); + expect(await selectedIds([['name', 'is_not_empty']], EMPTY_ROWS)).toEqual(['full']); + }); + + it('with no declared type, a value is judged by value — `\'\'` in a number column is empty', async () => { + // This adapter holds no field declarations (its config is rows and an id + // field), so it takes the by-value reading the spec gives such faces. That + // differs from a face that holds the declaration on exactly this kind of + // value: a number field is the `null_only` row of the ruled table, which + // would leave `''` (and `[]`) a value. The control below is the spec's own + // function asked both ways, so the divergence this case pins is the + // spec's, not this file's. + expect(isEmptyFilterValue('')).toBe(true); + expect(isEmptyFilterValue('', EMPTY_OPERATOR_ARMS.null_only)).toBe(false); + expect(isEmptyFilterValue([], EMPTY_OPERATOR_ARMS.null_only)).toBe(false); + const rows = [ + { id: 'number', score: 3 }, + { id: 'zero', score: 0 }, + { id: 'blank-string', score: '' }, + { id: 'blank-list', score: [] }, + ]; + expect(await selectedIds([['score', 'is_empty']], rows)).toEqual(['blank-string', 'blank-list']); + expect(await selectedIds([['score', 'is_not_empty']], rows)).toEqual(['number', 'zero']); + }); + + it('never reads the value slot — filler, null, or a stray array', async () => { + // Same rule as the null pair: direction from the NAME. An array in the slot + // is not an array comparand here (the spec discards it), so it is neither + // refused nor read. + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const empty = EMPTY_BY_COLUMN.tags; + expect(await selectedIds(['tags', 'is_empty'], EMPTY_ROWS)).toEqual(empty); + expect(await selectedIds(['tags', 'is_empty', null], EMPTY_ROWS)).toEqual(empty); + expect(await selectedIds(['tags', 'is_empty', true], EMPTY_ROWS)).toEqual(empty); + expect(await selectedIds(['tags', 'isempty', ['x']], EMPTY_ROWS)).toEqual(empty); + expect(await selectedIds(['tags', 'is_not_empty', 'FILLER'], EMPTY_ROWS)).toEqual(complementOf(empty)); + expect(warn).not.toHaveBeenCalled(); + }); +}); + // --------------------------------------------------------------------------- // 3. Refusal — the arm that used to be `return true` // --------------------------------------------------------------------------- diff --git a/packages/core/src/adapters/__tests__/ValueDataSource.dollarFilterVocabulary.test.ts b/packages/core/src/adapters/__tests__/ValueDataSource.dollarFilterVocabulary.test.ts index 5a6bdb0443..fd3e651efc 100644 --- a/packages/core/src/adapters/__tests__/ValueDataSource.dollarFilterVocabulary.test.ts +++ b/packages/core/src/adapters/__tests__/ValueDataSource.dollarFilterVocabulary.test.ts @@ -200,6 +200,15 @@ const EXECUTED_CASES: Record< ast: ['nickname', 'is_not_null'], expected: ['a'], }, + // objectui#11094 — admitted to `FILTER_OPERATORS` by `@objectstack/spec` + // 17.6.0, which lowers `is_empty` to it. On these rows (no `''`, no `[]`) + // it selects what `$null: true` selects; the case that tells them apart is + // below, and the per-type cases are in `ValueDataSource.astFilterVocabulary`. + $empty: { + filter: { nickname: { $empty: true } }, + ast: ['nickname', 'is_empty'], + expected: ['b', 'c'], + }, }; /** @@ -235,6 +244,37 @@ describe('objectui#8447 — every executed operator constrains', () => { expect(warn).not.toHaveBeenCalled(); }); + it('`$empty: false` is the other direction of ITS operator', async () => { + const warn = spyWarn(); + expect(await selectedIds({ nickname: { $empty: false } })).toEqual(['a']); + expect(warn).not.toHaveBeenCalled(); + }); + + it('`$empty` is not `$null`: `\'\'` and `[]` are empty, not values (objectui#11094)', async () => { + // The row set that separates the two operators the fixture above cannot. + const rows = [ + { id: 'a', nickname: 'ace' }, + { id: 'blank', nickname: '' }, + { id: 'list', nickname: [] }, + { id: 'null', nickname: null }, + ]; + expect(await selectedIds({ nickname: { $null: true } }, rows)).toEqual(['null']); + expect(await selectedIds({ nickname: { $empty: true } }, rows)).toEqual(['blank', 'list', 'null']); + expect(await selectedIds({ nickname: { $empty: false } }, rows)).toEqual(['a']); + }); + + it.each([['yes'], [1], [0], [null]])( + '`$empty: %j` — a flag that is not a boolean — is refused, not read for truthiness', + async (flag) => { + // `SpecialOperatorSchema` declares the flag `boolean`. Reading `'yes'` as + // true and `0` as false would answer a filter the spec refuses. + const warn = spyWarn(); + expect(await selectedIds({ nickname: { $empty: flag } })).toEqual([]); + expect(warn).toHaveBeenCalledTimes(1); + expect(String(warn.mock.calls[0]?.[0])).toContain('$empty'); + }, + ); + it('`$exists` and `$null` are one predicate read from opposite ends', async () => { // Not a restatement of the two cases above: it pins the INVERSION, so an // arm that got the boolean the wrong way round fails here even if each diff --git a/packages/core/src/adapters/__tests__/ValueDataSource.filterLogicConformance-8513.test.ts b/packages/core/src/adapters/__tests__/ValueDataSource.filterLogicConformance-8513.test.ts index 927b394a07..861393aad1 100644 --- a/packages/core/src/adapters/__tests__/ValueDataSource.filterLogicConformance-8513.test.ts +++ b/packages/core/src/adapters/__tests__/ValueDataSource.filterLogicConformance-8513.test.ts @@ -47,10 +47,16 @@ * * `@objectstack/spec` 17.5.0 added a second partition of the same kind: the * STAGED `$empty` flag (objectstack#20444), enrolled in the table while the spec - * keeps it out of `FILTER_OPERATORS` and says the executors refuse it until each - * face has its arm. It is pinned the same way — by name, by direction, and with + * kept it out of `FILTER_OPERATORS` and said the executors refuse it until each + * face has its arm. It was pinned the same way — by name, by direction, and with * an expiry row that reddens when the staging ends (objectui#11073). * + * That row reddened on `@objectstack/spec` 17.6.0, which admitted `$empty` to + * `FILTER_OPERATORS` (objectstack#20446). This face owed its arm, and has it + * (objectui#11094), so the partition moved back into the executed table as the + * expiry row instructed. The by-name pin stays, now over the executed `$empty` + * cases, so a new `$empty` case landing upstream is still a decision. + * * That last one is why this file asserts the refusal instead of `it.skip`: * `$not` failed in the OPPOSITE direction from `$and` / `$or` before #8447, and * a fix that flattened the three combinators into one arm is the specific @@ -84,19 +90,20 @@ import { toFilterNode } from '../../utils/filter-converter'; const carriesNot = (filter: unknown): boolean => JSON.stringify(filter).includes('"$not"'); /** - * Cases whose filter mentions the STAGED `$empty` flag (objectstack#20444), - * which `@objectstack/spec` 17.5.0 enrolled in this table while keeping it OUT - * of `FILTER_OPERATORS`: "Until each face has its arm, the query executors - * refuse it". This matcher has no arm, so it refuses — the staged behaviour — - * and that partition is pinned below by name and direction, exactly as `$not`'s - * is (objectui#11073). A `$not` over `$empty` stays in the `$not` partition: - * the outer refusal is the one this matcher gives. + * Cases whose filter mentions the `$empty` flag (objectstack#20444). + * `@objectstack/spec` 17.5.0 enrolled them in this table while keeping the flag + * OUT of `FILTER_OPERATORS`, and this matcher refused them as staged + * (objectui#11073). 17.6.0 admitted the flag (objectstack#20446) and this + * matcher executes it (objectui#11094), so they are now a SUBSET of the + * executed cases, named below so that the subset is a decision rather than an + * accident. A `$not` over `$empty` stays in the `$not` partition: the outer + * refusal is the one this matcher gives. */ const carriesEmpty = (filter: unknown): boolean => JSON.stringify(filter).includes('"$empty"'); const NOT_CASES = FILTER_LOGIC_CASES.filter((c) => carriesNot(c.filter)); -const STAGED_EMPTY_CASES = FILTER_LOGIC_CASES.filter((c) => !carriesNot(c.filter) && carriesEmpty(c.filter)); -const EXECUTED_CASES = FILTER_LOGIC_CASES.filter((c) => !carriesNot(c.filter) && !carriesEmpty(c.filter)); +const EXECUTED_CASES = FILTER_LOGIC_CASES.filter((c) => !carriesNot(c.filter)); +const EMPTY_CASES = EXECUTED_CASES.filter((c) => carriesEmpty(c.filter)); async function selectedIds(filter: unknown): Promise { const ds = new ValueDataSource({ items: FILTER_LOGIC_ROWS as any[] }); @@ -117,16 +124,16 @@ afterEach(() => { // --------------------------------------------------------------------------- describe('objectui#8513 — the conformance harness discriminates', () => { - it('the table is present, non-trivial, and split into three non-empty parts', () => { + it('the table is present, non-trivial, and split into two non-empty parts', () => { // A table that failed to import would make every `it.each` below vacuous. expect(FILTER_LOGIC_CASES.length).toBeGreaterThan(20); expect(FILTER_LOGIC_ROWS.length).toBe(4); expect(EXECUTED_CASES.length).toBeGreaterThan(0); expect(NOT_CASES.length).toBeGreaterThan(0); - expect(STAGED_EMPTY_CASES.length).toBeGreaterThan(0); - expect(EXECUTED_CASES.length + NOT_CASES.length + STAGED_EMPTY_CASES.length).toBe( - FILTER_LOGIC_CASES.length, - ); + expect(EXECUTED_CASES.length + NOT_CASES.length).toBe(FILTER_LOGIC_CASES.length); + // …and the executed part really carries the `$empty` cases, so the run + // below exercises that arm rather than passing around it. + expect(EMPTY_CASES.length).toBeGreaterThan(0); }); it('the fixture discriminates: no expectation is the whole table AND none is empty', () => { @@ -203,13 +210,24 @@ describe('objectui#8513 — `$not` is out of scope, and stays refused', () => { }); // --------------------------------------------------------------------------- -// 2b. The STAGED `$empty` partition — refused, as the spec stages it +// 2b. The `$empty` cases — staged until 17.6.0, executed since (objectui#11094) // --------------------------------------------------------------------------- -describe('objectui#11073 — the staged `$empty` flag is refused by this matcher, as the spec stages it', () => { - it('the staged partition is exactly these cases', () => { - // By NAME, so another staged case landing upstream is a decision. - expect(STAGED_EMPTY_CASES.map((c) => c.name).sort()).toEqual( +describe('objectui#11094 — the `$empty` flag is executed now that the spec admits it', () => { + it('the staging is over upstream — `$empty` is in FILTER_OPERATORS', () => { + // The expiry row objectui#11073 left here, flipped. It read "not in" and + // reddened on `@objectstack/spec` 17.6.0 (objectstack#20446), which is the + // day this face owed its arm; the partition it guarded moved into the + // executed table above. Lit control: a flag operator that was always in. + expect(FILTER_OPERATORS as readonly string[]).toContain('$empty'); + expect(FILTER_OPERATORS as readonly string[]).toContain('$null'); + }); + + it('the formerly staged partition is exactly these cases, and every one is executed', () => { + // By NAME, so another `$empty` case landing upstream is a decision. They + // run in §1 with the rest of the executed table: the expected row set, and + // no logged refusal. + expect(EMPTY_CASES.map((c) => c.name).sort()).toEqual( [ '$empty true selects exactly the no-value rows', '$empty false selects exactly the valued rows', @@ -218,28 +236,8 @@ describe('objectui#11073 — the staged `$empty` flag is refused by this matcher '$empty ANDs with a sibling operator on the same field', ].sort(), ); + for (const testCase of EMPTY_CASES) expect(EXECUTED_CASES).toContain(testCase); }); - - it('the staging still holds upstream — `$empty` is not in FILTER_OPERATORS', () => { - // The expiry of this partition. The day the flip card adds `$empty` to - // `FILTER_OPERATORS`, the query executors stop refusing it and this face - // owes its arm: this row reddens, and the partition above moves back into - // the executed table. Lit control: a flag operator that IS in the list. - expect(FILTER_OPERATORS as readonly string[]).not.toContain('$empty'); - expect(FILTER_OPERATORS as readonly string[]).toContain('$null'); - }); - - it.each(STAGED_EMPTY_CASES.map((c) => [c.name, c] as const))( - 'refused, not answered: %s', - async (_name, testCase) => { - const warn = spyWarn(); - // A visibly different answer from the table's, AND a logged reason that - // names the operator — never a silent partial answer. - expect(await selectedIds(testCase.filter)).not.toEqual(testCase.expected); - expect(warn).toHaveBeenCalledTimes(1); - expect(String(warn.mock.calls[0]?.[0])).toContain('$empty'); - }, - ); }); // --------------------------------------------------------------------------- From 158204d7980e13947b4ced966b25c0f56706651f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 05:00:59 +0000 Subject: [PATCH 18/36] fix(core): convertFiltersToAST lowers $empty, the canonical operator it refused (objectui#11094) $empty is a member of FILTER_OPERATORS since @objectstack/spec 17.6.0, and ValueDataSource now executes it, but the $-dialect lowering had no arm and threw "Unknown filter operator". The objectui#8976 shape, one operator over: the README pin's FILTER_OPERATORS completeness check reddened on it. $empty: true lowers to [f, 'is_empty', true] and false to [f, 'is_not_empty', true], the inverse of the spec's parseFilterAST, written the way $null is. A non-boolean flag throws the INVALID_FILTER / 400 envelope, matching ValueDataSource's refusal. The unknown-operator message lists $empty, and the data-objectstack README gains its row. Claude-Session: https://claude.ai/code/session_01YLg8XqWGJ785fwQ5v4pH37 Co-authored-by: Claude --- .../filter-empty-operator-11094.test.ts | 137 ++++++++++++++++++ packages/core/src/utils/filter-converter.ts | 32 +++- packages/data-objectstack/README.md | 22 ++- 3 files changed, 184 insertions(+), 7 deletions(-) create mode 100644 packages/core/src/utils/__tests__/filter-empty-operator-11094.test.ts diff --git a/packages/core/src/utils/__tests__/filter-empty-operator-11094.test.ts b/packages/core/src/utils/__tests__/filter-empty-operator-11094.test.ts new file mode 100644 index 0000000000..c9ee9ce1e1 --- /dev/null +++ b/packages/core/src/utils/__tests__/filter-empty-operator-11094.test.ts @@ -0,0 +1,137 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#11094 — the `$empty` operator through this repo's `$`-dialect + * lowering, and the same filter through both arms of `ValueDataSource`. + * + * `@objectstack/spec` 17.6.0 admitted `$empty` to `FILTER_OPERATORS` + * (objectstack#20446) and lowers the view operators `is_empty` / + * `is_not_empty` to it (objectstack#20570). Before this card, + * `convertFiltersToAST` had no arm for it and threw "Unknown filter operator", + * so an object filter carrying the canonical operator never reached the wire, + * while `ValueDataSource` was about to execute it. That is the + * objectui#8976 shape (`$icontains`), one operator over. + * + * Three facts are pinned here: + * + * 1. the lowering is the inverse of the spec's own `parseFilterAST`, so the + * node round-trips through the server's reader to the filter the author + * wrote; + * 2. a flag that is not a boolean is refused in the `INVALID_FILTER` / 400 + * envelope rather than read for its truthiness; + * 3. one authored filter selects one row set, whether it reaches + * `ValueDataSource` as the object, as the lowered node, or as a stored + * view rule. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { isFilterAST, parseFilterAST } from '@objectstack/spec/data'; +import { convertFiltersToAST, toFilterNode, FilterOperatorError } from '../filter-converter'; +import { ValueDataSource } from '../../adapters/ValueDataSource'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('objectui#11094 — convertFiltersToAST lowers `$empty`', () => { + it('`$empty: true` lowers to `is_empty`, and `false` to `is_not_empty`', () => { + // The `true` in the value slot is the placeholder `$null` and `$exists` + // already carry: direction comes from the operator NAME. + expect(convertFiltersToAST({ a: { $empty: true } })).toEqual(['a', 'is_empty', true]); + expect(convertFiltersToAST({ a: { $empty: false } })).toEqual(['a', 'is_not_empty', true]); + }); + + it('round-trips through the spec\'s own reader to the filter that was written', () => { + for (const flag of [true, false]) { + const node = convertFiltersToAST({ a: { $empty: flag } }); + expect(isFilterAST(node)).toBe(true); + expect(parseFilterAST(node)).toEqual({ a: { $empty: flag } }); + } + }); + + it('composes with a sibling key like every other operator', () => { + expect(convertFiltersToAST({ a: { $empty: true }, b: 1 })).toEqual([ + 'and', + ['a', 'is_empty', true], + ['b', '=', 1], + ]); + }); + + it.each([['yes'], [1], [0], [null], [[]]])( + 'refuses a flag that is not a boolean — `$empty: %j`', + (flag) => { + let thrown: unknown; + try { + convertFiltersToAST({ a: { $empty: flag } }); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(FilterOperatorError); + // The envelope a refusal is read by, not merely "it threw". + expect(thrown).toMatchObject({ + code: 'INVALID_FILTER', + httpStatus: 400, + operator: '$empty', + field: 'a', + }); + expect((thrown as Error).message).toContain('$empty'); + }, + ); + + it('the unknown-operator message lists it among the supported operators', () => { + let message = ''; + try { + convertFiltersToAST({ a: { $definitelyNotAnOperator: 1 } }); + } catch (error) { + message = (error as Error).message; + } + expect(message).toMatch(/Supported operators:[^.]*\$empty/); + }); +}); + +/** + * Rows on which `$empty` and `$null` disagree: a `''` and a `[]`. On a fixture + * without them the two operators select the same rows, and nothing here could + * tell an emptiness test from a null test. + */ +const ROWS = [ + { id: 'full', v: 'x' }, + { id: 'blank', v: '' }, + { id: 'list', v: [] }, + { id: 'null', v: null }, + { id: 'missing' }, +]; + +async function selectedIds(filter: unknown): Promise { + const ds = new ValueDataSource({ items: ROWS }); + const result = await ds.find('rows', { $filter: filter as any }); + return result.data.map((r) => r.id as string); +} + +describe('objectui#11094 — one `$empty` filter, one row set, on every path into ValueDataSource', () => { + it.each([ + [true, ['blank', 'list', 'null', 'missing']], + [false, ['full']], + ] as const)('`$empty: %s`', async (flag, expected) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const object = { v: { $empty: flag } }; + const lowered = toFilterNode(object); + const storedRule = toFilterNode([{ field: 'v', operator: flag ? 'is_empty' : 'is_not_empty' }]); + + expect(await selectedIds(object)).toEqual(expected); + expect(await selectedIds(lowered)).toEqual(expected); + expect(await selectedIds(storedRule)).toEqual(expected); + expect(warn).not.toHaveBeenCalled(); + }); + + it('and none of the three is the null test it used to be', async () => { + expect(await selectedIds({ v: { $null: true } })).toEqual(['null', 'missing']); + expect(await selectedIds({ v: { $empty: true } })).not.toEqual(['null', 'missing']); + }); +}); diff --git a/packages/core/src/utils/filter-converter.ts b/packages/core/src/utils/filter-converter.ts index 5d04847cc4..ff63bb7dc4 100644 --- a/packages/core/src/utils/filter-converter.ts +++ b/packages/core/src/utils/filter-converter.ts @@ -578,7 +578,9 @@ function refuseEmptyOperatorMap(field: string): never { * `ValueDataSource` has refused since objectui#8748; see * {@link refuseTextComparand} (objectui#9001), or if a field is an EMPTY * operator map (`{ a: {} }`) — alone (objectui#9164) or - * beside a key that lowers (objectui#10788): see {@link refuseEmptyOperatorMap}. + * beside a key that lowers (objectui#10788): see {@link refuseEmptyOperatorMap}, + * or if a `$empty` flag is not a boolean (`{ a: { $empty: 'yes' } }`, + * objectui#11094). * * @example * // A field with NO operator (objectui#9164) @@ -852,6 +854,32 @@ export function convertFiltersToAST( conditions.push([field, operatorValue ? 'is_not_null' : 'is_null', true]); continue; } + // objectui#11094 — `$empty`, admitted to the spec's `FILTER_OPERATORS` + // in `@objectstack/spec` 17.6.0 (objectstack#20446). The spec's own + // `parseFilterAST` reads `is_empty` as `$empty: true` and `is_not_empty` + // as `$empty: false`, so this is the inverse of that lowering, written + // the way `$null` is written above: direction from the flag, a `true` + // placeholder in the value slot, which the spec discards. + // + // The flag is a BOOLEAN by declaration (`SpecialOperatorSchema`), so a + // non-boolean is refused rather than read for its truthiness. That is + // the answer the spec's save door and every query face give, and + // `ValueDataSource`'s `$empty` arm refuses the same values, so one + // authored filter gets one answer whichever data source is behind it. + // Until this arm the operator reached the unknown-operator throw below. + if (operator === '$empty') { + if (operatorValue !== true && operatorValue !== false) { + throw new FilterOperatorError( + `[ObjectUI] The '$empty' filter operator on field '${field}' takes a boolean flag ` + + `(true or false); received ${JSON.stringify(operatorValue) ?? String(operatorValue)}. ` + + `Write { ${field}: { $empty: true } } for an empty value, or ` + + `{ ${field}: { $empty: false } } for a non-empty one.`, + { operator: '$empty', field }, + ); + } + conditions.push([field, operatorValue ? 'is_empty' : 'is_not_empty', true]); + continue; + } const astOperator = convertOperatorToAST(operator); @@ -892,7 +920,7 @@ export function convertFiltersToAST( throw new FilterOperatorError( `[ObjectUI] Unknown filter operator '${operator}' for field '${field}'. ` + `Supported operators: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $between, ` + - `$contains, $notContains, $startsWith, $endsWith, $icontains, $null, $exists. ` + + `$contains, $notContains, $startsWith, $endsWith, $icontains, $null, $exists, $empty. ` + `If you need exact object matching, use the value directly without an operator.`, { operator, field }, ); diff --git a/packages/data-objectstack/README.md b/packages/data-objectstack/README.md index 9b409e9cb1..5c864e3168 100644 --- a/packages/data-objectstack/README.md +++ b/packages/data-objectstack/README.md @@ -160,11 +160,23 @@ of going unnoticed. | `$icontains` | `icontains` | `{ name: { $icontains: 'john' } }` → `['name', 'icontains', 'john']` | | `$null` | `is_null` / `is_not_null` | `{ email: { $null: true } }` → `['email', 'is_null', true]` | | `$exists` | `is_not_null` / `is_null` | `{ email: { $exists: true } }` → `['email', 'is_not_null', true]` | - -`$null` and `$exists` read their boolean: `$null: false` lowers to -`is_not_null` and `$exists: false` to `is_null`. The lowered node's value slot -is always `true` — the direction comes from the operator name, which is how the -spec's `data/filter.zod.ts` reads it. +| `$empty` | `is_empty` / `is_not_empty` | `{ email: { $empty: true } }` → `['email', 'is_empty', true]` | + +`$null`, `$exists` and `$empty` read their boolean: `$null: false` lowers to +`is_not_null`, `$exists: false` to `is_null` and `$empty: false` to +`is_not_empty`. The lowered node's value slot is always `true` — the direction +comes from the operator name, which is how the spec's `data/filter.zod.ts` +reads it. `$empty` takes ONLY a boolean: any other flag throws `INVALID_FILTER` +/ 400 at lowering time, which is how the spec's own doors treat it. + +`$empty` is not `$null`. `@objectstack/spec` 17.6.0 admitted it to +`FILTER_OPERATORS` and lowers the view operators `is_empty` / `is_not_empty` to +it (objectstack#20446); they lowered to `$null` before. Its meaning is the +spec's ruled per-type table: a text-like field is empty when null or `''`, a +multi-value field when null or `[]`, and any other field when null. Which +backend applies that table, and how, is listed in the docblock of the spec's +`FILTER_OPERATORS`, not here. Until objectui#11094 `convertFiltersToAST` +refused `$empty` as an unknown operator. `$icontains` constrains its **comparand**, which no other row in this table does: `@objectstack/spec`'s `FILTER_TEXT_CASES` declares an empty or non-string From 9786e01289f6bde9f23f838a4f2f483a32e06add Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 05:00:59 +0000 Subject: [PATCH 19/36] test: promote the staged-$empty tripwires to @objectstack/spec 17.6.0 and declare the slice's changeset (objectui#11094) filter-source-merge: the server now reads the valueless is_empty 2-tuple as $empty: true, not $null: true; the assertion's point (no invented value slot) is unchanged. FilterConditionField.operators: the expiry row flips to "FILTER_OPERATORS admits $empty". The entry stays in KNOWN_UNREACHABLE with its reason restated: which builder row authors $empty is objectui#10813's decision, and a new expiry row reddens the day a builder operator emits it. Claude-Session: https://claude.ai/code/session_01YLg8XqWGJ785fwQ5v4pH37 Co-authored-by: Claude --- .../11094-value-source-empty-operator.md | 13 +++++ .../__tests__/filter-source-merge.test.ts | 7 ++- .../FilterConditionField.operators.test.ts | 49 +++++++++++++++---- 3 files changed, 58 insertions(+), 11 deletions(-) create mode 100644 .changeset/11094-value-source-empty-operator.md diff --git a/.changeset/11094-value-source-empty-operator.md b/.changeset/11094-value-source-empty-operator.md new file mode 100644 index 0000000000..71456cc58d --- /dev/null +++ b/.changeset/11094-value-source-empty-operator.md @@ -0,0 +1,13 @@ +--- +'@object-ui/core': minor +--- + +`ValueDataSource` executes the empty pair `is_empty` / `is_not_empty` and the `$empty` operator, and `convertFiltersToAST` lowers `$empty` (objectui#11094). + +`@objectstack/spec` 17.6.0 admits `$empty` to `FILTER_OPERATORS` (objectstack#20446). It also stops folding `is_empty` / `isempty` onto `is_null`, and `is_not_empty` / `isnotempty` onto `is_not_null`, and lowers them to `$empty: true` / `$empty: false` instead (objectstack#20570). `ValueDataSource` canonicalises AST operators through the spec's `canonicalAstOperator` and had arms only for the null pair. So on 17.6.0 a stored `is_empty` or `is_not_empty` rule on a `provider: 'value'` source reached the refusal arm: it selected no row and logged one refusal per `find()`. + +**After.** `is_empty` and `$empty: true` select a row whose value is null, absent, `''` or `[]`. `is_not_empty` and `$empty: false` select exactly the other rows. The answer is the spec's own `isEmptyFilterValue`, called without a field declaration, because this adapter holds none. That is the by-value reading the spec gives a face with no declarations, the same call `@objectstack/formula`'s matcher makes. A `$empty` flag that is not `true` or `false` is refused like every other refusal in this adapter: the row is excluded and the reason is logged once. + +**What moves for a stored rule.** On 17.5.0 the empty pair was a null test here. A row whose value is `''` or `[]` is now selected by `is_empty` and no longer by `is_not_empty`. Rows holding a value, null or no key answer as before. + +`convertFiltersToAST` lowers `{ f: { $empty: true } }` to `['f', 'is_empty', true]` and `$empty: false` to `['f', 'is_not_empty', true]`, the inverse of the spec's `parseFilterAST`. A flag that is not a boolean throws a `FilterOperatorError` (`code: 'INVALID_FILTER'`, `httpStatus: 400`). Before this, the operator reached the unknown-operator throw. The unknown-operator message now lists `$empty` among the supported operators. `@object-ui/data-objectstack`'s `find()` lowers an object `$filter` through this function, so an object filter carrying `$empty` reaches the wire there instead of throwing. diff --git a/packages/core/src/utils/__tests__/filter-source-merge.test.ts b/packages/core/src/utils/__tests__/filter-source-merge.test.ts index 74cb546ca9..054e8328eb 100644 --- a/packages/core/src/utils/__tests__/filter-source-merge.test.ts +++ b/packages/core/src/utils/__tests__/filter-source-merge.test.ts @@ -101,7 +101,12 @@ describe('lowering a ViewFilterRule — the operator vocabulary (objectui#3431)' const node = toFilterNode([{ field: 'a', operator: 'is_empty' }]); expect(node).toEqual([['a', 'is_empty']]); expect(isFilterAST(node)).toBe(true); - expect(parseFilterAST(node)).toEqual({ a: { $null: true } }); + // What the server reads the 2-tuple AS. It read `$null: true` until + // `@objectstack/spec` 17.6.0, whose `parseFilterAST` lowers `is_empty` to + // the emptiness operator instead (objectstack#20570, objectui#11094). The + // assertion's job is unchanged: the server reads the rule's own predicate, + // keyed on the operator NAME, and not an equality on a `null` nobody wrote. + expect(parseFilterAST(node)).toEqual({ a: { $empty: true } }); }); it('passes an operator the spec does not know through VERBATIM', () => { diff --git a/packages/fields/src/widgets/__tests__/FilterConditionField.operators.test.ts b/packages/fields/src/widgets/__tests__/FilterConditionField.operators.test.ts index b650e9c74f..09454a44ed 100644 --- a/packages/fields/src/widgets/__tests__/FilterConditionField.operators.test.ts +++ b/packages/fields/src/widgets/__tests__/FilterConditionField.operators.test.ts @@ -90,13 +90,23 @@ const noTypes = () => undefined; * not a run. */ /* - * `$empty` arrived with `@objectstack/spec` 17.5.0 (objectui#11073) and is a - * different kind of entry: a STAGED operator, not a builder decision. The spec - * declares it "ahead of its backends and absent from FILTER_OPERATORS. Until - * each face has its arm, the query executors refuse it … the view operators - * is_empty / is_not_empty still lower to $null." A dropdown row emitting it - * would therefore author a filter every executor refuses. It leaves this set - * the day `FILTER_OPERATORS` admits it — the row below the ratchet reddens then. + * `$empty` arrived with `@objectstack/spec` 17.5.0 (objectui#11073) as a STAGED + * operator: declared, absent from `FILTER_OPERATORS`, refused by every query + * executor, so a dropdown row emitting it would have authored a filter nothing + * could run. The entry was to leave this set the day `FILTER_OPERATORS` admitted + * it, and the expiry row below reddened on `@objectstack/spec` 17.6.0 + * (objectstack#20446), which admitted it and lowers `is_empty` / `is_not_empty` + * to it. + * + * ⚠️ It has NOT left, and the reason changed rather than expired. The staging + * reason is gone; what remains is which builder row should author `$empty`. + * This widget's `is_empty` writes "no value OR `''`" on every field type + * (objectui#10790), one of the three meanings of 「is empty」 objectui#10813 + * reconciles. Moving that row onto `$empty` changes what a stored sharing rule + * or roll-up filter selects, so it is that card's decision, not the 17.6.0 + * bump's (objectui#11094 executed the operator and left the builders alone). + * The entry now leaves the day a builder operator emits `$empty`: the second + * row below the ratchet reddens then. */ const KNOWN_UNREACHABLE = new Set(['$eq', '$between', '$like', '$ilike', '$empty']); @@ -179,12 +189,31 @@ describe('every spec field operator is reachable from the builder (#2942)', () = ).toEqual([]); }); - it('the staged `$empty` exclusion expires when FILTER_OPERATORS admits it', () => { - // Lit control: a flag operator that IS in the list. - expect(FILTER_OPERATORS as readonly string[]).not.toContain('$empty'); + it('the staged `$empty` exclusion expired: FILTER_OPERATORS admits it', () => { + // The objectui#11073 expiry row, flipped. It read "not in" and reddened on + // `@objectstack/spec` 17.6.0 (objectstack#20446). Lit control: a flag + // operator that was always in the list. + expect(FILTER_OPERATORS as readonly string[]).toContain('$empty'); expect(FILTER_OPERATORS as readonly string[]).toContain('$null'); }); + it('the `$empty` exclusion now expires when a builder operator emits it (objectui#10813)', () => { + // The entry's remaining reason, held mechanically: no drawable builder id + // writes `$empty` today. The day objectui#10813 moves a row onto it, this + // reddens and `$empty` leaves KNOWN_UNREACHABLE, so the sweep above starts + // holding that row to the spec like every other. Derived from the same + // drawable vocabulary the sweep feeds `condToMongo`. + const emitted = new Set(); + for (const operator of FILTER_BUILDER_OPERATORS) { + const value = operator === 'in' || operator === 'not_in' ? ['a'] : operator === 'between' ? [1, 5] : 'a'; + const frag = condToMongo({ id: 'c1', field: 'f', operator, value } as any, noTypes); + for (const op of operatorsOf(frag)) emitted.add(op); + } + // Lit control: the sweep really reads emitted operators. + expect(emitted.has('$null')).toBe(true); + expect(emitted.has('$empty')).toBe(false); + }); + it('every KNOWN_UNREACHABLE token is still a spec operator (the exclusion ratchet)', () => { // A stale exclusion is how a parity test rots into a tautology: if the spec // ever drops one of these, the entry must go too rather than sit there From 4ac6be76f52dc000828c538df1a20b748fbc5f3b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 05:13:11 +0000 Subject: [PATCH 20/36] =?UTF-8?q?feat(plugin-grid,plugin-list,types):=20fo?= =?UTF-8?q?llow=20object-grid's=2017.6.0=20description=20/=20emptyState=20?= =?UTF-8?q?rows=20=E2=80=94=20publish=20both,=20type=20emptyState=20text?= =?UTF-8?q?=20as=20I18nLabel,=20resolve=20maps=20at=20both=20renderers=20(?= =?UTF-8?q?objectui#11227)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @objectstack/spec 17.6.0 declares description, emptyState and keyboardNavigation on its object-grid row (objectstack#20694). This slice follows the first two: - GRID_QUERY_INPUTS publishes description (string and object arms, an I18nLabel the grid already resolves) and emptyState. - ObjectGridSchema.emptyState is the spec's EmptyState; the zod twin takes EmptyStateSchema by reference, not a second shape. NamedListView.emptyState indexes ListViewSchema's own member. title and message are I18nLabel. - ObjectGrid and ListView resolve emptyState.title / .message with the spec's resolveI18nLabel against the display locale. ObjectGrid handed a map to DataEmptyState as an object child, which React refuses; ListView's typeof guard drew the default copy for every map. - The objectui#11276 bag pins flip to the 17.6.0 readings (not deleted), the objectui#11068 twin pin reads invalid_union, member pins are registered for object-grid.description and .emptyState, and the docs move description into the bag (written flat it is now refused toward properties.description). keyboardNavigation is not published: nothing reads it yet. The parity gate stays red on that one key, reported for a decision. Claude-Session: https://claude.ai/code/session_01YLg8XqWGJ785fwQ5v4pH37 Co-authored-by: Claude --- .../i18nLabelInputsManifest-10993.test.ts | 10 +- .../registry-inputs-spec-parity.test.ts | 8 ++ content/docs/api/schema-reference.md | 12 +- content/docs/plugins/plugin-grid.mdx | 51 ++++--- packages/plugin-grid/README.md | 57 +++++--- packages/plugin-grid/src/ObjectGrid.tsx | 12 +- ...ectGrid.emptyStateI18nLabel-11227.test.tsx | 126 ++++++++++++++++++ packages/plugin-grid/src/index.tsx | 29 ++++ packages/plugin-list/src/ListView.tsx | 12 +- ...istView.emptyStateI18nLabel-11227.test.tsx | 123 +++++++++++++++++ .../object-grid-declared-keys-11068.test.ts | 18 ++- .../object-grid-properties-bag-11276.test.ts | 58 +++++++- packages/types/src/objectql.ts | 42 +++--- packages/types/src/zod/objectql.zod.ts | 23 ++-- 14 files changed, 493 insertions(+), 88 deletions(-) create mode 100644 packages/plugin-grid/src/__tests__/ObjectGrid.emptyStateI18nLabel-11227.test.tsx create mode 100644 packages/plugin-list/src/__tests__/ListView.emptyStateI18nLabel-11227.test.tsx diff --git a/apps/console/src/__tests__/i18nLabelInputsManifest-10993.test.ts b/apps/console/src/__tests__/i18nLabelInputsManifest-10993.test.ts index a228dcfabf..e0c35d472e 100644 --- a/apps/console/src/__tests__/i18nLabelInputsManifest-10993.test.ts +++ b/apps/console/src/__tests__/i18nLabelInputsManifest-10993.test.ts @@ -15,9 +15,11 @@ * (`ObjectMetric.i18nLabel-10993.test.tsx` in `plugin-dashboard` pins the * render); * - `object-grid`: `label` (`ObjectGrid.labelI18nLabel-10993.test.tsx` in - * `plugin-grid`). `view:grid` registers the same inputs, so its declaration - * carries the same two arms, but it is not in the public manifest, so it has - * no row here: this door does not reach it; + * `plugin-grid`), and `description` since objectui#11227 published it at + * `@objectstack/spec` 17.6.0 (`ObjectGrid.declaredKeys-11068.test.tsx`). + * `view:grid` registers the same inputs, so its declaration carries the same + * two arms, but it is not in the public manifest, so it has no row here: this + * door does not reach it; * - `record:related_list`: `title` * (`record-related-list.titleI18nLabel-10993.test.tsx` in `plugin-detail`). * @@ -45,7 +47,7 @@ const manifest = manifestFromConfigs( /** Each block, a minimal node the gate reads as clean, and its `I18nLabel` inputs. */ const BLOCKS: ReadonlyArray<{ block: string; base: Record; keys: readonly string[] }> = [ { block: 'object-metric', base: { objectName: 'deal' }, keys: ['label', 'description', 'title'] }, - { block: 'object-grid', base: { objectName: 'account' }, keys: ['label'] }, + { block: 'object-grid', base: { objectName: 'account' }, keys: ['label', 'description'] }, { block: 'record:related_list', base: { objectName: 'task', relationshipField: 'account_id', columns: ['name'] }, diff --git a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts index 9c275605be..3b5a293c84 100644 --- a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts +++ b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts @@ -2954,6 +2954,14 @@ const MEMBER_PINS: Record = { file: 'packages/plugin-grid/src/__tests__/ObjectGrid.labelI18nLabel-10993.test.tsx', pins: 'The I18nLabel object arm on the grid\'s identity key: through the real `SchemaRenderer`, the `{ type, properties }` document, and the table caption `ObjectGrid` builds from `resolveI18nLabel(schema.label, displayLocale)`. With no regional locale mounted the display locale is the UI language, so a `properties.label` map paints its `zh-CN` entry under `zh` and its `en` entry under `en`, every map listing `en` FIRST so an `en` or first-entry fallback fails the `zh` row; a plain-string control renders as authored. The record-detail overlay heading, the third read, is `ObjectGrid.overlayTitleInlineLocale-9092.test.tsx`\'s pin; the manifest half (a map is not a `type-mismatch`) is `i18nLabelInputsManifest-10993.test.ts` in this directory. ⛔ Not the deprecated `title`, whose caption fallback still reads a map raw and is held to a later batch (objectui#10993).', }, + 'object-grid.description': { + file: 'packages/plugin-grid/src/__tests__/ObjectGrid.declaredKeys-11068.test.tsx', + pins: 'The I18nLabel object arm on the grid\'s help-text line, published at `@objectstack/spec` 17.6.0 once the row declared it (objectui#11227): through the real `SchemaRenderer` and the `{ type, properties }` document, a `properties.description` map paints its `fr` entry under a `fr` UI language, with `en` listed FIRST so an `en` or first-entry fallback fails the row; a plain string draws as authored; and a map with no usable entry draws no strip at all, the same as an absent key, because the guard reads the RESOLVED text. Pre-existing file (objectui#11068), read end to end before being cited; the manifest half (a map is not a `type-mismatch`) is `i18nLabelInputsManifest-10993.test.ts` in this directory.', + }, + 'object-grid.emptyState': { + file: 'packages/plugin-grid/src/__tests__/ObjectGrid.emptyStateI18nLabel-11227.test.tsx', + pins: 'All three members the spec\'s strict `EmptyStateSchema` declares, at the empty state the grid draws in place of an empty table, through the real `SchemaRenderer` and the `{ type, properties }` document (objectui#11227). `title` and `message` are I18nLabel: a map paints its `zh-CN` entry under `zh` and its `en` entry under `en`, with `en` listed FIRST so a fallback fails the `zh` row, and the two members resolve independently (a map title beside a string message). A map with no usable entry keeps that member\'s default: the table\'s own "No results found" heading, and no message line. `icon` is a plain Lucide name, read off the same object. The render-failure signature is asserted absent on every row, because the pre-fix renderer handed a map to `DataEmptyState` as an object child, which React refuses. The string arm, the empty-search case and the not-drawn-with-rows case are `ObjectGrid.declaredKeys-11068.test.tsx`\'s rows. New file.', + }, 'object-kanban.cardFields': { file: 'packages/plugin-kanban/src/__tests__/ObjectKanban.structuredMembersReachTheirSinks-8313.test.tsx', pins: 'Members are BARE FIELD NAMES, and the pin is explicit about WHICH question it answers (the objectui#8269 trap): `resolveKanbanCardFields` answers which names the AUTHOR chose — authored order preserved, and NOT filtered against the object definition, which is the one behaviour that separates the explicit list from the `highlightFields` fallback it overrides (that fallback IS filtered). Which cells a card ends up carrying is a SECOND and narrower question, measured separately at the render, because the card loop further drops a name duplicating the title and one whose value is empty. An empty array reading as omitted is the control that keeps the fallback rows from being vacuous. The spec side is `z.array(z.string())`, so it constrains the member KIND but says nothing about either read — the sinks are the whole of the member contract (objectui#8313).', diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md index 6afce4e262..b36157a918 100644 --- a/content/docs/api/schema-reference.md +++ b/content/docs/api/schema-reference.md @@ -751,14 +751,14 @@ These schemas integrate with [ObjectStack](https://objectstack.ai) for automatic A data grid that auto-fetches from an ObjectQL object definition. Includes search, filters, pagination, grouping, and inline editing. -An authored `object-grid` node takes its props in its `properties` bag, whose members are `@objectstack/spec`'s `ComponentPropsMap['object-grid']` row. `objectui validate` judges the bag against that row and refuses a prop written flat on the node by name, naming its bag member, as the spec's own page component does (objectui#11276). `SchemaRenderer` hoists the bag onto the node before `ObjectGrid` runs, so `ObjectGridSchema` is the node as the renderer reads it, and the table below lists its members. Base props such as `description` and `className` stay on the node, beside the bag. +An authored `object-grid` node takes its props in its `properties` bag, whose members are `@objectstack/spec`'s `ComponentPropsMap['object-grid']` row. `objectui validate` judges the bag against that row and refuses a prop written flat on the node by name, naming its bag member, as the spec's own page component does (objectui#11276). `SchemaRenderer` hoists the bag onto the node before `ObjectGrid` runs, so `ObjectGridSchema` is the node as the renderer reads it, and the table below lists its members. Base props such as `className` stay on the node, beside the bag. `description` is a bag member: the spec's row declares it since `@objectstack/spec` 17.6.0, so written on the node it is refused by name and pointed at `properties.description` (objectui#11227). ```json { "type": "object-grid", - "description": "Manage your contacts", "properties": { "objectName": "Contact", + "description": "Manage your contacts", "title": "All Contacts", "searchableFields": ["name", "email", "company"], "resizable": true, @@ -791,8 +791,8 @@ also author `showFilters` (now retired on this node, below), `striped` and `pagination.enabled`, which the validator refuses: selection is spelled `selection: { type: 'multiple' }` (`'none'`, `'single'` or `'multiple'`), and `pagination` declares no on switch — its presence enables paging. It also authored `emptyState`, which the grid reads but -the spec's row does not declare, so it is not authorable in a document today (see -its row below). +the spec's row did not declare until `@objectstack/spec` 17.6.0; it is authorable +in the `properties` bag now (see its row below). | Property | Type | Description | |----------|------|-------------| @@ -801,7 +801,7 @@ its row below). | `columns` | `string[] \| ListColumn[]` | Columns to display. Either a plain array of field names (`["name", "email"]`), which auto-resolve from object metadata, or an array of `ListColumn` objects whose identity key is `field` (`{ "field": "status", "label": "Status" }`) — never `name`. **Do not mix the two forms in one array:** the array is dispatched on its first entry, so column objects sitting behind a bare string are dropped. | | `filter` | `any[]` | Pre-applied filter conditions. | | `sort` | `SortConfig[]` | Default sort configuration. The string clause (`"name desc"`) was retired in objectui#8221 and now fails validation. | -| `description` | `string \| I18nLabel` | One line of help text drawn above the grid; a per-locale map resolves like `label` (objectui#11068). | +| `description` | `string \| I18nLabel` | One line of help text drawn above the grid; a per-locale map resolves like `label` (objectui#11068). Authored in the `properties` bag since the spec's row declared it at 17.6.0 (objectui#11227). | | `searchableFields` | `string[]` | Fields included in search. | | `selection` | `SelectionConfig` | Row selection configuration. | | `pagination` | `PaginationConfig` | Pagination settings. | @@ -811,7 +811,7 @@ its row below). | `grouping` | `GroupingConfig` | Row grouping configuration. **Server-side**: the set of groups, every group count and every per-group aggregation come from the group header query (`dataSource.queryGroupHeaders`), and each group's rows are paged by the server. Rows handed in whole are grouped in the browser (exact); over a data source with no header query, a grid that fetches its own rows refuses grouping with an error naming `queryGroupHeaders`. | | `frozenColumns` | `number` | Number of columns frozen on scroll. | | `navigation` | `ViewNavigationConfig` | SPA navigation configuration. | -| `emptyState` | `{ title?, message?, icon? }` | Drawn in place of an empty table: a Lucide `icon`, a `title` (default: the table's "No results found") and a `message` (default: none). Not drawn when a term in the grid's own server-side search box emptied it — the table and its search box stay (objectui#11068). **Not authorable in a document today:** the spec's `object-grid` row does not declare it, so `objectui validate` refuses it in the `properties` bag, and the strict face refuses it on the node; a host mounting `` or composing the node in code can set it. | +| `emptyState` | `EmptyState` — `{ title?, message?, icon? }` | Drawn in place of an empty table: a Lucide `icon`, a `title` (default: the table's "No results found") and a `message` (default: none). `title` and `message` are `string \| I18nLabel`, each resolved against the display locale; a map with no usable entry keeps that member's default. Not drawn when a term in the grid's own server-side search box emptied it — the table and its search box stay (objectui#11068). Authored in the `properties` bag: the spec's row declares it since `@objectstack/spec` 17.6.0, as the list view's own `EmptyStateSchema` (objectui#11227). | > **`name`, `placeholder`, `rowSpecActions` and `bulkSpecActions` are retired on > this node (objectui#11068).** Nothing ever read them: `rowSpecActions` / diff --git a/content/docs/plugins/plugin-grid.mdx b/content/docs/plugins/plugin-grid.mdx index 9169e8321a..a089de70ea 100644 --- a/content/docs/plugins/plugin-grid.mdx +++ b/content/docs/plugins/plugin-grid.mdx @@ -93,6 +93,8 @@ below for why this plugin deliberately does not claim it. | `objectName` | `string` (**required**) | The object queried. There is no `object`. | | `columns` | `string[] \| ListColumn[]` | Field names or column objects — see below. | | `label` | `I18nLabel` | Table caption and export file title. | +| `description` | `I18nLabel` | One line of help text above the grid — see `description` and `emptyState` below. | +| `emptyState` | `EmptyState` (`@objectstack/spec/ui`) | `{ title?, message?, icon? }`, drawn in place of an empty table — see below. | | `filter` | `ViewFilterRule[]` | Baked into the query, lowered to `$filter`. | | `sort` | `[{ field, order }]` | Initial order; a header click replaces it. | | `pagination` | `PaginationConfig` | `{ pageSize?, pageSizeOptions? }` — **strict**, and its presence is what enables paging. | @@ -114,35 +116,46 @@ booleans do not exist at all; the five `on*` names are **component props** #### `description` and `emptyState` -Two more keys the grid honours on the node it reads (objectui#11068): +Two keys the grid honours (objectui#11068). `@objectstack/spec` 17.6.0 declares +both on its `object-grid` row, so both are bag members and both are in +`GRID_QUERY_INPUTS` (objectui#11227): - `description` — one line of help text drawn above the grid. A string, or an inline locale map resolved against the display locale the way `label` is. -- `emptyState: { title?, message?, icon? }` — drawn **in place of** an empty - table: a Lucide `icon`, a `title` (default: the table's own "No results found") - and a `message` (default: none). It is not drawn when a term typed into the - grid's own server-side search box is what emptied it — the table and its - search box stay, so the term can be cleared. Leave the key out and an empty - grid draws the table's own empty row, as before. - -`description` is a base prop, so it sits on the node beside the bag: +- `emptyState: { title?, message?, icon? }` — the list view's own empty-state + shape (`EmptyState`), drawn **in place of** an empty table: a Lucide `icon`, a + `title` (default: the table's own "No results found") and a `message` + (default: none). `title` and `message` each take a string or an inline locale + map, resolved against the display locale; a map with no usable entry keeps + that member's default. It is not drawn when a term typed into the grid's own + server-side search box is what emptied it — the table and its search box stay, + so the term can be cleared. Leave the key out and an empty grid draws the + table's own empty row, as before. ```json { "type": "object-grid", - "description": "Everyone you work with", - "properties": { "objectName": "contacts" } + "properties": { + "objectName": "contacts", + "description": "Everyone you work with", + "emptyState": { + "title": { "en": "No contacts yet", "fr": "Aucun contact" }, + "message": "Add one to get started", + "icon": "users" + } + } } ``` -`emptyState` is **not authorable in a document today**. The upstream protocol's -`object-grid` row does not declare it, so `objectui validate` refuses it inside -the `properties` bag (the row's own refusal) and, on the node, its strict face -refuses it as an unknown key — as `os validate` does. It stays a key of the node -the grid reads: a host that mounts `` itself, or composes -the node in code, can set it. For the same reason it is not in -`GRID_QUERY_INPUTS` yet: that list may only declare keys the row accepts, so the -SDUI parser reports an authored `emptyState` as `unknown-prop` (a warning). +Written flat on the node, either key is refused by name and pointed at the bag +(`description` → `properties.description`), as the spec's own page component +refuses it. Before 17.6.0 the row did not declare `description`, and this page +told you to write it on the node; that is now the refused spelling. + +`keyboardNavigation`, the row's third 17.6.0 key, is marked +`[EXPERIMENTAL — not enforced]` there, and nothing in this plugin reads it yet, +so it is not in `GRID_QUERY_INPUTS`. The bag accepts it, as the spec row does, +and it changes nothing. `name`, `placeholder`, `rowSpecActions` and `bulkSpecActions` are **retired** on this node (objectui#11068): nothing ever read them, and both faces of diff --git a/packages/plugin-grid/README.md b/packages/plugin-grid/README.md index 6e9220cd28..ce2384c7eb 100644 --- a/packages/plugin-grid/README.md +++ b/packages/plugin-grid/README.md @@ -63,8 +63,8 @@ two calls in `src/index.tsx` claim exactly these keys: | `register(…)` call | Namespaced key | Bare fallback | | --- | --- | --- | -| `('object-grid', ObjectGridRenderer, { namespace: 'plugin-grid' })` — `src/index.tsx:202` | `plugin-grid:object-grid` | `object-grid` | -| `('grid', ObjectGridRenderer, { namespace: 'view', skipFallback: true })` — `src/index.tsx:214` | `view:grid` | **none** — `skipFallback: true` | +| `('object-grid', ObjectGridRenderer, { namespace: 'plugin-grid' })` | `plugin-grid:object-grid` | `object-grid` | +| `('grid', ObjectGridRenderer, { namespace: 'view', skipFallback: true })` | `view:grid` | **none** — `skipFallback: true` | The `import-wizard` node key is RETIRED (objectui#10859 batch 8): no schema produced it, and `objectui validate` refused it at `type`. `ImportWizard` is still @@ -169,7 +169,7 @@ An authored grid node takes its props in its `properties` bag, whose members are `@objectstack/spec`'s `ComponentPropsMap['object-grid']` row (`ObjectGridProps`): one required `objectName` — or the node's `dataSource` binding naming the object — and keys drawn from the list this package **declares** as its authoring surface -(`GRID_QUERY_INPUTS`, `src/index.tsx:166`) — the same list that feeds the designer +(`GRID_QUERY_INPUTS` in `src/index.tsx`) — the same list that feeds the designer panel and the generated `sdui-intrinsics.d.ts`, so what is authorable here is what the renderer reads. `objectui validate` judges the bag against that row and refuses a prop written flat on the node by name, naming where it goes @@ -199,6 +199,8 @@ const grid: ObjectGridBlockNode = { | `objectName` | `string` (**required**) | The object queried. There is no `object`. | | `columns` | `string[] \| ListColumn[]` | Field names or column objects — see below. | | `label` | `I18nLabel` | Table caption and export title. | +| `description` | `I18nLabel` | One line of help text above the grid — see `description` and `emptyState` below. | +| `emptyState` | `EmptyState` (`@objectstack/spec/ui`) | `{ title?, message?, icon? }`, drawn in place of an empty table — see below. | | `filter` | `ViewFilterRule[]` | Lowered to `$filter`. | | `sort` | `[{ field, order }]` | Initial order; a header click replaces it. | | `pagination` | `PaginationConfig` | `{ pageSize?, pageSizeOptions? }` — **strict**, and its presence is what enables paging. | @@ -220,35 +222,46 @@ booleans do not exist at all; the five `on*` names are **component props** #### `description` and `emptyState` -Two more keys the grid honours on the node it reads (objectui#11068): +Two keys the grid honours (objectui#11068). `@objectstack/spec` 17.6.0 declares +both on its `object-grid` row, so both are bag members and both are in +`GRID_QUERY_INPUTS` (objectui#11227): - `description` — one line of help text drawn above the grid. A string, or an inline locale map resolved against the display locale the way `label` is. -- `emptyState: { title?, message?, icon? }` — drawn **in place of** an empty - table: a Lucide `icon`, a `title` (default: the table's own "No results found") - and a `message` (default: none). It is not drawn when a term typed into the - grid's own server-side search box is what emptied it — the table and its - search box stay, so the term can be cleared. Leave the key out and an empty - grid draws the table's own empty row, as before. - -`description` is a base prop, so it sits on the node beside the bag: +- `emptyState: { title?, message?, icon? }` — the list view's own empty-state + shape (`EmptyState`), drawn **in place of** an empty table: a Lucide `icon`, a + `title` (default: the table's own "No results found") and a `message` + (default: none). `title` and `message` each take a string or an inline locale + map, resolved against the display locale; a map with no usable entry keeps + that member's default. It is not drawn when a term typed into the grid's own + server-side search box is what emptied it — the table and its search box stay, + so the term can be cleared. Leave the key out and an empty grid draws the + table's own empty row, as before. ```json { "type": "object-grid", - "description": "Everyone you work with", - "properties": { "objectName": "contacts" } + "properties": { + "objectName": "contacts", + "description": "Everyone you work with", + "emptyState": { + "title": { "en": "No contacts yet", "fr": "Aucun contact" }, + "message": "Add one to get started", + "icon": "users" + } + } } ``` -`emptyState` is **not authorable in a document today**. The upstream protocol's -`object-grid` row does not declare it, so `objectui validate` refuses it inside -the `properties` bag (the row's own refusal) and, on the node, its strict face -refuses it as an unknown key — as `os validate` does. It stays a key of the node -the grid reads: a host that mounts `` itself, or composes -the node in code, can set it. For the same reason it is not in -`GRID_QUERY_INPUTS` yet: that list may only declare keys the row accepts, so the -SDUI parser reports an authored `emptyState` as `unknown-prop` (a warning). +Written flat on the node, either key is refused by name and pointed at the bag +(`description` → `properties.description`), as the spec's own page component +refuses it. Before 17.6.0 the row did not declare `description`, and this page +told you to write it on the node; that is now the refused spelling. + +`keyboardNavigation`, the row's third 17.6.0 key, is marked +`[EXPERIMENTAL — not enforced]` there, and nothing in this package reads it yet, +so it is not in `GRID_QUERY_INPUTS`. The bag accepts it, as the spec row does, +and it changes nothing. `name`, `placeholder`, `rowSpecActions` and `bulkSpecActions` are **retired** on this node (objectui#11068): nothing ever read them, and both faces of diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index ce823cdcf6..d0327129fe 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -6439,7 +6439,15 @@ export const ObjectGrid: React.FC = ({ // (an icon name that resolves to nothing is the same as none), the table's // "No results found" heading, and no message line. Text only, as the table's // empty row is — an empty table draws no add-record row either. + // + // objectui#11227 — `title` and `message` are the spec's `I18nLabel`: a plain + // string or an inline locale map. Each is resolved against the display locale + // the way `description` above is, so a map draws its locale's entry instead of + // reaching `DataEmptyState` as an object (React refuses an object child). A map + // with no usable entry resolves to nothing and keeps that member's default. const authoredEmptyState = schema.emptyState; + const authoredEmptyTitle = resolveInlineI18nLabel(authoredEmptyState?.title, displayLocale); + const authoredEmptyMessage = resolveInlineI18nLabel(authoredEmptyState?.message, displayLocale); const searchEmptiedRows = manualSearchOn && manualSearch.trim() !== ''; const drawsAuthoredEmptyState = authoredEmptyState != null && !loading @@ -6455,8 +6463,8 @@ export const ObjectGrid: React.FC = ({ // render. The rule cannot see that through a call. // eslint-disable-next-line react-hooks/static-components icon={AuthoredEmptyIcon ? : undefined} - title={authoredEmptyState?.title || t('table.noResults')} - description={authoredEmptyState?.message || undefined} + title={authoredEmptyTitle || t('table.noResults')} + description={authoredEmptyMessage || undefined} /> ) : gridContent; diff --git a/packages/plugin-grid/src/__tests__/ObjectGrid.emptyStateI18nLabel-11227.test.tsx b/packages/plugin-grid/src/__tests__/ObjectGrid.emptyStateI18nLabel-11227.test.tsx new file mode 100644 index 0000000000..370761f4c9 --- /dev/null +++ b/packages/plugin-grid/src/__tests__/ObjectGrid.emptyStateI18nLabel-11227.test.tsx @@ -0,0 +1,126 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * `object-grid`'s `emptyState` members, through the registry, with the + * `I18nLabel` arm on `title` and `message` (objectui#11227). + * + * `@objectstack/spec` 17.6.0 declares `emptyState` on its + * `ComponentPropsMap['object-grid']` row as the list view's own + * `EmptyStateSchema`: strict over `{ title, message, icon }`, with `title` and + * `message` typed `I18nLabel` (a plain string or an inline per-locale map) and + * `icon` a string. `ObjectGrid` drew the two text members raw. A map reached + * `DataEmptyState` as an object CHILD, which React refuses ("Objects are not + * valid as a React child"), so the block failed to render. Each member is now + * resolved against the display locale, as `label` and `description` are. + * + * This file is the member pin the console parity gate registers for + * `object-grid.emptyState` (`MEMBER_PINS` in + * `registry-inputs-spec-parity.test.ts`): it mounts the `{ type, properties }` + * document a page carries through the real `SchemaRenderer` and the registered + * block, over a grid with no record, and reads what each of the three members + * draws. The string arm, the empty-search case and the not-drawn-with-rows case + * are `ObjectGrid.declaredKeys-11068.test.tsx`'s rows and are not repeated here. + * + * No regional locale is provided, so the display locale is the UI language. + * Every map lists `en` FIRST, so under `zh` a resolver that fell back to `en` + * or to the first entry would paint English and fail the row. + */ + +import React from 'react'; +import { describe, it, expect, afterEach } from 'vitest'; +import { render, cleanup, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { I18nProvider } from '@object-ui/i18n'; +import { ActionProvider, SchemaRenderer } from '@object-ui/react'; +// Registers `object-grid`, the block under test. +import '../index'; + +afterEach(() => cleanup()); + +/** `en` first on purpose; see the file header. */ +const TITLE = { en: 'No contacts yet', 'zh-CN': '还没有联系人' }; +const MESSAGE = { en: 'Add one to get started', 'zh-CN': '添加一个联系人开始使用' }; + +/** The table's own empty-row heading: the default an authored `title` replaces. */ +const TABLE_EMPTY_HEADING = 'No results found'; + +/** A JSON document: the node and its `properties` bag over no record at all. */ +const doc = (emptyState: unknown) => ({ + type: 'object-grid', + properties: { + objectName: 'contacts', + columns: [{ field: 'name', label: 'Name' }], + data: { provider: 'value', items: [] }, + emptyState, + }, +}); + +function mountIn(language: string, emptyState: unknown) { + return render( + + + + + , + ); +} + +/** The drawn empty state, once it is on screen; a render failure fails here. */ +async function emptyStateOf(container: HTMLElement): Promise { + await waitFor(() => expect(container.querySelector('[data-testid="object-grid-empty-state"]')).not.toBeNull()); + // The defect's own signature: an object child throws, and the block is + // replaced by its error boundary instead of drawing anything. + expect(container.textContent ?? '').not.toContain('failed to render'); + expect(container.textContent ?? '').not.toContain('[object Object]'); + return container.querySelector('[data-testid="object-grid-empty-state"]') as HTMLElement; +} + +const headingOf = (state: HTMLElement) => state.querySelector('h3')?.textContent ?? null; +const messageOf = (state: HTMLElement) => state.querySelector('p')?.textContent ?? null; + +describe('object-grid `emptyState.title` / `.message` resolve an inline locale map (objectui#11227)', () => { + it('zh: the heading and the line below it paint the zh-CN entries, with the authored icon', async () => { + const { container } = mountIn('zh', { title: TITLE, message: MESSAGE, icon: 'users' }); + const state = await emptyStateOf(container); + expect(headingOf(state)).toBe('还没有联系人'); + expect(messageOf(state)).toBe('添加一个联系人开始使用'); + // `icon` stays a plain Lucide name, read off the same object. + expect(state.querySelector('svg.lucide-users')).not.toBeNull(); + // It replaces the table, as the string arm does. + expect(container.querySelector('table')).toBeNull(); + }); + + it('en: the same maps paint the en entries', async () => { + const { container } = mountIn('en', { title: TITLE, message: MESSAGE }); + const state = await emptyStateOf(container); + expect(headingOf(state)).toBe('No contacts yet'); + expect(messageOf(state)).toBe('Add one to get started'); + }); + + it('the members resolve independently: a map title beside a plain-string message', async () => { + const { container } = mountIn('zh', { title: TITLE, message: 'Add one to get started' }); + const state = await emptyStateOf(container); + expect(headingOf(state)).toBe('还没有联系人'); + expect(messageOf(state)).toBe('Add one to get started'); + }); + + it('a map with no usable entry keeps that member’s default: the table’s heading, and no line', async () => { + // Under `en`, where the default heading is the English one this file names + // (under `zh` the locale pack translates it). + const { container } = mountIn('en', { title: {}, message: {} }); + const state = await emptyStateOf(container); + expect(headingOf(state)).toBe(TABLE_EMPTY_HEADING); + expect(state.querySelector('p')).toBeNull(); + }); + + it('CONTROL: plain-string members render exactly as authored, under zh', async () => { + const { container } = mountIn('zh', { title: 'No contacts yet', message: 'Add one to get started' }); + const state = await emptyStateOf(container); + expect(headingOf(state)).toBe('No contacts yet'); + expect(messageOf(state)).toBe('Add one to get started'); + }); +}); diff --git a/packages/plugin-grid/src/index.tsx b/packages/plugin-grid/src/index.tsx index eb981eba73..9f70ffa12a 100644 --- a/packages/plugin-grid/src/index.tsx +++ b/packages/plugin-grid/src/index.tsx @@ -212,6 +212,17 @@ export const ObjectGridRenderer: React.FC<{ schema: any; [key: string]: any }> = * objectui#5861 removed every renderer read of it (ADR-0049 enforce-or-remove). * It stays off this list because the contract refuses it, not by exemption. * + * ## `description`, `emptyState`, and the one 17.6.0 key that is not here + * + * `@objectstack/spec` 17.6.0 adds three keys to the `object-grid` row + * (objectstack#20694). `description` and `emptyState` are published below + * (objectui#11227): `ObjectGrid` has read both since objectui#11068, and until + * the row declared them this list could not. `keyboardNavigation` is NOT here. + * The row marks it `[EXPERIMENTAL — not enforced]`, nothing in this repo reads it + * yet, and this list is what the renderer reads: the key joins it with its + * reader (objectui#11068's build). Until then the console parity gate's reverse + * direction reports it as the one unpublished `object-grid` key. + * * ## `data` declares the CONTRACT's shape, not the shortcut's (objectui#5090) * * The key landed above with `type: 'array'`, labelled "Static Data" and described @@ -253,6 +264,18 @@ const GRID_QUERY_INPUTS: ComponentInput[] = [ description: 'Grid label, used as the table caption, as the export file title and in the record-detail overlay heading. The canonical spelling — the deprecated `title` is only read when this is absent. Accepts either a plain string or an inline per-locale map (`{ en: "Accounts", "zh-CN": "客户" }`) — the `I18nLabel` union the contract admits on this key — and the grid resolves the map against the display locale (the workspace\'s regional default when one is configured, otherwise the active UI language), falling back through base language, a region-qualified sibling, `default`, then `en`, and finally to any remaining entry.', }, + // `description` (objectui#11068 honoured it; objectui#11227 publishes it now + // that the spec row declares it, at 17.6.0) is an `I18nLabel` in the row too, + // and `ObjectGrid` resolves it with the same resolver as `label`, so both arms + // are declared for the same reason. The render is pinned by + // `ObjectGrid.declaredKeys-11068.test.tsx`, the manifest by the console's + // `i18nLabelInputsManifest-10993.test.ts`. + { + name: 'description', + type: ['string', 'object'], + description: + 'One line of help text drawn above the grid\'s rows, in muted type. Accepts either a plain string or an inline per-locale map (`{ en: "Everyone you work with", "zh-CN": "你的所有联系人" }`) — the `I18nLabel` union the contract admits on this key — resolved against the display locale the way `label` is. A map with no usable entry draws no line.', + }, // ── query shaping ───────────────────────────────────────────────────────── { name: 'sort', type: 'array', description: 'Initial sort order, `[{ field, order }]`. The only sort spelling this block reads — the retired single-sort `defaultSort` is refused by the protocol and ignored by the renderer.' }, { name: 'pagination', type: 'object', description: 'Pagination config, `{ pageSize, pageSizeOptions, … }`. Presence enables paging with the object\'s settings, and an explicit off wins — the deprecated flat `showPagination: false` turns paging off even beside this object, because this object declares no off switch of its own. Prefer it over the deprecated flat `pageSize` / `showPagination` pair.' }, @@ -266,6 +289,12 @@ const GRID_QUERY_INPUTS: ComponentInput[] = [ { name: 'showColumnTypeIcons', type: 'boolean', description: 'Show a field-type icon in each column header. Off by default — the type is usually obvious from the cell content, and the icons compete with the column labels.' }, { name: 'rowColor', type: 'object', description: 'Rules that colour whole rows from a field value.' }, { name: 'conditionalFormatting', type: 'array', description: 'Row/cell styling rules. Accepts both the ObjectUI `{ field, operator, value }` form and the spec expression form `{ condition, style }`.' }, + // `emptyState` (objectui#11068 honoured it; objectui#11227 publishes it now + // that the spec row declares it, at 17.6.0, as the list view's own + // `EmptyStateSchema`). Its `title` and `message` are `I18nLabel` members that + // `ObjectGrid` resolves against the display locale. The members are pinned by + // `ObjectGrid.emptyStateI18nLabel-11227.test.tsx`. + { name: 'emptyState', type: 'object', description: 'What the grid draws in place of an empty table, `{ title, message, icon }`: a Lucide `icon` name, a `title` (default: the table\'s own "No results found") and a `message` (default: none). `title` and `message` each accept a plain string or an inline per-locale map, resolved against the display locale. Not drawn when a term typed into the grid\'s own server-side search box is what emptied it — the table and its search box stay, so the term can be cleared. Leave it out and an empty grid draws the table\'s own empty row.' }, // ── grouping and roll-ups ───────────────────────────────────────────────── { name: 'grouping', type: 'object', description: 'Group rows by one or more fields into collapsible sections.' }, { name: 'aggregations', type: 'array', description: 'Per-group roll-ups shown in group headers, `[{ field, type: "sum" | "count" | "avg" | "min" | "max" | "count_distinct" }]`. Needs `grouping` to have anything to roll up.' }, diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index 7bcdeea4c9..5cb221ca85 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -5198,9 +5198,17 @@ export const ListView = React.forwardRef(({ hasBaseFilter || (Array.isArray(appliedUserFilterConditions) && appliedUserFilterConditions.length > 0) || (Array.isArray(appliedFilters?.conditions) && appliedFilters.conditions.length > 0); - const title = (typeof schema.emptyState?.title === 'string' ? schema.emptyState.title : undefined) + // objectui#11227 — `title` and `message` are the spec's `I18nLabel` + // (`EmptyStateSchema`): a plain string or an inline locale map. They + // are RESOLVED against the display locale, as the view's `label` + // is. A `typeof === 'string'` test stood here, which is not a + // resolution: it answered "absent" for every map an author may write, + // so a localised empty state silently drew the default copy in every + // locale. A string still passes through unchanged, and a map with no + // usable entry still falls to the default. + const title = resolveInlineI18nLabel(schema.emptyState?.title, displayLocale) ?? (hasActiveQuery ? t('list.noMatches') : t('list.firstRunTitle')); - const description = (typeof schema.emptyState?.message === 'string' ? schema.emptyState.message : undefined) + const description = resolveInlineI18nLabel(schema.emptyState?.message, displayLocale) ?? (hasActiveQuery ? t('list.noMatchesMessage') : t('list.firstRunMessage')); return ( ({ + find: vi.fn().mockResolvedValue([]), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), +}); + +/** `en` first on purpose; see the file header. */ +const TITLE = { en: 'No open work', 'zh-CN': '没有未完成的工作' }; +const MESSAGE = { en: 'Everything is done.', 'zh-CN': '全部完成。' }; + +async function emptyStateFor(emptyState: unknown, locale: string): Promise { + const ds = emptyDataSource(); + const schema = { + type: 'list-view', + objectName: 'tasks', + viewType: 'grid', + columns: ['name'], + emptyState, + } as ListViewSchema; + const { container } = render( + + + + + , + ); + await waitFor(() => expect(container.querySelector('[data-testid="empty-state"]')).not.toBeNull()); + return container.querySelector('[data-testid="empty-state"]') as HTMLElement; +} + +const headingOf = (state: HTMLElement) => state.querySelector('h3')?.textContent ?? null; +const messageOf = (state: HTMLElement) => state.querySelector('p')?.textContent ?? null; + +afterEach(() => cleanup()); + +describe('ListView `emptyState.title` / `.message` resolve the inline locale map (objectui#11227)', () => { + it('zh-CN: the heading and the message paint the zh-CN entries, not the default copy', async () => { + const state = await emptyStateFor({ title: TITLE, message: MESSAGE }, 'zh-CN'); + expect(headingOf(state)).toBe('没有未完成的工作'); + expect(messageOf(state)).toBe('全部完成。'); + // The exact thing the `typeof` else-arm produced for a map. + expect(state.textContent).not.toMatch(/Nothing here yet/i); + }); + + it('en: the same maps paint the en entries', async () => { + const state = await emptyStateFor({ title: TITLE, message: MESSAGE }, 'en'); + expect(headingOf(state)).toBe('No open work'); + expect(messageOf(state)).toBe('Everything is done.'); + }); + + it('a map with no usable entry keeps the default copy, as an absent member does', async () => { + const absent = await emptyStateFor({}, 'en'); + const defaults = [headingOf(absent), messageOf(absent)]; + cleanup(); + const unusable = await emptyStateFor({ title: {}, message: {} }, 'en'); + expect([headingOf(unusable), messageOf(unusable)]).toEqual(defaults); + // Non-vacuity: the default is real copy, not an empty node. + expect(defaults[0]).toMatch(/Nothing here yet/i); + }); + + it('CONTROL: plain-string members pass through unchanged, under zh-CN', async () => { + const state = await emptyStateFor({ title: 'No open work', message: 'Everything is done.' }, 'zh-CN'); + expect(headingOf(state)).toBe('No open work'); + expect(messageOf(state)).toBe('Everything is done.'); + }); +}); diff --git a/packages/types/src/__tests__/object-grid-declared-keys-11068.test.ts b/packages/types/src/__tests__/object-grid-declared-keys-11068.test.ts index eaca0f823f..6615d2788e 100644 --- a/packages/types/src/__tests__/object-grid-declared-keys-11068.test.ts +++ b/packages/types/src/__tests__/object-grid-declared-keys-11068.test.ts @@ -113,10 +113,24 @@ describe('objectql.zod.ts#ObjectGridSchema — `emptyState` is mirrored member f if (result.success) expect(result.data.emptyState).toEqual(emptyState); }); - it('refuses a non-string member AT that member', () => { + it('refuses a member that is neither a string nor a locale map AT that member', () => { + // objectui#11227 — the member is the spec's `EmptyStateSchema` by reference + // since 17.6.0, so `title` is `I18nLabel`: a union of a string and an inline + // locale map. A number fails both arms, so the refusal at the member is the + // union's (`invalid_union`); it read `invalid_type` while the twin restated + // `title` as a plain string. const result = ObjectGridSchema.safeParse({ ...NODE, emptyState: { title: 42 } }); expect(result.success).toBe(false); - expect(issueAt(result, 'emptyState.title')?.code).toBe('invalid_type'); + expect(issueAt(result, 'emptyState.title')?.code).toBe('invalid_union'); + }); + + it('accepts an inline locale map on `title` and `message`, as the spec row does (objectui#11227)', () => { + const emptyState = { title: { en: 'No contacts yet', fr: 'Aucun contact' }, message: { en: 'Add one' } }; + const result = ObjectGridSchema.safeParse({ ...NODE, emptyState }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.emptyState).toEqual(emptyState); + // The twin follows the spec, ⛔ not a second shape: the member IS the spec's schema. + expect(SpecObjectGridPropsSchema.safeParse({ objectName: 'probe', emptyState }).success).toBe(true); }); it('refuses an unknown member by name instead of keeping it — `description` is not `message`', () => { diff --git a/packages/types/src/__tests__/object-grid-properties-bag-11276.test.ts b/packages/types/src/__tests__/object-grid-properties-bag-11276.test.ts index 610e27e085..e9ac476b2c 100644 --- a/packages/types/src/__tests__/object-grid-properties-bag-11276.test.ts +++ b/packages/types/src/__tests__/object-grid-properties-bag-11276.test.ts @@ -367,9 +367,6 @@ describe('the flat spelling is refused by name, with the bag member as the remed it.each([ ['an invented key', 'inventedKey11276'], - // Mirror members the row does not declare: not authored keys of this node. - ['the mirror\'s `emptyState`', 'emptyState'], - ['the mirror\'s `keyboardNavigation`', 'keyboardNavigation'], ] as const)('%s written flat stays unjudged on the tolerant face and is refused on the strict face', (_label, key) => { // The strictness control: this arm is `BaseSchema`, whose `.passthrough()` // every arm keeps, so a key the arm does not declare is not refused by the @@ -382,6 +379,28 @@ describe('the flat spelling is refused by name, with the bag member as the remed const issue = strict.error.issues.find((i) => i.code === 'unrecognized_keys'); expect((issue as { keys?: string[] } | undefined)?.keys).toEqual([key]); }); + // ⭐ THE READING FLIPPED at `@objectstack/spec` 17.6.0 (objectstack#20694, read + // on objectui#11227). These two mirror members used to sit in the table above, + // as keys the row did not declare: written flat, the tolerant face kept them + // unjudged and only the strict face refused them as unknown. The row now + // declares both, so each is a row member like any other: written flat, it is + // refused on BOTH faces, by name, toward the bag. Recorded by name here, not + // left to the derived `REFUSED_TO_BAG` loop, so the flip itself stays visible. + it.each(['emptyState', 'keyboardNavigation'] as const)( + 'the mirror\'s `%s`, which the row declares since 17.6.0, written flat is refused on both faces toward the bag (objectui#11227)', + (key) => { + // The spec's reading first: the installed row lists the key and does not retire it. + expect(ROW_KEYS).toContain(key); + expect(isNeverMember(ROW_SHAPE[key])).toBe(false); + expect(REFUSED_TO_BAG).toContain(key); + const doc = { ...SHOWCASE_MY_WORK, [key]: { title: 'x' } }; + for (const [face, parse] of FACES) { + const issue = issuesOf(parse(doc)).find((i) => i.path.join('.') === key); + expect(issue, face).toBeDefined(); + expect(issue!.message, face).toContain(`\`${key}\` → \`properties.${key}\``); + } + }, + ); }); describe('the bag is judged by the spec row (objectui#11276)', () => { @@ -414,7 +433,7 @@ describe('the bag is judged by the spec row (objectui#11276)', () => { expect(issue.path).toEqual(path); }); - it.each(['emptyState', 'keyboardNavigation', 'showFilters', 'name'] as const)( + it.each(['showFilters', 'name'] as const)( 'the row does not declare `%s`, so the bag refuses it on both faces (the spec\'s reading, recorded)', (key) => { for (const [face, parse] of FACES) { @@ -425,6 +444,37 @@ describe('the bag is judged by the spec row (objectui#11276)', () => { }, ); + // ⭐ THE READING FLIPPED at `@objectstack/spec` 17.6.0 (objectstack#20694, read + // on objectui#11227). `emptyState` and `keyboardNavigation` used to be rows of + // the table above: the row did not declare them, so the bag refused each as an + // unknown key. The row now declares both, so the bag ACCEPTS each on both faces + // and judges its value — `emptyState` as the list view's `EmptyStateSchema`, + // whose `title` and `message` take an inline locale map, and + // `keyboardNavigation` as a boolean. Accepting `keyboardNavigation` here is the + // spec row's reading on the authoring face, nothing more: the row marks it + // `[EXPERIMENTAL — not enforced]`, and whether `object-grid` PUBLISHES it in + // `GRID_QUERY_INPUTS` is the console parity gate's question, not this file's. + it.each([ + ['emptyState', { title: { en: 'No tasks yet', 'zh-CN': '暂无任务' }, message: 'Create one to start', icon: 'inbox' }, { title: 7 }], + ['keyboardNavigation', true, 'yes'], + ] as const)( + 'the row declares `%s` since 17.6.0, so the bag accepts it on both faces and judges its value (objectui#11227)', + (key, valid, invalid) => { + // The spec's own reading of the same values, so the recorded flip is the row's. + expect(SpecObjectGridPropsSchema.safeParse({ objectName: 'task', [key]: valid }).success).toBe(true); + expect(SpecObjectGridPropsSchema.safeParse({ objectName: 'task', [key]: invalid }).success).toBe(false); + for (const [face, parse] of FACES) { + const accepted = parse(bag({ [key]: valid })); + expect(accepted.success, `${face}: ${JSON.stringify(accepted.success ? null : accepted.error.issues)}`).toBe(true); + // Lit control: the member is JUDGED, not merely admitted — a wrong value + // is refused at the member, and never as an unknown key. + const issues = issuesOf(parse(bag({ [key]: invalid }))); + expect(issues.some((i) => i.path[0] === 'properties' && i.path[1] === key), `${face}: ${JSON.stringify(issues)}`).toBe(true); + expect(issues.some((i) => i.code === 'unrecognized_keys'), face).toBe(false); + } + }, + ); + it('the bag is the spec row by reference, through the import boundary', () => { const member = ObjectGridBlockSchema.shape.properties.unwrap(); const keysOf = (schema: unknown) => Object.keys((schema as { shape: Record }).shape).sort(); diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index 035c03f03e..464c739817 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -161,6 +161,11 @@ import type { // objectui#11355 — `ComponentPropsMap['object-kanban']`'s author state, so // `ObjectKanbanSchema.swimlaneField` takes the row's own type by reference. ObjectKanbanProps as SpecObjectKanbanProps, + // objectui#11227 — the list view's empty-state shape, which the spec's + // `object-grid` row holds by reference since 17.6.0 (`EmptyStateSchema`): + // `ObjectGridSchema.emptyState` below takes the protocol's own authored type. + // Aliased for the reason `SpecObjectCalendarProps` above is. + EmptyState as SpecEmptyState, ChartDrillDown, I18nLabel, DashboardWidget as SpecDashboardWidget, @@ -1397,18 +1402,17 @@ export interface ObjectGridSchema extends BaseSchema { * own "no results" row, so the search box that emptied it stays reachable. * Leave the key out and nothing changes — the table draws its own empty row. * - * The same three members, with the same meanings, as `ListViewSchema`'s - * `emptyState`. Mirrored member for member by the Zod twin, which refuses an - * unknown member. + * The protocol's own empty-state type, ⛔ not a second shape (objectui#11227): + * `@objectstack/spec` 17.6.0 declares `emptyState` on its `object-grid` row + * by reference to the list view's `EmptyStateSchema`, so the grid and + * `ListViewSchema` share one shape. `title` and `message` are `I18nLabel` — + * a plain string or an inline locale map — and `ObjectGrid` resolves each + * against the display locale, as it resolves `label` and `description`; a + * map with no usable entry keeps that member's default. `icon` stays a + * string. The Zod twin takes the same schema by reference, so it refuses an + * unknown member as the spec does. */ - emptyState?: { - /** Title text for the empty state */ - title?: string; - /** Message/description for the empty state */ - message?: string; - /** Icon name (Lucide icon identifier) */ - icon?: string; - }; + emptyState?: SpecEmptyState; /** * REFUSED BY NAME (objectui#9256, ADR-0049) — `object-grid` reads NEITHER * content channel; see `children` below for the measurement. @@ -2878,12 +2882,16 @@ export interface NamedListView { /** Allow printing the view @default false */ allowPrinting?: boolean; - /** Empty state configuration */ - emptyState?: { - title?: string; - message?: string; - icon?: string; - }; + /** + * Empty state configuration — the type of {@link ListViewSchema}'s own + * `emptyState`, indexed rather than restated, as the objectui#8980 members + * below are (objectui#11227). That member is the protocol's `EmptyStateSchema` + * by reference, so `title` and `message` are `I18nLabel` — a plain string or + * an inline locale map — and `ListView`, which this view is forwarded into, + * resolves each against the display locale. This member said plain `string` + * until then, narrower than the protocol on both. + */ + emptyState?: ListViewSchema['emptyState']; /** ARIA attributes for accessibility */ aria?: { diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 56f51bf97a..739e23e3cc 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -71,6 +71,10 @@ import { // objectui#11070 — the per-element data binding (`PageComponentSchema.dataSource`) // the object-bound arms below declare as `dataSource`, by reference. ElementDataSourceSchema as SpecElementDataSourceSchema, + // objectui#11227 — the list view's empty-state shape, which the spec's + // `object-grid` row holds by reference since 17.6.0; `ObjectGridSchema.emptyState` + // below takes it by reference too. + EmptyStateSchema as SpecEmptyStateSchema, checkListViewCalendarVisualization, } from '@objectstack/spec/ui'; import { BaseSchema, specFieldsExcept } from './base.zod.js'; @@ -621,17 +625,16 @@ export const ObjectGridSchema = BaseSchema.extend({ placeholder: retirementTombstone(OBJECT_GRID_PLACEHOLDER_RETIRED), showFilters: retirementTombstone(OBJECT_GRID_SHOW_FILTERS_RETIRED), // objectui#11068 — read by `ObjectGrid`, which draws it in place of an empty - // table. Mirrored member for member with the interface: three optional - // strings, and an unknown member refused rather than kept, so a misspelt - // `description` / `text` for `message` is named instead of drawing nothing. - emptyState: z - .strictObject({ - title: z.string().optional().describe('Heading of the empty state; absent, the table’s own "No results found"'), - message: z.string().optional().describe('Line of text below the heading; absent, no line'), - icon: z.string().optional().describe('Lucide icon name; absent or unknown, the shared empty-state glyph'), - }) + // table. objectui#11227 — the spec's `EmptyStateSchema` BY REFERENCE, ⛔ not a + // second shape: the spec's `object-grid` row (17.6.0) declares this member as + // that very schema, so the twin follows the spec. It is strict, so a misspelt + // `description` / `text` for `message` is still named instead of drawing + // nothing; `title` and `message` are `I18nLabel` (a plain string or an inline + // locale map, which `ObjectGrid` resolves against the display locale), and + // `icon` is a string. Mirrored by the interface's `SpecEmptyState`. + emptyState: stripImportedDefaults(SpecEmptyStateSchema) .optional() - .describe('What the grid draws instead of an empty table: `{ title, message, icon }`'), + .describe('What the grid draws instead of an empty table: `{ title, message, icon }`, the spec EmptyState by reference'), editable: z.boolean().optional(), keyboardNavigation: z.boolean().optional(), frozenColumns: z.number().optional(), From 518c8552a2831d341b56c2b909acd263382f7e6d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 05:14:33 +0000 Subject: [PATCH 21/36] chore(changeset): objectui#11227's changeset, and corrections to three pending changesets its slice falsified .changeset/11227-object-grid-17-6-keys.md declares plugin-grid and types minor and plugin-list patch. Three pending changesets of the same release said emptyState was three optional strings or not authorable in a document; each gets a dated correction paragraph instead of a rewrite. Claude-Session: https://claude.ai/code/session_01YLg8XqWGJ785fwQ5v4pH37 Co-authored-by: Claude --- .changeset/11068-grid-declared-keys.md | 6 +++ .changeset/11227-object-grid-17-6-keys.md | 39 +++++++++++++++++++ .../11276-object-grid-properties-bag.md | 7 ++++ .../11276-plugin-grid-properties-bag-docs.md | 5 +++ 4 files changed, 57 insertions(+) create mode 100644 .changeset/11227-object-grid-17-6-keys.md diff --git a/.changeset/11068-grid-declared-keys.md b/.changeset/11068-grid-declared-keys.md index 0645008bf5..90a0ee96d4 100644 --- a/.changeset/11068-grid-declared-keys.md +++ b/.changeset/11068-grid-declared-keys.md @@ -40,3 +40,9 @@ now authors only keys the grid reads. **Unchanged.** An `object-view`'s `table` slot still withholds `description` and `emptyState`. Write them on the `object-grid` node itself. `keyboardNavigation` is still declared and still not read. + +**Correction, 2026-10-03 (objectui#11227).** The Zod twin's `emptyState` is no longer three +optional strings. It is the spec's `EmptyStateSchema` by reference, which `@objectstack/spec` +17.6.0 declares on its `object-grid` row: `title` and `message` are `string | I18nLabel` (a +plain string or an inline locale map, which the grid resolves against the display locale), and +an unknown member is still refused by name (`.changeset/11227-object-grid-17-6-keys.md`). diff --git a/.changeset/11227-object-grid-17-6-keys.md b/.changeset/11227-object-grid-17-6-keys.md new file mode 100644 index 0000000000..a1bc3f18b8 --- /dev/null +++ b/.changeset/11227-object-grid-17-6-keys.md @@ -0,0 +1,39 @@ +--- +'@object-ui/plugin-grid': minor +'@object-ui/plugin-list': patch +'@object-ui/types': minor +--- + +An `object-grid` publishes `description` and `emptyState` now that `@objectstack/spec` 17.6.0 +declares them on its `object-grid` row, and `emptyState.title` / `.message` take an inline +locale map on both the grid and the list view (objectui#11227). + +**`@object-ui/plugin-grid` (feature and fix).** + +- `description` and `emptyState` are in the grid's declared inputs, so the designer panel, the + component manifest and the generated `sdui-intrinsics.d.ts` offer them, and the SDUI parser + no longer reports an authored `emptyState` as `unknown-prop`. `description` declares both + arms of the `I18nLabel` union, so a locale map on it is not a `type-mismatch`. +- `emptyState.title` and `emptyState.message` are resolved against the display locale, as + `label` and `description` are. Before, a locale map on either reached the empty-state + component as an object, and the grid failed to render. A map with no usable entry keeps + that member's default: the table's "No results found" heading, and no message line. +- `keyboardNavigation`, the row's third 17.6.0 key, is not published. The spec marks it + `[EXPERIMENTAL — not enforced]`, and nothing in the grid reads it yet. + +**`@object-ui/plugin-list` (fix).** A list view's authored `emptyState.title` and +`emptyState.message` are resolved against the display locale. Before, a locale map on either +was treated as absent, and the empty state drew the default copy in every locale. A plain +string is drawn as before. + +**`@object-ui/types` (breaking for a reader of the text members, hence `minor`).** + +- `ObjectGridSchema.emptyState` is the spec's `EmptyState`, and the Zod twin takes the spec's + `EmptyStateSchema` by reference. `title` and `message` are `string | I18nLabel`, `icon` stays + a string, and an unknown member is still refused by name. A locale map on `title` or + `message` now parses, and a value that is neither a string nor a map is refused at that + member. +- `NamedListView.emptyState` is `ListViewSchema['emptyState']`, the same spec type. +- TypeScript code that reads `emptyState.title` or `.message` as a `string` no longer + type-checks. Resolve the value first, for example with `resolveI18nLabel` from + `@objectstack/spec/ui`. diff --git a/.changeset/11276-object-grid-properties-bag.md b/.changeset/11276-object-grid-properties-bag.md index defd40bcce..0e5378dac3 100644 --- a/.changeset/11276-object-grid-properties-bag.md +++ b/.changeset/11276-object-grid-properties-bag.md @@ -29,3 +29,10 @@ Nothing changes at render time: `SchemaRenderer` hoists every `properties` key o **What did not move.** The TypeScript `ObjectGridSchema` and its zod mirror `ObjectGridSchema` stay published and unchanged in shape. They are the node as `ObjectGrid` reads it after the hoist, and as code composes it, and the `object-view` `table` slot is still built from the mirror. **Correction, 2026-10-02 (objectui#6152, round 6).** The list above of keys "the row declares and the flat mirror does not" is no longer true for ten of them: the flat `ObjectGridSchema` mirror now declares `rowHeight`, `grouping`, `aggregations`, `conditionalFormatting`, `rowColor`, `bulkActionDefs`, `navigation`, `singleClickEdit`, `reorderableColumns` and `operations`, each typed as its TypeScript twin types it (`.changeset/6152-objectgrid-round6-mirrored.md`). `resizableColumns` is still the row's alone. The bag is unchanged: the spec row still judges these keys inside `properties`. + +**Correction, 2026-10-03 (objectui#11227).** At `@objectstack/spec` 17.6.0 the row declares +`description`, `emptyState` and `keyboardNavigation`, so the sentence above saying it does not +declare `emptyState` or `keyboardNavigation` no longer holds. All three parse in the bag, and +each written flat on the node is refused by name toward its bag member, like the row's other +members. `emptyState` is authorable in a document, and so is `description`, which no longer +stays on the node beside the bag (`.changeset/11227-object-grid-17-6-keys.md`). diff --git a/.changeset/11276-plugin-grid-properties-bag-docs.md b/.changeset/11276-plugin-grid-properties-bag-docs.md index d20d56288d..64fafe006b 100644 --- a/.changeset/11276-plugin-grid-properties-bag-docs.md +++ b/.changeset/11276-plugin-grid-properties-bag-docs.md @@ -5,3 +5,8 @@ docs(plugin-grid): authored `object-grid` examples write their props in the `properties` bag (objectui#11276) The README's authored `object-grid` examples now write `{ "type": "object-grid", "properties": { … } }`, the spelling `@objectstack/spec`'s `ComponentPropsMap['object-grid']` row declares and `objectui validate` now requires, each bag checked with `satisfies ObjectGridProps` (the spec's row type). `emptyState` is described as what it is today: a key `ObjectGrid` reads that the spec's row does not declare, so not authorable in a document. The examples that mount `ObjectGrid` directly keep the flat `schema` prop: a component mounted without `SchemaRenderer` receives the node as it reads it, and nothing hoists a bag there. Under `src/`, a new render pin holds that the bag and a stored flat node draw the same grid, and the CRUD-guide render pin reads the guide's bag. No runtime change. + +**Correction, 2026-10-03 (objectui#11227).** The README no longer describes `emptyState` as +not authorable: `@objectstack/spec` 17.6.0 declares it, with `description`, on the `object-grid` +row, so both are written in the `properties` bag, and the README's example shows them there +(`.changeset/11227-object-grid-17-6-keys.md`). From ed622b48fff0f3eccdb2d3436ea47fd70cc42a29 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 05:35:38 +0000 Subject: [PATCH 22/36] test: type the two casts the slice's new pins added instead of widening to any (objectui#11094) The new $empty pin's row helper casts to QueryParams['$filter'], and the FilterConditionField expiry row needs no cast at all (BuilderCondition's value is already loose). Runtime-identical; eslint's no-explicit-any count on the touched files returns to its base reading. Claude-Session: https://claude.ai/code/session_01YLg8XqWGJ785fwQ5v4pH37 Co-authored-by: Claude --- .../src/utils/__tests__/filter-empty-operator-11094.test.ts | 3 ++- .../widgets/__tests__/FilterConditionField.operators.test.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/core/src/utils/__tests__/filter-empty-operator-11094.test.ts b/packages/core/src/utils/__tests__/filter-empty-operator-11094.test.ts index c9ee9ce1e1..cd95d7fb82 100644 --- a/packages/core/src/utils/__tests__/filter-empty-operator-11094.test.ts +++ b/packages/core/src/utils/__tests__/filter-empty-operator-11094.test.ts @@ -32,6 +32,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { isFilterAST, parseFilterAST } from '@objectstack/spec/data'; +import type { QueryParams } from '@object-ui/types'; import { convertFiltersToAST, toFilterNode, FilterOperatorError } from '../filter-converter'; import { ValueDataSource } from '../../adapters/ValueDataSource'; @@ -110,7 +111,7 @@ const ROWS = [ async function selectedIds(filter: unknown): Promise { const ds = new ValueDataSource({ items: ROWS }); - const result = await ds.find('rows', { $filter: filter as any }); + const result = await ds.find('rows', { $filter: filter as QueryParams['$filter'] }); return result.data.map((r) => r.id as string); } diff --git a/packages/fields/src/widgets/__tests__/FilterConditionField.operators.test.ts b/packages/fields/src/widgets/__tests__/FilterConditionField.operators.test.ts index 09454a44ed..e7ef01cc84 100644 --- a/packages/fields/src/widgets/__tests__/FilterConditionField.operators.test.ts +++ b/packages/fields/src/widgets/__tests__/FilterConditionField.operators.test.ts @@ -206,7 +206,7 @@ describe('every spec field operator is reachable from the builder (#2942)', () = const emitted = new Set(); for (const operator of FILTER_BUILDER_OPERATORS) { const value = operator === 'in' || operator === 'not_in' ? ['a'] : operator === 'between' ? [1, 5] : 'a'; - const frag = condToMongo({ id: 'c1', field: 'f', operator, value } as any, noTypes); + const frag = condToMongo({ id: 'c1', field: 'f', operator, value }, noTypes); for (const op of operatorsOf(frag)) emitted.add(op); } // Lit control: the sweep really reads emitted operators. From 8a9cf1b5ed49b47affce85067733a228f11a4a4b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 06:56:18 +0000 Subject: [PATCH 23/36] test(core): the refusal-subject census names the $empty flag refusal, and the READMEs drop the version literal (objectui#11094) The full suite caught two things the targeted run could not: - filter-refusal-subject-9050 enumerates every FilterOperatorError throw site in filter-converter.ts and reddened on the new $empty one (13 sites, 12 rows). Its row names $empty as the subject. - doc-version-claims refuses an unledgered version literal on a scanned doc surface; the data-objectstack README now cites objectstack#20446 instead of the release number, and the core adapters README cites objectstack#20570 the same way. Claude-Session: https://claude.ai/code/session_01YLg8XqWGJ785fwQ5v4pH37 Co-authored-by: Claude --- packages/core/src/adapters/README.md | 4 ++-- .../__tests__/filter-refusal-subject-9050.test.ts | 3 +++ packages/data-objectstack/README.md | 12 ++++++------ 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/core/src/adapters/README.md b/packages/core/src/adapters/README.md index 20ba1ddaf2..21fe105944 100644 --- a/packages/core/src/adapters/README.md +++ b/packages/core/src/adapters/README.md @@ -135,8 +135,8 @@ is the lowering `convertFiltersToAST` already performs, not a reading invented h `$empty` (objectui#11094) is not a null test. `$empty: true` selects a row whose value is null, absent, `''` or `[]`, and `$empty: false` is its exact complement; a flag that -is not a boolean is refused. The AST twins are `is_empty` / `is_not_empty`, which -`@objectstack/spec` 17.6.0 stopped folding onto `is_null` / `is_not_null`. The answer is +is not a boolean is refused. The AST twins are `is_empty` / `is_not_empty`, which the +spec stopped folding onto `is_null` / `is_not_null` (objectstack#20570). The answer is the spec's own `isEmptyFilterValue`, called without a field declaration, because this adapter holds none: the spec's ruled table is keyed on a field's declared type (a text-like field is empty when null or `''`, a multi-value field when null or `[]`, diff --git a/packages/core/src/utils/__tests__/filter-refusal-subject-9050.test.ts b/packages/core/src/utils/__tests__/filter-refusal-subject-9050.test.ts index ca00670243..4375a40256 100644 --- a/packages/core/src/utils/__tests__/filter-refusal-subject-9050.test.ts +++ b/packages/core/src/utils/__tests__/filter-refusal-subject-9050.test.ts @@ -32,6 +32,8 @@ * ⚠️ UPDATED. It did: objectui#9164 added the empty-operator-map refusal * (`{ a: {} }`), and that case went red until its row was added below. Like * the two comparand arms it names no operator, so `field` is its subject. + * objectui#11094 added the `$empty` flag refusal (a flag that is not a + * boolean) the same way; it names its operator, so `$empty` is its subject. * * ⚠️ Each input is driven through `toFilterNodeSafely` — the RENDER-time entry, * not `convertFiltersToAST` directly — because the question is what a renderer @@ -73,6 +75,7 @@ const REFUSALS: Array<{ { site: 'bare array equality comparand', input: { tags: ['a', 'b'] }, operator: undefined, field: 'tags', subject: 'tags' }, { site: 'exotic comparand', input: { created: /abc/ }, operator: undefined, field: 'created', subject: 'created' }, { site: '$regex operator', input: { name: { $regex: 'a.c' } }, operator: '$regex', field: 'name', subject: '$regex' }, + { site: '$empty flag is not a boolean', input: { a: { $empty: 'yes' } }, operator: '$empty', field: 'a', subject: '$empty' }, { site: 'retired lowercase alias', input: { name: { $startswith: 'x' } }, operator: '$startswith', field: 'name', subject: '$startswith' }, { site: 'unknown operator', input: { name: { $bogus: 1 } }, operator: '$bogus', field: 'name', subject: '$bogus' }, { site: 'view rule: array on a single-value operator', input: [{ field: 'tags', operator: 'equals', value: ['a'] }], operator: 'equals', field: 'tags', subject: 'equals' }, diff --git a/packages/data-objectstack/README.md b/packages/data-objectstack/README.md index 5c864e3168..5ea45642ba 100644 --- a/packages/data-objectstack/README.md +++ b/packages/data-objectstack/README.md @@ -169,12 +169,12 @@ comes from the operator name, which is how the spec's `data/filter.zod.ts` reads it. `$empty` takes ONLY a boolean: any other flag throws `INVALID_FILTER` / 400 at lowering time, which is how the spec's own doors treat it. -`$empty` is not `$null`. `@objectstack/spec` 17.6.0 admitted it to -`FILTER_OPERATORS` and lowers the view operators `is_empty` / `is_not_empty` to -it (objectstack#20446); they lowered to `$null` before. Its meaning is the -spec's ruled per-type table: a text-like field is empty when null or `''`, a -multi-value field when null or `[]`, and any other field when null. Which -backend applies that table, and how, is listed in the docblock of the spec's +`$empty` is not `$null`. objectstack#20446 admitted it to the spec's +`FILTER_OPERATORS` and flipped the view operators `is_empty` / `is_not_empty` +to lower to it; they lowered to `$null` before. Its meaning is the spec's ruled +per-type table: a text-like field is empty when null or `''`, a multi-value +field when null or `[]`, and any other field when null. Which backend applies +that table, and how, is listed in the docblock of the spec's `FILTER_OPERATORS`, not here. Until objectui#11094 `convertFiltersToAST` refused `$empty` as an unknown operator. From 6a86dd13aea62f7056e2bb6b3b8fc1f2fc516cb3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 07:22:38 +0000 Subject: [PATCH 24/36] test(types),docs: register EmptyStateSchema at the import boundary, re-read the NamedListView loose census, cite objectstack#20694 instead of a version literal (objectui#11227) - imported-defaults-8317: the flat mirror now imports the spec's EmptyStateSchema, so it joins IMPORTED and the differential measures it. - object-view-unmirrored-keys-7779: NamedListView.emptyState no longer restates a nested object literal, so the loose regex reads 73 (gap 9), not 76 (gap 12). The member census is unchanged at 64. - The plugin-grid README, plugin-grid.mdx and schema-reference.md cite the upstream card instead of a spec version literal, which doc-version-claims refuses unless ledgered. Claude-Session: https://claude.ai/code/session_01YLg8XqWGJ785fwQ5v4pH37 Co-authored-by: Claude --- content/docs/api/schema-reference.md | 10 +++++----- content/docs/plugins/plugin-grid.mdx | 10 +++++----- packages/plugin-grid/README.md | 10 +++++----- .../src/__tests__/imported-defaults-8317.test.ts | 6 ++++++ .../object-view-unmirrored-keys-7779.test.ts | 13 ++++++++----- 5 files changed, 29 insertions(+), 20 deletions(-) diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md index b36157a918..3f04667b3f 100644 --- a/content/docs/api/schema-reference.md +++ b/content/docs/api/schema-reference.md @@ -751,7 +751,7 @@ These schemas integrate with [ObjectStack](https://objectstack.ai) for automatic A data grid that auto-fetches from an ObjectQL object definition. Includes search, filters, pagination, grouping, and inline editing. -An authored `object-grid` node takes its props in its `properties` bag, whose members are `@objectstack/spec`'s `ComponentPropsMap['object-grid']` row. `objectui validate` judges the bag against that row and refuses a prop written flat on the node by name, naming its bag member, as the spec's own page component does (objectui#11276). `SchemaRenderer` hoists the bag onto the node before `ObjectGrid` runs, so `ObjectGridSchema` is the node as the renderer reads it, and the table below lists its members. Base props such as `className` stay on the node, beside the bag. `description` is a bag member: the spec's row declares it since `@objectstack/spec` 17.6.0, so written on the node it is refused by name and pointed at `properties.description` (objectui#11227). +An authored `object-grid` node takes its props in its `properties` bag, whose members are `@objectstack/spec`'s `ComponentPropsMap['object-grid']` row. `objectui validate` judges the bag against that row and refuses a prop written flat on the node by name, naming its bag member, as the spec's own page component does (objectui#11276). `SchemaRenderer` hoists the bag onto the node before `ObjectGrid` runs, so `ObjectGridSchema` is the node as the renderer reads it, and the table below lists its members. Base props such as `className` stay on the node, beside the bag. `description` is a bag member: the spec's row declares it (objectstack#20694), so written on the node it is refused by name and pointed at `properties.description` (objectui#11227). ```json { @@ -791,8 +791,8 @@ also author `showFilters` (now retired on this node, below), `striped` and `pagination.enabled`, which the validator refuses: selection is spelled `selection: { type: 'multiple' }` (`'none'`, `'single'` or `'multiple'`), and `pagination` declares no on switch — its presence enables paging. It also authored `emptyState`, which the grid reads but -the spec's row did not declare until `@objectstack/spec` 17.6.0; it is authorable -in the `properties` bag now (see its row below). +the spec's row did not declare until objectstack#20694; it is authorable in the +`properties` bag now (see its row below). | Property | Type | Description | |----------|------|-------------| @@ -801,7 +801,7 @@ in the `properties` bag now (see its row below). | `columns` | `string[] \| ListColumn[]` | Columns to display. Either a plain array of field names (`["name", "email"]`), which auto-resolve from object metadata, or an array of `ListColumn` objects whose identity key is `field` (`{ "field": "status", "label": "Status" }`) — never `name`. **Do not mix the two forms in one array:** the array is dispatched on its first entry, so column objects sitting behind a bare string are dropped. | | `filter` | `any[]` | Pre-applied filter conditions. | | `sort` | `SortConfig[]` | Default sort configuration. The string clause (`"name desc"`) was retired in objectui#8221 and now fails validation. | -| `description` | `string \| I18nLabel` | One line of help text drawn above the grid; a per-locale map resolves like `label` (objectui#11068). Authored in the `properties` bag since the spec's row declared it at 17.6.0 (objectui#11227). | +| `description` | `string \| I18nLabel` | One line of help text drawn above the grid; a per-locale map resolves like `label` (objectui#11068). Authored in the `properties` bag: the spec's row declares it (objectstack#20694, objectui#11227). | | `searchableFields` | `string[]` | Fields included in search. | | `selection` | `SelectionConfig` | Row selection configuration. | | `pagination` | `PaginationConfig` | Pagination settings. | @@ -811,7 +811,7 @@ in the `properties` bag now (see its row below). | `grouping` | `GroupingConfig` | Row grouping configuration. **Server-side**: the set of groups, every group count and every per-group aggregation come from the group header query (`dataSource.queryGroupHeaders`), and each group's rows are paged by the server. Rows handed in whole are grouped in the browser (exact); over a data source with no header query, a grid that fetches its own rows refuses grouping with an error naming `queryGroupHeaders`. | | `frozenColumns` | `number` | Number of columns frozen on scroll. | | `navigation` | `ViewNavigationConfig` | SPA navigation configuration. | -| `emptyState` | `EmptyState` — `{ title?, message?, icon? }` | Drawn in place of an empty table: a Lucide `icon`, a `title` (default: the table's "No results found") and a `message` (default: none). `title` and `message` are `string \| I18nLabel`, each resolved against the display locale; a map with no usable entry keeps that member's default. Not drawn when a term in the grid's own server-side search box emptied it — the table and its search box stay (objectui#11068). Authored in the `properties` bag: the spec's row declares it since `@objectstack/spec` 17.6.0, as the list view's own `EmptyStateSchema` (objectui#11227). | +| `emptyState` | `EmptyState` — `{ title?, message?, icon? }` | Drawn in place of an empty table: a Lucide `icon`, a `title` (default: the table's "No results found") and a `message` (default: none). `title` and `message` are `string \| I18nLabel`, each resolved against the display locale; a map with no usable entry keeps that member's default. Not drawn when a term in the grid's own server-side search box emptied it — the table and its search box stay (objectui#11068). Authored in the `properties` bag: the spec's row declares it as the list view's own `EmptyStateSchema` (objectstack#20694, objectui#11227). | > **`name`, `placeholder`, `rowSpecActions` and `bulkSpecActions` are retired on > this node (objectui#11068).** Nothing ever read them: `rowSpecActions` / diff --git a/content/docs/plugins/plugin-grid.mdx b/content/docs/plugins/plugin-grid.mdx index a089de70ea..62cce3d0d4 100644 --- a/content/docs/plugins/plugin-grid.mdx +++ b/content/docs/plugins/plugin-grid.mdx @@ -116,8 +116,8 @@ booleans do not exist at all; the five `on*` names are **component props** #### `description` and `emptyState` -Two keys the grid honours (objectui#11068). `@objectstack/spec` 17.6.0 declares -both on its `object-grid` row, so both are bag members and both are in +Two keys the grid honours (objectui#11068). The upstream protocol's `object-grid` +row declares both (objectstack#20694), so both are bag members and both are in `GRID_QUERY_INPUTS` (objectui#11227): - `description` — one line of help text drawn above the grid. A string, or an @@ -149,10 +149,10 @@ both on its `object-grid` row, so both are bag members and both are in Written flat on the node, either key is refused by name and pointed at the bag (`description` → `properties.description`), as the spec's own page component -refuses it. Before 17.6.0 the row did not declare `description`, and this page -told you to write it on the node; that is now the refused spelling. +refuses it. Until the row declared `description`, this page told you to write it +on the node; that is now the refused spelling. -`keyboardNavigation`, the row's third 17.6.0 key, is marked +`keyboardNavigation`, the third key objectstack#20694 added to the row, is marked `[EXPERIMENTAL — not enforced]` there, and nothing in this plugin reads it yet, so it is not in `GRID_QUERY_INPUTS`. The bag accepts it, as the spec row does, and it changes nothing. diff --git a/packages/plugin-grid/README.md b/packages/plugin-grid/README.md index ce2384c7eb..21ff6cd8e9 100644 --- a/packages/plugin-grid/README.md +++ b/packages/plugin-grid/README.md @@ -222,8 +222,8 @@ booleans do not exist at all; the five `on*` names are **component props** #### `description` and `emptyState` -Two keys the grid honours (objectui#11068). `@objectstack/spec` 17.6.0 declares -both on its `object-grid` row, so both are bag members and both are in +Two keys the grid honours (objectui#11068). The upstream protocol's `object-grid` +row declares both (objectstack#20694), so both are bag members and both are in `GRID_QUERY_INPUTS` (objectui#11227): - `description` — one line of help text drawn above the grid. A string, or an @@ -255,10 +255,10 @@ both on its `object-grid` row, so both are bag members and both are in Written flat on the node, either key is refused by name and pointed at the bag (`description` → `properties.description`), as the spec's own page component -refuses it. Before 17.6.0 the row did not declare `description`, and this page -told you to write it on the node; that is now the refused spelling. +refuses it. Until the row declared `description`, this page told you to write it +on the node; that is now the refused spelling. -`keyboardNavigation`, the row's third 17.6.0 key, is marked +`keyboardNavigation`, the third key objectstack#20694 added to the row, is marked `[EXPERIMENTAL — not enforced]` there, and nothing in this package reads it yet, so it is not in `GRID_QUERY_INPUTS`. The bag accepts it, as the spec row does, and it changes nothing. diff --git a/packages/types/src/__tests__/imported-defaults-8317.test.ts b/packages/types/src/__tests__/imported-defaults-8317.test.ts index d4386841f1..69f7e04f9b 100644 --- a/packages/types/src/__tests__/imported-defaults-8317.test.ts +++ b/packages/types/src/__tests__/imported-defaults-8317.test.ts @@ -97,6 +97,8 @@ import { RowHeightSchema as SpecRowHeightSchema, BulkActionDefSchema as SpecBulkActionDefSchema, BulkActionOperationSchema as SpecBulkActionOperationSchema, + // objectui#11227 — the list view's empty-state shape `ObjectGridSchema.emptyState` reads. + EmptyStateSchema as SpecEmptyStateSchema, // objectui#10872 — the `ComponentPropsMap` rows the public-block arms read. PageHeaderProps as SpecPageHeaderProps, PageTabsProps as SpecPageTabsProps, @@ -303,6 +305,10 @@ const IMPORTED: Array = [ ['RowHeightSchema', SpecRowHeightSchema], ['BulkActionDefSchema', SpecBulkActionDefSchema], ['BulkActionOperationSchema', SpecBulkActionOperationSchema], + // objectui#11227: the flat mirror's `emptyState` is the spec's `EmptyStateSchema` + // by reference (the same schema the `object-grid` row holds since 17.6.0), + // crossed through this boundary. + ['EmptyStateSchema', SpecEmptyStateSchema], // objectui#10872: each ADR-0080 public-block arm's `properties` is the // block's `ComponentPropsMap` row, crossed through this boundary, so every // row is measured here like every other crossing (`page:section`, diff --git a/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts b/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts index 8abb5fdd9c..612fc16368 100644 --- a/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts +++ b/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts @@ -1379,13 +1379,16 @@ describe('objectui#7924 — the per-member liveness census on `NamedListView`, r // replacing the regex without re-opening the number. expect(ast).toBe(64); expect(namedListViewMemberCount()).toBe(ast); - // …and the loose regex does NOT, by 12, because it also counts nested - // object-literal lines. The gap is still exactly 12 after objectui#8980: + // …and the loose regex does NOT, by 9, because it also counts nested + // object-literal lines. The gap stayed exactly 12 through objectui#8980: // every one of the seventeen new members is a single-line type reference, // so none of them adds a nested object literal for the loose instrument to - // over-count. Pinned so "a figure between two instruments is neither" stays - // a reading rather than a remembered sentence. - expect(namedListViewLooseMemberCount()).toBe(76); + // over-count. objectui#11227 took it from 12 to 9: `emptyState` stopped + // restating `{ title, message, icon }` as a nested literal and indexes + // `ListViewSchema['emptyState']` instead, so its three nested lines left + // and the member count did not move. Pinned so "a figure between two + // instruments is neither" stays a reading rather than a remembered sentence. + expect(namedListViewLooseMemberCount()).toBe(73); expect(namedListViewLooseMemberCount()).toBeGreaterThan(ast); }); From 0a518da48675803eb7c470ea74490c3d53e5101e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 10:25:23 +0000 Subject: [PATCH 25/36] =?UTF-8?q?test(types):=20book=20the=20dimensionless?= =?UTF-8?q?=20measure-arity=20check=20on=20the=207715=20census=20to=20obje?= =?UTF-8?q?ctui#11334=20under=20objectui#11438=20ruling=20A=E2=80=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spec that objectui#11531 resolves adds `checkDashboardWidgetDimensionlessMeasureArity` to `DashboardWidgetSchema` (objectstack `11d28c17`, #21053). objectui's mirror cannot re-attach it by static import: objectstack `main` renamed the export (`32d57690`), and the Spec Main Shape Gate refuses the 17.6 name. objectui#11438 ruling A″ (record 5968177777) books row 3 to objectui#11334 with an expiry, in objectui#11111 decision 3 = B's shape. - The `DashboardWidgetSchema (complex.zod.ts)` row's `notAttachable` list books the check, written OWED TO objectui#11334. Expires when objectui resolves an `@objectstack/spec` carrying `32d57690`, or 2026-11-02, whichever is first. - The census bounds the booking: the row's count equality and the exact export-name match admit nothing beyond the listed entry, and the expiry condition turns the name census red by name. - An empty-frontmatter changeset declares no release; the test is excluded from `dist`. Claude-Session: https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn Co-authored-by: Claude --- .changeset/11334-row3-booking-17-6.md | 4 ++++ .../spec-object-refinements-7715.test.ts | 22 ++++++++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 .changeset/11334-row3-booking-17-6.md diff --git a/.changeset/11334-row3-booking-17-6.md b/.changeset/11334-row3-booking-17-6.md new file mode 100644 index 0000000000..4ff31f34d5 --- /dev/null +++ b/.changeset/11334-row3-booking-17-6.md @@ -0,0 +1,4 @@ +--- +--- + +Test-only change in `@object-ui/types`; no published behaviour changes. The spec-object refinement census in `spec-object-refinements-7715.test.ts` books `checkDashboardWidgetDimensionlessMeasureArity` on its `DashboardWidgetSchema (complex.zod.ts)` row as owed to objectui#11334, with an expiry, under objectui#11438 ruling A″. The test sits under `src/__tests__/`, which the package's `tsconfig.json` excludes from the published `dist`. diff --git a/packages/types/src/__tests__/spec-object-refinements-7715.test.ts b/packages/types/src/__tests__/spec-object-refinements-7715.test.ts index be5bad7967..57d18b3205 100644 --- a/packages/types/src/__tests__/spec-object-refinements-7715.test.ts +++ b/packages/types/src/__tests__/spec-object-refinements-7715.test.ts @@ -133,7 +133,27 @@ const SITES = [ // Both added to the spec object at 17.5.0 and attached by objectui#11073: each reads only // `type`, `options.stageOrder` / `values` and `id`, the spec's own fields on this node. attached: ['checkDashboardWidgetStageOrder', 'checkDashboardWidgetMetricMeasureArity'], - notAttachable: [], + // OWED TO objectui#11334. A BOOKING, not a measured structural refusal like the + // `checkListViewPageMount` entry the `ListViewSchema` row once held: objectstack + // `11d28c17` (#21053) added this check to the spec object, and objectui's mirror + // does not re-attach it yet. A static import of it compiles against the spec this + // repository resolves and fails the `Spec Main Shape Gate`, because objectstack + // `main` renamed the export (`32d57690`, #21293 / #21425) and no longer exports + // this name; objectui#11531 measured that attach on the bump's trunk and reverted + // it (its row 3). Nothing in this file measures that reason, for the Shape Gate + // compiles against objectstack `main`, which no run of this file sees. + // Booked by objectui#11438 ruling A″ (record 5968177777), which applies + // objectui#11111 decision 3 = B (record 5902351047) to the bump; group 5's + // booking of objectui#11536 has the same shape. + // Expires when objectui resolves an `@objectstack/spec` carrying `32d57690`, or + // 2026-11-02, whichever is first. The condition is enforced here and the date + // is read by objectui#11334, not by a clock: that resolution drops this export + // name, so the census test that matches every exported `check*` name against + // these lists turns red by name, and objectui#11334 strikes this entry as it + // re-attaches the renamed check. Capped by this row's own count equality and + // that census's exact name match: no check beyond the entries listed here is + // admitted. + notAttachable: ['checkDashboardWidgetDimensionlessMeasureArity'], }, { site: 'SpecDashboardFields → DashboardComponentSchema (complex.zod.ts)', spec: SpecDashboardSchema, attached: [], notAttachable: [] }, { site: 'SpecPageFields → PageNodeSchema (layout.zod.ts)', spec: SpecPageSchema, attached: ['checkPageSourceCompleteness'], notAttachable: [] }, From c4775ed3a913d51cba3fb72784361cd6f5879ae3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 10:48:00 +0000 Subject: [PATCH 26/36] =?UTF-8?q?test(types,plugin-dashboard):=20book=20th?= =?UTF-8?q?e=20dimensionless=20measure-arity=20rows=20of=20both=208894=20m?= =?UTF-8?q?easure-door=20tests=20to=20objectui#11334=20under=20objectui#11?= =?UTF-8?q?438=20ruling=20A=E2=80=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both files derive FAMILY by effect, so the spec's dimensionless measure-arity check (objectstack `11d28c17`, #21053) put pie, donut, funnel, scatter, treemap, sankey and radar into it, and objectui's door does not refuse them: the mirror waits for objectui#11334, because objectstack `main` renamed the export (`32d57690`). The seat ruled Q1 = A on objectui#11334 (record 5968413148): a bounded in-file ledger with inverted pins, under ruling A″ (record 5968177777) and objectui#11111 decision 3 = B (record 5902351047). - `OWED_TO_OBJECTUI_11334` names exactly the seven types in each file, with the owner, the rulings and the expiry: objectui resolves an `@objectstack/spec` carrying `32d57690`, or 2026-11-02, whichever is first. - The listed types' rows assert today's difference: the spec refuses, and objectui's door accepts (types); the add control is offered and no stored refusal is shown (panel). - Cap rows require the FAMILY types objectui fails to refuse to equal the ledger exactly, so a new difference is red, and so is a listed type objectui starts refusing once the mirror attaches. - The metric family's rows keep their assertions byte for byte; they iterate FAMILY minus the ledger, and a row keeps that set non-empty. Claude-Session: https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn Co-authored-by: Claude --- .changeset/11334-row3-booking-17-6.md | 2 +- ...idgetConfigPanel.measureDoor-8894.test.tsx | 95 +++++++++++++++++- ...rd-widget-metric-measure-door-8894.test.ts | 98 ++++++++++++++++++- 3 files changed, 189 insertions(+), 6 deletions(-) diff --git a/.changeset/11334-row3-booking-17-6.md b/.changeset/11334-row3-booking-17-6.md index 4ff31f34d5..fe7f5c1618 100644 --- a/.changeset/11334-row3-booking-17-6.md +++ b/.changeset/11334-row3-booking-17-6.md @@ -1,4 +1,4 @@ --- --- -Test-only change in `@object-ui/types`; no published behaviour changes. The spec-object refinement census in `spec-object-refinements-7715.test.ts` books `checkDashboardWidgetDimensionlessMeasureArity` on its `DashboardWidgetSchema (complex.zod.ts)` row as owed to objectui#11334, with an expiry, under objectui#11438 ruling A″. The test sits under `src/__tests__/`, which the package's `tsconfig.json` excludes from the published `dist`. +Test-only change in `@object-ui/types` and `@object-ui/plugin-dashboard`; no published behaviour changes. Under objectui#11438 ruling A″, the spec's dimensionless measure-arity check is booked as owed to objectui#11334, with an expiry, in three tests. In `spec-object-refinements-7715.test.ts` it goes on the census's `DashboardWidgetSchema (complex.zod.ts)` row. In the two objectui#8894 measure-door tests, it is a bounded ledger of the seven types objectui's door does not refuse yet, and a cap row requires that set to equal the ledger. All three files sit under `__tests__/`, which each package's `tsconfig.json` excludes from the published `dist`. diff --git a/packages/plugin-dashboard/src/__tests__/WidgetConfigPanel.measureDoor-8894.test.tsx b/packages/plugin-dashboard/src/__tests__/WidgetConfigPanel.measureDoor-8894.test.tsx index 21c8e47552..a13d1e049b 100644 --- a/packages/plugin-dashboard/src/__tests__/WidgetConfigPanel.measureDoor-8894.test.tsx +++ b/packages/plugin-dashboard/src/__tests__/WidgetConfigPanel.measureDoor-8894.test.tsx @@ -30,6 +30,14 @@ * A refusal shown in the panel is asserted to EQUAL the spec's message for the * same document, read off the spec's own parse: equal text proves the panel * surfaces the door's verdict rather than a restatement of it. + * + * ## OWED TO objectui#11334 — a bounded ledger, not a skip + * + * The spec's dimensionless measure-arity check puts more types into FAMILY than + * objectui's door refuses, because the mirror does not re-attach it yet. Those + * types are booked in `OWED_TO_OBJECTUI_11334` below, as in the types package's + * test: their rows assert what the panel does today, and a cap row requires the + * set the panel fails to refuse to equal the ledger exactly. */ import * as React from 'react'; @@ -76,6 +84,46 @@ const FAMILY = SPEC_TYPES.filter((t) => specArityIssue(t, TWO) !== undefined); /** Every other spec widget type. */ const OTHERS = SPEC_TYPES.filter((t) => !FAMILY.includes(t)); +/** + * ⚠️ OWED TO objectui#11334 — the FAMILY types the panel does NOT refuse yet, + * booked rather than skipped. + * + * The spec this repository resolves refuses two or more measures on these types + * when the widget declares no `dimensions` + * (`checkDashboardWidgetDimensionlessMeasureArity`, objectstack `11d28c17`, + * #21053). The panel asks objectui's door (`measureRefusal`), and the door's + * mirror does not re-attach that check: objectstack `main` renamed its export + * (`32d57690`), and a static import of the name the resolved spec ships fails + * the `Spec Main Shape Gate`. objectui#11334 owns the mirror; the panel follows + * it with no edit of its own. + * + * Booked by objectui#11438 ruling A″ (record 5968177777), which applies + * objectui#11111 decision 3 = B (record 5902351047) to the bump. Each listed + * type's rows assert TODAY's behaviour (the add control is offered, and no + * refusal is shown under a stored pair the spec refuses), and the cap row + * requires the FAMILY types the panel fails to refuse to EQUAL this list. A new + * difference is red, and so is a listed type the panel starts refusing: the + * entries go stale, by name, when the mirror attaches, and objectui#11334 + * strikes them in that change. + * + * EXPIRES when objectui resolves an `@objectstack/spec` carrying `32d57690`, or + * 2026-11-02, whichever is first. That resolution is caught by name in + * `packages/types/src/__tests__/spec-object-refinements-7715.test.ts`, whose + * census books the same check; the date is read by objectui#11334, not by a + * clock. + */ +const OWED_TO_OBJECTUI_11334 = ['pie', 'donut', 'funnel', 'scatter', 'treemap', 'sankey', 'radar']; + +/** The reason every owed row prints when it fails. */ +const OWED_REASON = + 'OWED TO objectui#11334: the dimensionless measure-arity check is not re-attached by the mirror yet. ' + + 'Booked by objectui#11438 ruling A″ (record 5968177777), applying objectui#11111 decision 3 = B ' + + '(record 5902351047). Expires when objectui resolves an @objectstack/spec carrying 32d57690, or ' + + '2026-11-02, whichever is first.'; + +/** The FAMILY types the panel refuses today: FAMILY minus the ledger. */ +const MIRRORED = FAMILY.filter((t) => !OWED_TO_OBJECTUI_11334.includes(t)); + const renderPanel = (config: Record, datasets: WidgetDatasetCatalogEntry[] | undefined = catalog) => render(); @@ -94,7 +142,7 @@ describe('objectui#8894 — the family is read off the spec\'s rule, and it is n }); describe('objectui#8894 — no second measure is offered on a metric-family widget', () => { - it.each(FAMILY)('SUBJECT: `%s` with one measure chosen offers no add control (catalog)', (type) => { + it.each(MIRRORED)('SUBJECT: `%s` with one measure chosen offers no add control (catalog)', (type) => { renderPanel({ id: 'rev_tile', type, dataset: 'sales_pipeline', values: ONE }); expect(valuesField().getByTestId('dataset-name-chip-revenue')).toBeInTheDocument(); expect(addControl()).toBeNull(); @@ -102,7 +150,7 @@ describe('objectui#8894 — no second measure is offered on a metric-family widg expect(valuesField().queryByTestId('dataset-names-refusal')).toBeNull(); }); - it.each(FAMILY)('SUBJECT: `%s` with one measure chosen offers no add control (free text, no catalog)', (type) => { + it.each(MIRRORED)('SUBJECT: `%s` with one measure chosen offers no add control (free text, no catalog)', (type) => { renderPanel({ id: 'rev_tile', type, dataset: 'sales_pipeline', values: ONE }, undefined); expect(addControl()).toBeNull(); }); @@ -120,7 +168,7 @@ describe('objectui#8894 — no second measure is offered on a metric-family widg }); describe('objectui#8894 — measures the door refuses are reported at once, in the door\'s words', () => { - it.each(FAMILY)('SUBJECT: a stored `%s` with two measures shows the spec\'s own refusal under them', (type) => { + it.each(MIRRORED)('SUBJECT: a stored `%s` with two measures shows the spec\'s own refusal under them', (type) => { renderPanel({ id: 'sales_tile', type, dataset: 'sales_pipeline', values: TWO }); const shown = valuesField().getByTestId('dataset-names-refusal'); expect(shown).toHaveAttribute('role', 'alert'); @@ -143,3 +191,44 @@ describe('objectui#8894 — measures the door refuses are reported at once, in t expect(addControl()).not.toBeNull(); }); }); + +describe('objectui#11334 — OWED: what the panel does today for the types the spec refuses and the door accepts', () => { + it.each(OWED_TO_OBJECTUI_11334)('OWED TO objectui#11334: `%s` with one measure chosen still offers the add control (catalog)', (type) => { + renderPanel({ id: 'rev_tile', type, dataset: 'sales_pipeline', values: ONE }); + expect(valuesField().getByTestId('dataset-name-chip-revenue')).toBeInTheDocument(); + expect(addControl(), OWED_REASON).not.toBeNull(); + expect(valuesField().queryByTestId('dataset-names-refusal'), OWED_REASON).toBeNull(); + }); + + it.each(OWED_TO_OBJECTUI_11334)('OWED TO objectui#11334: `%s` with one measure chosen still offers the add control (free text, no catalog)', (type) => { + renderPanel({ id: 'rev_tile', type, dataset: 'sales_pipeline', values: ONE }, undefined); + expect(addControl(), OWED_REASON).not.toBeNull(); + }); + + it.each(OWED_TO_OBJECTUI_11334)('OWED TO objectui#11334: a stored `%s` with two measures and no dimension shows no refusal, though the spec refuses it', (type) => { + expect(specArityIssue(type, TWO), OWED_REASON).toBeDefined(); + renderPanel({ id: 'sales_tile', type, dataset: 'sales_pipeline', values: TWO }); + expect(valuesField().queryByTestId('dataset-names-refusal'), OWED_REASON).toBeNull(); + expect(valuesField().getByTestId('dataset-name-chip-revenue')).toBeInTheDocument(); + expect(valuesField().getByTestId('dataset-name-chip-deal_count')).toBeInTheDocument(); + expect(addControl(), OWED_REASON).not.toBeNull(); + }); +}); + +describe('objectui#11334 — the cap: the panel fails to refuse EXACTLY the ledgered FAMILY types', () => { + it('the FAMILY types whose stored two-measure widget shows no spec refusal equal the ledger', () => { + const unrefused = FAMILY.filter((type) => { + const { unmount } = renderPanel({ id: 'sales_tile', type, dataset: 'sales_pipeline', values: TWO }); + const shown = valuesField().queryByTestId('dataset-names-refusal'); + const refused = shown !== null && shown.textContent === specArityIssue(type, TWO)?.message; + unmount(); + return !refused; + }); + expect([...unrefused].sort(), OWED_REASON).toEqual([...OWED_TO_OBJECTUI_11334].sort()); + }); + + it('the ledger leaves the metric family judged: the mirrored rows are not vacuous', () => { + expect(MIRRORED.length).toBeGreaterThan(0); + expect(MIRRORED).toContain('kpi'); + }); +}); diff --git a/packages/types/src/__tests__/dashboard-widget-metric-measure-door-8894.test.ts b/packages/types/src/__tests__/dashboard-widget-metric-measure-door-8894.test.ts index b6677803ca..0bcd31844a 100644 --- a/packages/types/src/__tests__/dashboard-widget-metric-measure-door-8894.test.ts +++ b/packages/types/src/__tests__/dashboard-widget-metric-measure-door-8894.test.ts @@ -43,6 +43,15 @@ * dimension, the shape the spec's refusal points authors at), and the family * derivation itself is checked for being non-vacuous in both directions. * + * ## OWED TO objectui#11334 — a bounded ledger, not a skip + * + * FAMILY is read by effect, so a second spec rule at `values` joins it: the + * dimensionless measure-arity check (objectstack `11d28c17`, #21053) refuses two + * measures with no `dimensions` on more types than the metric family, and + * objectui's mirror does not re-attach it yet. Those types are booked in + * `OWED_TO_OBJECTUI_11334` below: their rows assert today's difference, and a cap + * row requires the set objectui fails to refuse to equal the ledger exactly. + * * ⛔ Not pinned here, deliberately: the TypeScript face. `values` stays * `string[]` on the type, as it does on the spec's own inferred type — the * card rules out a TypeScript narrowing. @@ -88,6 +97,55 @@ const FAMILY = SPEC_TYPES.filter((t) => specArityIssue(widget(t, TWO)) !== undef /** Every other spec chart family. */ const OTHERS = SPEC_TYPES.filter((t) => !FAMILY.includes(t)); +/** + * ⚠️ OWED TO objectui#11334 — the FAMILY types whose refusal objectui's doors do + * NOT mirror yet, booked rather than skipped. + * + * The spec this repository resolves refuses two or more measures on these types + * when the widget declares no `dimensions` + * (`checkDashboardWidgetDimensionlessMeasureArity`, objectstack `11d28c17`, + * #21053). objectui's mirror does not re-attach that check: objectstack `main` + * renamed its export (`32d57690`), and a static import of the name the resolved + * spec ships fails the `Spec Main Shape Gate`. objectui#11334 owns the mirror. + * + * Booked by objectui#11438 ruling A″ (record 5968177777), which applies + * objectui#11111 decision 3 = B (record 5902351047) to the bump. Each listed + * type's rows assert TODAY's difference (the spec refuses; objectui's door + * accepts), and the cap rows require the FAMILY types objectui fails to refuse + * to EQUAL this list on both faces. A new difference is red, and so is a listed + * type objectui starts refusing: the entries go stale, by name, when the mirror + * attaches, and objectui#11334 strikes them in that change. + * + * EXPIRES when objectui resolves an `@objectstack/spec` carrying `32d57690`, or + * 2026-11-02, whichever is first. That resolution is caught by name in + * `spec-object-refinements-7715.test.ts`, whose census books the same check; + * the date is read by objectui#11334, not by a clock. + */ +const OWED_TO_OBJECTUI_11334 = ['pie', 'donut', 'funnel', 'scatter', 'treemap', 'sankey', 'radar']; + +/** The reason every owed row prints when it fails. */ +const OWED_REASON = + 'OWED TO objectui#11334: the dimensionless measure-arity check is not re-attached by the mirror yet. ' + + 'Booked by objectui#11438 ruling A″ (record 5968177777), applying objectui#11111 decision 3 = B ' + + '(record 5902351047). Expires when objectui resolves an @objectstack/spec carrying 32d57690, or ' + + '2026-11-02, whichever is first.'; + +/** The FAMILY types whose refusal objectui's doors mirror today: FAMILY minus the ledger. */ +const MIRRORED = FAMILY.filter((t) => !OWED_TO_OBJECTUI_11334.includes(t)); + +/** Does an objectui verdict carry the spec's own arity issue for `doc` at `path`? */ +const refusesAsSpec = ( + r: { success: boolean; error?: { issues: readonly unknown[] } }, + doc: Record, + path: string, +): boolean => { + const specIssue = specArityIssue(doc); + if (r.success || specIssue === undefined) return false; + return envelope(r.error!.issues as Issue[]).some( + (i) => i.code === 'custom' && i.path === path && i.message === specIssue.message, + ); +}; + describe('objectui#8894 — the family is read off the spec\'s own rule, and it is not vacuous', () => { it('the spec refuses a second measure on SOME types and not on others', () => { // Both halves must be populated: an empty family would make every @@ -106,7 +164,7 @@ describe('objectui#8894 — the family is read off the spec\'s own rule, and it }); describe('objectui#8894 — face 1: `DashboardWidgetSchema` refuses a second measure on the metric family', () => { - it.each(FAMILY)('`%s` with two measures is refused at `values`, with the spec\'s own issue', (type) => { + it.each(MIRRORED)('`%s` with two measures is refused at `values`, with the spec\'s own issue', (type) => { const doc = widget(type, TWO); const specIssue = specArityIssue(doc)!; const r = DashboardWidgetSchema.safeParse(doc); @@ -145,7 +203,7 @@ describe('objectui#8894 — face 1: `DashboardWidgetSchema` refuses a second mea }); describe('objectui#8894 — face 2: the published door (`safeValidateSchema`) refuses it inside a dashboard', () => { - it.each(FAMILY)('`%s` with two measures is refused at `widgets.0.values`, with the spec\'s own issue', (type) => { + it.each(MIRRORED)('`%s` with two measures is refused at `widgets.0.values`, with the spec\'s own issue', (type) => { const w = widget(type, TWO); const specIssue = specArityIssue(w)!; const r = safeValidateSchema(dashboardNode(w)); @@ -161,3 +219,39 @@ describe('objectui#8894 — face 2: the published door (`safeValidateSchema`) re expect(safeValidateSchema(dashboardNode(widget(type, ONE))).success).toBe(true); }); }); + +describe('objectui#11334 — OWED: the spec refuses these widgets and objectui\'s doors accept them today', () => { + it.each(OWED_TO_OBJECTUI_11334)('OWED TO objectui#11334: `%s` with two measures and no dimension: the spec refuses at `values`, `DashboardWidgetSchema` accepts', (type) => { + const doc = widget(type, TWO); + expect(specArityIssue(doc), OWED_REASON).toBeDefined(); + expect(DashboardWidgetSchema.safeParse(doc).success, OWED_REASON).toBe(true); + }); + + it.each(OWED_TO_OBJECTUI_11334)('OWED TO objectui#11334: `%s` with two measures and no dimension: the published door accepts it inside a dashboard', (type) => { + const w = widget(type, TWO); + expect(specArityIssue(w), OWED_REASON).toBeDefined(); + expect(safeValidateSchema(dashboardNode(w)).success, OWED_REASON).toBe(true); + }); +}); + +describe('objectui#11334 — the cap: objectui fails to refuse EXACTLY the ledgered FAMILY types', () => { + it('face 1: the FAMILY types `DashboardWidgetSchema` does not refuse with the spec\'s own issue equal the ledger', () => { + const unrefused = FAMILY.filter((t) => { + const doc = widget(t, TWO); + return !refusesAsSpec(DashboardWidgetSchema.safeParse(doc), doc, 'values'); + }); + expect([...unrefused].sort(), OWED_REASON).toEqual([...OWED_TO_OBJECTUI_11334].sort()); + }); + + it('face 2: the FAMILY types the published door does not refuse with the spec\'s own issue equal the ledger', () => { + const unrefused = FAMILY.filter((t) => { + const w = widget(t, TWO); + return !refusesAsSpec(safeValidateSchema(dashboardNode(w)), w, 'widgets.0.values'); + }); + expect([...unrefused].sort(), OWED_REASON).toEqual([...OWED_TO_OBJECTUI_11334].sort()); + }); + + it('the ledger leaves the metric family judged: the mirrored rows are not vacuous', () => { + expect(MIRRORED.length).toBeGreaterThan(0); + }); +}); From 5a631d60876023f159152e68d2f3e4b346039235 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 11:12:02 +0000 Subject: [PATCH 27/36] =?UTF-8?q?fix(fields,app-shell,plugin-list):=20the?= =?UTF-8?q?=20three=20=E3=80=8Cis=20empty=E3=80=8D=20builders=20write=20th?= =?UTF-8?q?e=20spec's=20one=20operator,=20$empty=20(objectui#10813)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FilterConditionField wrote "no value OR ''" on every type (an $or of $in [''] and $null), the dataset bridge wrote $exists (has a value), and the list view's live query an equality to null. @objectstack/spec 17.6.0 admits $empty (objectstack#20446), whose meaning is the field's declared row of the per-type table (ruling B on objectstack#20311), expanded by every evaluator. All three builders now write it, keeping no copy of the table: - FilterConditionField: { F: { $empty: true|false } }; kvToCondition reads a boolean $empty back as the pair, and still reads both earlier shapes (re-written as $empty on the next edit). - datasetFilterCondition: VALUELESS_TO_MONGO writes $empty; a stored $exists no longer reads back as the pair (Source tab, bytes untouched), since a sibling edit would otherwise rewrite it (objectui#10257). - ListView: the pair takes the value-less path; mapOperator emits the spec's isempty / isnotempty, lowered to $empty. FilterConditionField's KNOWN_UNREACHABLE expiry row turns over: $empty leaves the set and the row now asserts a builder emits it. Claude-Session: https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn Co-authored-by: Claude --- .../10813-dataset-filter-empty-operator.md | 11 ++ .../10813-filter-condition-empty-operator.md | 18 ++ .changeset/10813-list-view-empty-operator.md | 9 + ...FilterCondition.nullOperators-9363.test.ts | 10 +- ...lterCondition.readHalfHolds-10257.test.tsx | 17 +- .../inspectors/datasetFilterCondition.test.ts | 38 +++- .../inspectors/datasetFilterCondition.ts | 50 ++++-- ...r-builder-protocol-ids-census-9306.test.ts | 22 ++- .../src/widgets/FilterConditionField.tsx | 99 ++++++----- ...nditionField.emptyOperators-10790.test.tsx | 167 +++++++++++++----- .../FilterConditionField.operators.test.ts | 77 ++++---- packages/plugin-list/src/ListView.tsx | 31 +++- ...FilterGroupToAST.canonicalSpelling.test.ts | 16 +- .../__tests__/convertFilterGroupToAST.test.ts | 13 +- .../filter-operator-ast-parity.test.ts | 103 +++-------- ...ffered-operator-expressible-parity.test.ts | 6 +- 16 files changed, 432 insertions(+), 255 deletions(-) create mode 100644 .changeset/10813-dataset-filter-empty-operator.md create mode 100644 .changeset/10813-filter-condition-empty-operator.md create mode 100644 .changeset/10813-list-view-empty-operator.md diff --git a/.changeset/10813-dataset-filter-empty-operator.md b/.changeset/10813-dataset-filter-empty-operator.md new file mode 100644 index 0000000000..1d47373750 --- /dev/null +++ b/.changeset/10813-dataset-filter-empty-operator.md @@ -0,0 +1,11 @@ +--- +'@object-ui/app-shell': minor +--- + +The Studio dataset filter builder writes "Is empty" / "Is not empty" as the spec's `{ FIELD: { $empty: true } }` / `{ FIELD: { $empty: false } }` instead of `$exists` (objectui#10813). + +`dataset.filter` and `measure.filter` stored the pair as `{ FIELD: { $exists: false } }` / `{ FIELD: { $exists: true } }`. `$exists` is the spec's has-a-value test (`!= null`), so a text value of `''` or a multi-value `[]` was never "empty" there, while the same operator in the sharing-rule widget and in a saved view meant something else. `@objectstack/spec` 17.6.0 admits `$empty` (objectstack#20446), whose meaning is the column's DECLARED row of the spec's per-type table (ruling B on objectstack#20311): null or `''` on a text-like column, null or `[]` on a multi-value one, null alone on every other type. The analytics service answers it from the field's declared type and `multiple` on its SQL and ObjectQL strategies. The bridge keeps no copy of the table. + +The read half reads `$empty` back as the pair, with a boolean flag only. + +**What moves for a stored filter.** A stored `$exists` no longer opens as "Is empty" / "Is not empty": it opens in the Source tab, with its bytes untouched, and keeps matching what it matched before. Opening it as the pair would let an edit to ANOTHER row rewrite it to `$empty` and move `''` / `[]` across the line, which the read-half invariant (objectui#10257) forbids, and this inspector offers no `exists` row. To move such a filter to the new meaning, remove the row and add "Is empty" again. Readable stores (this repository, objectstack, hotcrm and cloud) hold no `dataset.filter` with `$exists`. diff --git a/.changeset/10813-filter-condition-empty-operator.md b/.changeset/10813-filter-condition-empty-operator.md new file mode 100644 index 0000000000..f650cd9100 --- /dev/null +++ b/.changeset/10813-filter-condition-empty-operator.md @@ -0,0 +1,18 @@ +--- +'@object-ui/fields': minor +--- + +`FilterConditionField` writes "Is empty" / "Is not empty" as the spec's one 「is empty」 operator, `{ FIELD: { $empty: true } }` / `{ FIELD: { $empty: false } }` (objectui#10813). + +The widget behind sharing-rule `criteria_json`, `relatedListFilter` and `summaryOperations.filter` used to write its own meaning of 「is empty」: `{ $or: [{ FIELD: { $in: [''] } }, { FIELD: { $null: true } }] }` and its complement `{ FIELD: { $nin: [''], $null: false } }`, i.e. "no value OR `''`" on every field type. The `''` member reached a number or date column as `IN ('')`, and a multi-value column (stored as JSON by the SQL driver) as an `$in` that driver refuses. `@objectstack/spec` 17.6.0 admits `$empty` to `FILTER_OPERATORS` (objectstack#20446), and its meaning is the field's DECLARED row of the spec's per-type table (ruling B on objectstack#20311): null or `''` on a text-like field, null or `[]` on a multi-value field, null alone on every other type. Every evaluator expands it itself (`expandEmptyOperator`), so the widget now writes the same token on every column type and keeps no copy of the table. + +`kvToCondition` reads `$empty` back as the pair, with a boolean flag only: any other flag stays the raw criteria it is, as every evaluator refuses it. + +**What moves for a stored rule.** Reading alone rewrites nothing: a criteria saved in either earlier shape (the objectui#10790 `$or` entry and `$nin` + `$null` pair, or the older `$in: [null, '']` / `$nin: [null, '']`) still opens as the same "Is empty" / "Is not empty" row, and an un-edited rule keeps its stored bytes and keeps matching as before. The next time the criteria is edited, those rows are written as `$empty`, which re-scopes the rule: + +- text-like column: no change (both mean null or `''`); +- every other single-valued column (number, boolean, date, datetime, time, select, a single lookup): a row holding `''` is no longer "empty" and becomes "not empty". Since objectstack#20308 the write door stores a cleared number, boolean, date, datetime or time as null, so on those types only a value written before it can hold `''`; a select or lookup can still hold one, and the spec's ruled table does not count it as empty; +- multi-value column (multiselect, checkboxes, tags, or a `multiple: true` select, lookup or user): a row holding `[]` becomes "empty" and leaves "is not empty". On the SQL driver the earlier shape was refused outright, so a rule there starts running; +- the `$in: [null, '']` shapes were refused by every objectstack filter face, so a rule still in them starts running. + +Measured on the installed 17.6.0 by-value readers (`@objectstack/formula`'s `matchesFilterCondition` and `ValueDataSource`) over null, an absent key, `''`, `[]` and set values: the `$or` entry and `$empty: true` disagree only on `[]`. Readable stores (this repository, objectstack, hotcrm and cloud) hold no criteria in either earlier shape. diff --git a/.changeset/10813-list-view-empty-operator.md b/.changeset/10813-list-view-empty-operator.md new file mode 100644 index 0000000000..829f61973c --- /dev/null +++ b/.changeset/10813-list-view-empty-operator.md @@ -0,0 +1,9 @@ +--- +'@object-ui/plugin-list': minor +--- + +The list view's live query sends "Is empty" / "Is not empty" as the spec's `isempty` / `isnotempty`, which the spec lowers to `$empty`, instead of an equality to `null` (objectui#10813). + +`convertFilterGroupToAST` resolved the pair to `[FIELD, '=', null]` / `[FIELD, '!=', null]`, a null-only test, before `mapOperator` was consulted. The same rule saved into the view is persisted as `is_empty`, which `@objectstack/spec` 17.6.0 lowers to `$empty` (objectstack#20446): so one filter panel answered two record sets, depending on whether the view had been saved. The pair now takes the value-less path like `is_null`, and `mapOperator` gains `isempty` / `isnotempty` arms. The node is `[FIELD, 'isempty', null]`; the spec discards the third slot. + +**What moves.** Nothing is stored by this path. On a text-like column a row holding `''` is now "empty", and on a multi-value column a row holding `[]` is, matching the server's per-type answer for the saved view. On a `provider: 'value'` list a row with no key at all is now "empty" too: the equality to `null` did not select it there. diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/datasetFilterCondition.nullOperators-9363.test.ts b/packages/app-shell/src/views/metadata-admin/inspectors/datasetFilterCondition.nullOperators-9363.test.ts index 2ad843413b..f3af76ec99 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/datasetFilterCondition.nullOperators-9363.test.ts +++ b/packages/app-shell/src/views/metadata-admin/inspectors/datasetFilterCondition.nullOperators-9363.test.ts @@ -102,9 +102,13 @@ describe('groupToCondition — the null predicates this inspector offers (object ).toEqual({ stage: { $null: true } }); }); - it('leaves the $exists pair exactly as it was', () => { - expect(groupToCondition(row('is_empty'))).toEqual({ closed_at: { $exists: false } }); - expect(groupToCondition(row('is_not_empty'))).toEqual({ closed_at: { $exists: true } }); + it('keeps the empty pair a predicate of its own — `$empty`, not `$null` (objectui#10813)', () => { + // This file's repair left the empty pair on `$exists`; objectui#10813 moved + // it to the spec's one 「is empty」 operator. Either way it is NOT the null + // pair: `$empty` also counts `''` on a text column and `[]` on a + // multi-value one, so folding the two would rewrite the author's choice. + expect(groupToCondition(row('is_empty'))).toEqual({ closed_at: { $empty: true } }); + expect(groupToCondition(row('is_not_empty'))).toEqual({ closed_at: { $empty: false } }); }); it('still drops an operator it does not map, rather than emitting a wrong filter', () => { diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/datasetFilterCondition.readHalfHolds-10257.test.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/datasetFilterCondition.readHalfHolds-10257.test.tsx index b79cd9425a..d04156d7ac 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/datasetFilterCondition.readHalfHolds-10257.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/datasetFilterCondition.readHalfHolds-10257.test.tsx @@ -208,7 +208,7 @@ describe('class 1 — an incomplete stored value is not opened as a row the next }); it('CONTROL: the value-less tokens carry no value to be incomplete, and stay opened', () => { - for (const other of [{ name: { $exists: false } }, { name: { $null: true } }]) { + for (const other of [{ name: { $empty: true } }, { name: { $null: true } }]) { const { group, representable } = conditionToGroup(beside(other), FIELDS); expect(representable, JSON.stringify(other)).toBe(true); expect(editStage(group, 'lost')).toEqual({ $and: [{ stage: { $eq: 'lost' } }, other] }); @@ -224,7 +224,7 @@ describe('class 2 — a stored token is not opened as an operator the column\'s { stored: { amount: { $in: [1, 2] } }, type: 'number', readAs: 'in' }, // "Every token" includes the value-less arms: the boolean bucket offers // only `equals` / `notEquals`. - { stored: { flag: { $exists: true } }, type: 'boolean', readAs: 'is_not_empty' }, + { stored: { flag: { $empty: false } }, type: 'boolean', readAs: 'is_not_empty' }, { stored: { flag: { $null: false } }, type: 'boolean', readAs: 'is_not_null' }, ]; @@ -262,7 +262,7 @@ describe('class 2 — a stored token is not opened as an operator the column\'s }); it('CONTROL: the value-less tokens on a column whose bucket offers them still open', () => { - for (const stored of [{ name: { $exists: true } }, { closed_at: { $null: false } }, { region: { $exists: false } }]) { + for (const stored of [{ name: { $empty: false } }, { closed_at: { $null: false } }, { region: { $empty: true } }]) { const { group, representable } = conditionToGroup(stored, FIELDS); expect(representable, JSON.stringify(stored)).toBe(true); expect(groupToCondition(group)).toEqual(stored); @@ -315,7 +315,16 @@ describe('the invariant, swept: every row the read half opens, the panel draws a .flatMap((t) => SCALARS.map((v) => ({ stored: { [t]: v }, implicit: false }))), ...['$in', '$nin'].flatMap((t) => LISTS.map((v) => ({ stored: { [t]: v }, implicit: false }))), ...PAIRS.map((v) => ({ stored: { $between: v }, implicit: false })), - ...[true, false].flatMap((b) => [{ stored: { $exists: b }, implicit: false }, { stored: { $null: b }, implicit: false }]), + // `$empty` is what the empty pair writes since objectui#10813; `$exists`, + // what it wrote before, stays in the domain — it is now REFUSED (no + // operator this inspector offers reads it back), which the invariant + // accepts, and a read that still opened it would be named by the + // byte-identical check below. + ...[true, false].flatMap((b) => [ + { stored: { $empty: b }, implicit: false }, + { stored: { $exists: b }, implicit: false }, + { stored: { $null: b }, implicit: false }, + ]), ...[...SCALARS, ...LISTS].map((v) => ({ stored: v, implicit: true })), ]; diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/datasetFilterCondition.test.ts b/packages/app-shell/src/views/metadata-admin/inspectors/datasetFilterCondition.test.ts index 25e4715a6a..17fa129b3c 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/datasetFilterCondition.test.ts +++ b/packages/app-shell/src/views/metadata-admin/inspectors/datasetFilterCondition.test.ts @@ -15,9 +15,38 @@ describe('datasetFilterCondition', () => { ] })).toEqual({ $and: [{ stage: { $eq: 'won' } }, { amount: { $gt: 1000 } }] }); }); - it('maps isEmpty/isNotEmpty to $exists', () => { + it('maps is_empty / is_not_empty to the spec\'s one 「is empty」 operator, `$empty` (objectui#10813)', () => { + // They wrote `$exists` until objectui#10813 — the has-a-value test, which + // never counted `''` or `[]`. The per-type meaning is the spec's expansion, + // so the SAME token is written whatever the column's type. expect(groupToCondition({ logic: 'and', conditions: [{ field: 'closed_at', operator: 'is_not_empty' }] })) - .toEqual({ closed_at: { $exists: true } }); + .toEqual({ closed_at: { $empty: false } }); + expect(groupToCondition({ logic: 'and', conditions: [{ field: 'closed_at', operator: 'is_empty' }] })) + .toEqual({ closed_at: { $empty: true } }); + }); + + it('a stored `$exists` is no longer read back as the empty pair — it opens in the Source tab, bytes untouched (objectui#10813)', () => { + // Read back as `is_empty`, a sibling edit would rewrite it to `$empty` and + // move `''` / `[]` across the line. This inspector offers no `exists` row, + // so the filter goes to the Source tab rather than opening as a row the + // next commit would change. + for (const stored of [{ closed_at: { $exists: false } }, { closed_at: { $exists: true } }]) { + expect(conditionToGroup(stored).representable, JSON.stringify(stored)).toBe(false); + expect(conditionToGroup({ $and: [{ stage: { $eq: 'won' } }, stored] }).representable).toBe(false); + } + // CONTROL: the `$empty` pair this bridge writes opens as the rows. + expect(conditionToGroup({ closed_at: { $empty: true } }).group.conditions.map((c) => c.operator)) + .toEqual(['is_empty']); + expect(conditionToGroup({ closed_at: { $empty: false } }).group.conditions.map((c) => c.operator)) + .toEqual(['is_not_empty']); + }); + + it('a non-boolean `$empty` flag is not opened as a row (objectui#10813)', () => { + // `$empty` is declared `z.boolean()` and every evaluator refuses another + // flag; opened as a row, the next commit would make it runnable. + for (const flag of ['yes', 1, null]) { + expect(conditionToGroup({ closed_at: { $empty: flag } } as never).representable, JSON.stringify(flag)).toBe(false); + } }); it('drops unmapped operators rather than emitting a bad filter', () => { @@ -49,7 +78,7 @@ describe('datasetFilterCondition', () => { ] })).toEqual({ stage: { $eq: 'won' } }); // value-less operators are still kept expect(groupToCondition({ logic: 'and', conditions: [{ field: 'closed_at', operator: 'is_not_empty', value: '' }] })) - .toEqual({ closed_at: { $exists: true } }); + .toEqual({ closed_at: { $empty: false } }); }); it('round-trips representable conditions (condition → group → condition)', () => { @@ -57,7 +86,8 @@ describe('datasetFilterCondition', () => { { status: { $eq: 'won' } }, { $and: [{ stage: { $eq: 'won' } }, { amount: { $gt: 1000 } }] }, { region: { $in: ['NA', 'EU'] } }, - { closed_at: { $exists: false } }, + { closed_at: { $empty: true } }, + { closed_at: { $empty: false } }, ]) { const { group, representable } = conditionToGroup(c); expect(representable).toBe(true); diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/datasetFilterCondition.ts b/packages/app-shell/src/views/metadata-admin/inspectors/datasetFilterCondition.ts index bc50245253..161ab5286f 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/datasetFilterCondition.ts +++ b/packages/app-shell/src/views/metadata-admin/inspectors/datasetFilterCondition.ts @@ -16,8 +16,8 @@ * * The value-less operators are the exception to "field op value": the builder * draws no input for them, so the row is complete without one. Both pairs the - * spec's vocabulary carries — `$exists` (is empty) and `$null` (is null) — are - * bridged here, in {@link VALUELESS_TO_MONGO}. + * builder offers — `$empty` (is empty) and `$null` (is null) — are bridged + * here, in {@link VALUELESS_TO_MONGO}. * * An operator that is NOT bridged is dropped, and dropping is where the danger * used to be: see {@link isClearedGroup} for why an unmapped operator is now @@ -102,10 +102,10 @@ const MONGO_TO_OP: Record = { * is exactly how the two halves drift apart. * * Measured over the whole domain the dropdown can build (objectui#9382): four - * tokens have two operators writing them — `$exists`, `$null`, `$gt`, `$lt`. - * The first two are disambiguated by their PAYLOAD, in the `$exists` / `$null` - * arms of {@link conditionToGroup}, because the stored value is the boolean - * that picks the operator. `$gt` / `$lt` carry the author's comparand instead, + * tokens have two operators writing them — `$empty` (`$exists` until + * objectui#10813), `$null`, `$gt`, `$lt`. The first two are disambiguated by + * their PAYLOAD, in the `$empty` / `$null` arms of {@link conditionToGroup}, + * because the stored value is the boolean that picks the operator. `$gt` / `$lt` carry the author's comparand instead, * so no bit of the stored condition tells `after` from `greater_than` — which * is why the field's declared type has to. */ @@ -174,10 +174,22 @@ function readBackOperator(mop: string, fieldType: string | undefined): string | * * `is_null` / `is_not_null` are not a spelling of `is_empty` / `is_not_empty`. The * dropdown offers both pairs as their own rows and the spec's filter vocabulary - * carries both `$null` and `$exists`, so they stay distinct in both directions; + * carries both `$null` and `$empty`, so they stay distinct in both directions; * collapsing them would draw two labels for one wire predicate and rewrite the * author's choice when the filter is read back. * + * objectui#10813: the empty pair writes the spec's ONE 「is empty」 operator, + * `$empty` (ruling B on objectstack#20311; in `FILTER_OPERATORS` and the + * lowering of the view operators `is_empty` / `is_not_empty` since + * objectstack#20446). What counts as empty is the column's DECLARED row of the + * spec's per-type table — `''` on a text-like column, `[]` on a multi-value + * one, null alone on every other type — expanded by each evaluator + * (`expandEmptyOperator`), so ⛔ no copy of that table lives here. It wrote + * `$exists` before — the spec's has-a-value test (`!= null`), which never + * counts `''` or `[]` — so one operator name, offered in three builders, + * matched three record sets. A stored `$exists` is no longer read back as this pair — + * see the note in {@link conditionToGroup}. + * * objectui#9363: the null pair was missing here, so an `Is null` row — an * ordinary entry in this inspector's menu, drawn as a finished row — fell * through to the unmapped-operator `continue` below and was dropped. Dropping @@ -187,7 +199,7 @@ function readBackOperator(mop: string, fieldType: string | undefined): string | * with no error and the condition still on screen. */ const VALUELESS_TO_MONGO: Record> = { - is_empty: { $exists: false }, is_not_empty: { $exists: true }, + is_empty: { $empty: true }, is_not_empty: { $empty: false }, is_null: { $null: true }, is_not_null: { $null: false }, }; @@ -334,7 +346,7 @@ export function groupToCondition(group: BuilderGroup | undefined): FilterConditi * * 2. DOES THE COLUMN'S BUCKET OFFER ITS OPERATOR? Read back as an operator the * dropdown does not list — `$in` on a date or number column, `$gt` on a - * text one, `$exists` on a boolean one — the panel draws a BLANK operator + * text one, `$empty` on a boolean one — the panel draws a BLANK operator * trigger (objectui#4768 / #7561), and one touch of the row's field picker * reconciles it to `equals` and reshapes the value, committing a different * filter than the one stored (the objectui#9382 defect). The bucket is @@ -394,8 +406,24 @@ export function conditionToGroup( const opKeys = Object.keys(v); if (opKeys.length !== 1) return { group: empty, representable: false }; const mop = opKeys[0]; - if (mop === '$exists') { - row = { id: `c${i}`, field, operator: v.$exists ? 'is_not_empty' : 'is_empty', value: '' }; + if (mop === '$empty') { + // The inverse of the write half, as for `$null` below — but only a + // BOOLEAN flag: the spec declares `$empty: z.boolean()` and every + // evaluator refuses any other, so a non-boolean one goes to the Source + // tab rather than opening as a row the next commit would make runnable. + // + // ⛔ A stored `$exists` no longer reads back as `is_empty` / + // `is_not_empty` (objectui#10813). It is what this pair WROTE before, + // but it is the has-a-value test (`!= null`) and `$empty` is not: read + // back as the pair, a sibling edit would rewrite it to `$empty` and + // move `''` / `[]` across the line — a stored filter changed by an + // edit to a DIFFERENT row, which the invariant pinned for + // {@link builderHolds} (objectui#10257) forbids. + // This inspector offers no `exists` row either, so `$exists` falls to + // the unmapped-token arm below and the filter opens in the Source tab, + // stored bytes untouched. + if (typeof v.$empty !== 'boolean') return { group: empty, representable: false }; + row = { id: `c${i}`, field, operator: v.$empty ? 'is_empty' : 'is_not_empty', value: '' }; } else if (mop === '$null') { // The inverse of the write half: `$null: false` is "is not null", so // the boolean picks the operator rather than becoming the row's value. diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/filter-builder-protocol-ids-census-9306.test.ts b/packages/app-shell/src/views/metadata-admin/inspectors/filter-builder-protocol-ids-census-9306.test.ts index b6816062b7..cb172ee7d6 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/filter-builder-protocol-ids-census-9306.test.ts +++ b/packages/app-shell/src/views/metadata-admin/inspectors/filter-builder-protocol-ids-census-9306.test.ts @@ -28,10 +28,18 @@ * A second, later exception is deliberate and is a CHANGED predicate: the * `mongo` cells for `is_empty` / `is_not_empty` (objectui#10790). They stored * `{ f: { $in: [null, ''] } }` / `{ f: { $nin: [null, ''] } }`, and a `null` - * list member is refused by every objectstack filter face, so those rows now - * store the spelling that refusal prescribes — same meaning, "no value OR the - * empty string" and its complement. "Is empty" is an `$or` entry rather than a - * field entry, and `kvToCondition` reads that entry back as the one row. + * list member is refused by every objectstack filter face, so those rows + * stored the spelling that refusal prescribes — "no value OR the empty string" + * and its complement, as an `$or` entry. + * + * A third, also a CHANGED predicate, and in BOTH columns: the same two rows + * since objectui#10813. Three builders offered 「is empty」 with three meanings + * — this `mongo` column's "no value OR `''`" on every type, the `dataset` + * column's `$exists` (has no value), and the live grid's equality to `null` — + * and all three now store the spec's ONE operator, `{ f: { $empty: true } }` / + * `{ f: { $empty: false } }`, whose meaning is the column's declared row of the + * spec's per-type table (ruling B on objectstack#20311). Both read halves read + * it back as the same row. * * - `mongo` / `readBack` — `@object-ui/fields`' sharing-rule criteria * (`condToMongo`, then `kvToCondition` on what it wrote). @@ -77,9 +85,9 @@ const CENSUS: ReadonlyArray<{ // The named exception in the `dataset` column — see the file header. { legacy: 'containsCaseInsensitive', id: 'icontains', value: 'x', mongo: { f: { $icontains: 'x' } }, readBack: 'icontains', dataset: { f: { $icontains: 'x' } }, datasetReadBack: 'icontains' }, { legacy: 'notContains', id: 'not_contains', value: 'x', mongo: { f: { $notContains: 'x' } }, readBack: 'not_contains', dataset: { f: { $notContains: 'x' } }, datasetReadBack: 'not_contains' }, - // The named exception in the `mongo` column since objectui#10790 — see the file header. - { legacy: 'isEmpty', id: 'is_empty', value: '', mongo: { $or: [{ f: { $in: [''] } }, { f: { $null: true } }] }, readBack: 'is_empty', dataset: { f: { $exists: false } }, datasetReadBack: 'is_empty' }, - { legacy: 'isNotEmpty', id: 'is_not_empty', value: '', mongo: { f: { $nin: [''], $null: false } }, readBack: 'is_not_empty', dataset: { f: { $exists: true } }, datasetReadBack: 'is_not_empty' }, + // The named exceptions in BOTH columns since objectui#10813 (the `mongo` one also objectui#10790) — see the file header. + { legacy: 'isEmpty', id: 'is_empty', value: '', mongo: { f: { $empty: true } }, readBack: 'is_empty', dataset: { f: { $empty: true } }, datasetReadBack: 'is_empty' }, + { legacy: 'isNotEmpty', id: 'is_not_empty', value: '', mongo: { f: { $empty: false } }, readBack: 'is_not_empty', dataset: { f: { $empty: false } }, datasetReadBack: 'is_not_empty' }, { legacy: 'greaterThan', id: 'greater_than', value: 5, mongo: { f: { $gt: 5 } }, readBack: 'greater_than', dataset: { f: { $gt: 5 } }, datasetReadBack: 'greater_than' }, { legacy: 'lessThan', id: 'less_than', value: 5, mongo: { f: { $lt: 5 } }, readBack: 'less_than', dataset: { f: { $lt: 5 } }, datasetReadBack: 'less_than' }, { legacy: 'greaterOrEqual', id: 'greater_than_or_equal', value: 5, mongo: { f: { $gte: 5 } }, readBack: 'greater_than_or_equal', dataset: { f: { $gte: 5 } }, datasetReadBack: 'greater_than_or_equal' }, diff --git a/packages/fields/src/widgets/FilterConditionField.tsx b/packages/fields/src/widgets/FilterConditionField.tsx index d81fa3370e..525e38a25b 100644 --- a/packages/fields/src/widgets/FilterConditionField.tsx +++ b/packages/fields/src/widgets/FilterConditionField.tsx @@ -200,29 +200,6 @@ function toArray(value: any): any[] { return value == null ? [] : [value]; } -/** - * "Is empty" as stored (objectui#10790): the field has NO value, OR its value - * is the empty string. - * - * This used to be `{ [field]: { $in: [null, ''] } }`, and `null` is not a list - * member any objectstack filter face accepts: the shared comparand-shape face - * (`assertListComparandShapes`) refuses it with `INVALID_FILTER` / 400 in every - * position (ruled 2026-08-31), so every related list, roll-up and sharing rule - * authored with this operator failed when it was evaluated. The spelling here - * is the one that refusal prescribes for "one of […] OR has no value" — - * `$or: [{ FIELD: { $in: […] } }, { FIELD: { $null: true } }]` — with the one - * non-null member the old list carried. The meaning is unchanged: `''` still - * counts as empty, which is what separates this operator from `is_null`. - * - * ONE stored entry per builder row, like every other arm: its key is `$or` - * rather than the field, so it merges into an AND group beside field keys and - * {@link kvToCondition} reads it back as one row. Two such rows collide on - * `$or` and fall to the `$and` form, as any two rows on one key already do. - */ -function isEmptyEntry(field: string): Record { - return { $or: [{ [field]: { $in: [''] } }, { [field]: { $null: true } }] }; -} - function isPlainObject(v: unknown): v is Record { return v !== null && typeof v === 'object' && !Array.isArray(v); } @@ -239,10 +216,17 @@ function soleFieldOperator(frag: unknown): [string, string, unknown] | null { } /** - * The FIELD of a stored {@link isEmptyEntry}, or `null` when `v` (the value - * under an `$or` key) is anything else — including an `$or` that means the same - * thing in another order or spelling, which reads back as the ordinary OR group - * it is rather than being folded into this row. + * The FIELD of a stored objectui#10790 "is empty" entry — + * `$or: [{ FIELD: { $in: [''] } }, { FIELD: { $null: true } }]` — or `null` + * when `v` (the value under an `$or` key) is anything else, including an `$or` + * that means the same thing in another order or spelling, which reads back as + * the ordinary OR group it is rather than being folded into this row. + * + * READ-ONLY since objectui#10813: this widget no longer WRITES that entry (see + * the `is_empty` arm of {@link condToMongo}), but a criteria saved before then + * still carries it, so it keeps opening as the same `is_empty` row and is + * written in the spec's `$empty` spelling the next time the criteria is + * edited. Reading alone rewrites nothing. */ function isEmptyEntryField(v: unknown): string | null { if (!Array.isArray(v) || v.length !== 2) return null; @@ -309,14 +293,21 @@ export function condToMongo(c: BuilderCondition, typeOf: (f: string) => string | // builder UI even though FieldOperatorsSchema accepts them (#2942). case 'starts_with': return { [field]: { $startsWith: value } }; case 'ends_with': return { [field]: { $endsWith: value } }; - // objectui#10790 — no `null` list member: see {@link isEmptyEntry}. - // "Is not empty" is its exact complement — has a value (`$null: false`, - // the refusal's own "has a value" half) AND that value is not `''` — on one - // field key, so it reads back through the ordinary two-operator arm. - case 'is_empty': return isEmptyEntry(field); - case 'is_not_empty': return { [field]: { $nin: [''], $null: false } }; + // objectui#10813 — the spec's ONE 「is empty」 operator, `$empty` (ruling B + // on objectstack#20311; admitted to `FILTER_OPERATORS` and made the lowering + // of the view operators `is_empty` / `is_not_empty` by objectstack#20446). + // What counts as empty is the field's DECLARED row of the spec's per-type + // table — `''` on a text-like field, `[]` on a multi-value one, null alone + // on every other type — and every evaluator expands it itself + // (`expandEmptyOperator`). ⛔ No local copy of that table: this arm used to + // spell its own meaning, "no value OR `''`" on EVERY type (objectui#10790's + // `$or` of `$in: ['']` and `$null`), which reached a number or date column + // as `IN ('')` and a JSON-stored multi-value column as an `$in` the SQL + // driver refuses. `false` is the exact complement. + case 'is_empty': return { [field]: { $empty: true } }; + case 'is_not_empty': return { [field]: { $empty: false } }; // Null / existence spec operators. Distinct from is_empty/is_not_empty, - // which also treat '' as empty. + // whose `$empty` also counts `''` (text-like) and `[]` (multi-value). case 'is_null': return { [field]: { $null: true } }; case 'is_not_null': return { [field]: { $null: false } }; case 'exists': return { [field]: { $exists: true } }; @@ -427,8 +418,9 @@ function criteriaKey(mongo: any): string { */ export function kvToCondition(field: string, v: any, idx: number): BuilderCondition | null { // A `$` key is a logical operator, not a field. The one such entry a builder - // row stores is "is empty"'s `$or` (objectui#10790, {@link isEmptyEntry}); - // every other one is a criteria the builder cannot draw as a row. + // row ever stored is objectui#10790's "is empty" `$or` ({@link isEmptyEntryField}, + // read-only since objectui#10813); every other one is a criteria the builder + // cannot draw as a row. if (field.startsWith('$')) { const target = field === '$or' ? isEmptyEntryField(v) : null; return target === null @@ -463,11 +455,23 @@ export function kvToCondition(field: string, v: any, idx: number): BuilderCondit case '$endsWith': return { id, field, operator: 'ends_with', value: val }; case '$null': return { id, field, operator: val === false ? 'is_not_null' : 'is_null', value: '' }; case '$exists': return { id, field, operator: val === false ? 'notExists' : 'exists', value: '' }; + // objectui#10813 — what `condToMongo` writes for the empty pair. Only a + // BOOLEAN flag reads as a row: the spec declares `$empty: z.boolean()` + // and every evaluator refuses any other flag, so a `{ $empty: 'yes' }` + // stays the raw criteria it is rather than opening as a row whose next + // save would quietly turn it into a predicate that runs. + case '$empty': + return val === true + ? { id, field, operator: 'is_empty', value: '' } + : val === false + ? { id, field, operator: 'is_not_empty', value: '' } + : null; // `[null, '']` is the pre-objectui#10790 spelling of "is empty" / // "is not empty", which every objectstack filter face refuses. Rules // saved before the fix still carry it, so it keeps opening as the same - // row — and the builder writes the accepted spelling the next time the - // criteria is edited. Reading alone rewrites nothing. + // row — and the builder writes the accepted spelling (`$empty`, since + // objectui#10813) the next time the criteria is edited. Reading alone + // rewrites nothing. case '$in': return arraysEqual(val, [null, '']) ? { id, field, operator: 'is_empty', value: '' } @@ -482,7 +486,9 @@ export function kvToCondition(field: string, v: any, idx: number): BuilderCondit if (opKeys.length === 2 && '$gte' in v && '$lte' in v) { return { id, field, operator: 'between', value: [v.$gte, v.$lte] }; } - // "Is not empty" as `condToMongo` writes it since objectui#10790. + // "Is not empty" as `condToMongo` wrote it from objectui#10790 until + // objectui#10813 moved the pair onto `$empty`: read-only, so a criteria saved + // in between keeps opening as the same row (see {@link isEmptyEntryField}). if (opKeys.length === 2 && '$nin' in v && '$null' in v && arraysEqual(v.$nin, ['']) && v.$null === false) { return { id, field, operator: 'is_not_empty', value: '' }; } @@ -495,8 +501,9 @@ function mongoToFilterGroup(mongo: any): BuilderGroup | null { if (typeof mongo !== 'object' || Array.isArray(mongo)) return null; const entries = Object.entries(mongo); if (entries.length === 0) return { ...EMPTY_GROUP, conditions: [] }; - // A lone "is empty" row stores a lone `$or` (objectui#10790); it is ONE row, - // not an OR group of its two halves. Read it as the flat entry it is, below. + // A lone "is empty" row stored a lone `$or` from objectui#10790 until + // objectui#10813; it is ONE row, not an OR group of its two halves. Read it + // as the flat entry it is, below. if (entries.length === 1 && (mongo.$or || mongo.$and) && !kvToCondition(entries[0][0], entries[0][1], 0)) { const logic: 'and' | 'or' = mongo.$or ? 'or' : 'and'; const arr = mongo.$or || mongo.$and; @@ -506,8 +513,8 @@ function mongoToFilterGroup(mongo: any): BuilderGroup | null { const frag = arr[i]; if (!frag || typeof frag !== 'object' || Object.keys(frag).length !== 1) return null; const field = Object.keys(frag)[0]; - // A `$` key reads only as "is empty"'s entry; `kvToCondition` answers - // `null` for every other one. + // A `$` key reads only as the legacy "is empty" entry; `kvToCondition` + // answers `null` for every other one. const c = kvToCondition(field, frag[field], i); if (!c) return null; conditions.push(c); @@ -517,9 +524,9 @@ function mongoToFilterGroup(mongo: any): BuilderGroup | null { const conditions: BuilderCondition[] = []; let i = 0; for (const [field, v] of entries) { - // Mixed logical + field → raw, except "is empty"'s `$or` entry, which an - // AND group merges beside field keys (`kvToCondition` answers `null` for - // every other `$` key). + // Mixed logical + field → raw, except the legacy "is empty" `$or` entry, + // which an AND group merged beside field keys (`kvToCondition` answers + // `null` for every other `$` key). const c = kvToCondition(field, v, i++); if (!c) return null; conditions.push(c); diff --git a/packages/fields/src/widgets/__tests__/FilterConditionField.emptyOperators-10790.test.tsx b/packages/fields/src/widgets/__tests__/FilterConditionField.emptyOperators-10790.test.tsx index fc56699fc9..e074bbe883 100644 --- a/packages/fields/src/widgets/__tests__/FilterConditionField.emptyOperators-10790.test.tsx +++ b/packages/fields/src/widgets/__tests__/FilterConditionField.emptyOperators-10790.test.tsx @@ -7,51 +7,63 @@ */ /** - * "Is empty" / "Is not empty" write a criteria every objectstack filter face - * accepts (objectui#10790). + * "Is empty" / "Is not empty" write the spec's ONE 「is empty」 operator, + * `$empty` (objectui#10813), and every shape the widget wrote before keeps + * opening as the same row (objectui#10790). * - * The widget used to store them as `{ FIELD: { $in: [null, ''] } }` and - * `{ FIELD: { $nin: [null, ''] } }`. A `null` list member is refused by the - * shared comparand-shape face (`assertListComparandShapes`, `INVALID_FILTER` / - * 400, ruled 2026-08-31) in every position, so a related list, roll-up or - * sharing rule authored with either operator failed when it was evaluated. The - * stored shapes are now the ones that refusal prescribes: + * The stored shapes, in order: * - * - "Is empty": `{ $or: [{ FIELD: { $in: [''] } }, { FIELD: { $null: true } }] }` - * - "Is not empty": `{ FIELD: { $nin: [''], $null: false } }` — its complement. + * - before objectui#10790: `{ FIELD: { $in: [null, ''] } }` / + * `{ FIELD: { $nin: [null, ''] } }`. A `null` list member is refused by the + * shared comparand-shape face (`assertListComparandShapes`, `INVALID_FILTER` + * / 400), so a rule authored with either failed when evaluated; + * - objectui#10790 to objectui#10813: `{ $or: [{ FIELD: { $in: [''] } }, + * { FIELD: { $null: true } }] }` / `{ FIELD: { $nin: [''], $null: false } }` + * — "no value OR `''`" on EVERY field type, one of the three meanings of + * 「is empty」 objectui#10813 converged; + * - since objectui#10813: `{ FIELD: { $empty: true } }` / `{ FIELD: { $empty: + * false } }`. What counts as empty is the field's DECLARED row of the spec's + * per-type table (ruling B on objectstack#20311), expanded by every + * evaluator (`expandEmptyOperator`); the widget keeps no copy of it, which + * is why the WRITER block pins the SAME token on every column type. * * What is pinned is the DOCUMENT the widget hands `onChange` — the string a * `relatedListFilter`, a roll-up filter or a `criteria_json` ends up holding — * judged by the objectstack faces themselves, imported from the installed - * `@objectstack/spec`: the query face (`assertListComparandShapes`) and the - * save door (`FilterConditionSchema`). The exact bytes are pinned too, so a - * shape that merely passes the faces but means something else is still red. + * `@objectstack/spec`: the query face (`assertListComparandShapes`), the save + * door (`FilterConditionSchema`), and `FILTER_OPERATORS`, the list every + * executor derives its accepted operators from. The exact bytes are pinned + * too, so a shape that merely passes the faces but means something else is + * still red. * * Four blocks: * * 1. WRITER — each operator on each field type that offers it (text, number, - * date, select, lookup: `operatorsForFieldType` in `@object-ui/components`), - * driven through the REAL dropdowns. The `equals` rows are the control: - * the same harness and the same faces, green before and after. - * 2. READER — the old shape AND the new shape each open as the same single - * builder row, and opening one emits nothing (no rewrite on read alone). - * 3. RE-SAVE — an old rule is written in the new shape the next time any row - * of it is edited. - * 4. GROUPS — the new "is empty" entry is a `$or` key, so it is pinned in an - * AND group (merged beside field keys), beside a second "is empty" (the - * `$and` form) and inside an OR group, each read back as the rows written. + * date, select, lookup, and multiselect, whose JSON column the SQL driver + * refused the old `$in` on), driven through the REAL dropdowns. The + * `equals` rows are the control: the same harness and the same faces, + * green before and after. + * 2. READER — the two legacy shapes and the new one each open as the same + * single builder row, and opening one emits nothing (no rewrite on read + * alone). + * 3. RE-SAVE — a legacy rule is written as `$empty` the next time any row of + * it is edited. That is the stored-rule reading objectui#10813's + * changeset names: re-saving moves a rule to the declared-type meaning. + * 4. GROUPS — the new entry is a FIELD key like every other row, so two + * "is empty" rows merge into one AND object instead of the `$and` form the + * legacy `$or` entry needed; a legacy group still reads back as its rows. * - * DIRECTION, predicted before running: on the base tree block 1's two operator - * rows are red (the old bytes, and the face's refusal), block 2's new-shape rows - * are red ("is empty" opens as two OR rows, "is not empty" as raw JSON), block 3 - * and block 4 are red, and block 2's old-shape rows and every `equals` control - * are green. + * DIRECTION, predicted before running on the base tree (the objectui#10790 + * writer): block 1's operator rows are red (the legacy bytes), the `equals` + * controls are green; block 2's legacy rows are green and its `$empty` rows red + * (`kvToCondition` had no `$empty` arm, so the widget fell to raw JSON); block + * 3 is red; block 4's legacy-group rows are green and its new-shape rows red. */ import { describe, it, expect, vi } from 'vitest'; import React from 'react'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import '@testing-library/jest-dom'; -import { assertListComparandShapes, FilterConditionSchema } from '@objectstack/spec/data'; +import { assertListComparandShapes, FilterConditionSchema, FILTER_OPERATORS } from '@objectstack/spec/data'; import { FilterConditionField } from '../FilterConditionField'; /** @@ -74,6 +86,15 @@ const OBJECT_SCHEMA = { ], }, { name: 'account', label: 'Account', type: 'lookup', reference: 'account' }, + { + name: 'tags', + label: 'Tags', + type: 'multiselect', + options: [ + { value: 'a', label: 'A' }, + { value: 'b', label: 'B' }, + ], + }, ], }; @@ -142,8 +163,12 @@ function expectAcceptedByTheFaces(stored: string) { expect(parsed.success, JSON.stringify(parsed.success ? null : parsed.error.issues)).toBe(true); } -const isEmpty = (f: string) => ({ $or: [{ [f]: { $in: [''] } }, { [f]: { $null: true } }] }); -const isNotEmpty = (f: string) => ({ [f]: { $nin: [''], $null: false } }); +/** The pair as the widget writes it since objectui#10813. */ +const isEmpty = (f: string) => ({ [f]: { $empty: true } }); +const isNotEmpty = (f: string) => ({ [f]: { $empty: false } }); +/** objectui#10790's shapes, written until objectui#10813 — still READ. */ +const legacyIsEmpty = (f: string) => ({ $or: [{ [f]: { $in: [''] } }, { [f]: { $null: true } }] }); +const legacyIsNotEmpty = (f: string) => ({ [f]: { $nin: [''], $null: false } }); const TYPES: ReadonlyArray<{ type: string; field: string; label: string }> = [ { type: 'text', field: 'name', label: 'Name' }, @@ -151,6 +176,7 @@ const TYPES: ReadonlyArray<{ type: string; field: string; label: string }> = [ { type: 'date', field: 'due_on', label: 'Due on' }, { type: 'select', field: 'stage', label: 'Stage' }, { type: 'lookup', field: 'account', label: 'Account' }, + { type: 'multiselect', field: 'tags', label: 'Tags' }, ]; /** A fresh row on `label`'s column, still on the seed operator (`equals`). */ @@ -161,11 +187,19 @@ async function freshRowOn(label: string) { return utils; } -describe('WRITER — each operator on each offered field type writes the accepted shape (objectui#10790)', () => { +describe('WRITER — each operator on each offered field type writes `$empty` (objectui#10813)', () => { + it('the operator it writes is one every executor accepts', () => { + // `FILTER_OPERATORS` is the list the executors derive acceptance from; the + // operator joined it in `@objectstack/spec` 17.6.0 (objectstack#20446). + expect(FILTER_OPERATORS as readonly string[]).toContain('$empty'); + }); + it.each(TYPES)('"Is empty" on a $type column', async ({ field, label }) => { const { onChange } = await freshRowOn(label); await pickFrom(1, 'Is empty'); const stored = lastEmitted(onChange); + // The SAME token on every column type: the per-type meaning is the spec's + // expansion, not this widget's. expect(stored).toBe(JSON.stringify(isEmpty(field))); expectAcceptedByTheFaces(stored); }); @@ -201,12 +235,14 @@ async function expectOneRow(fieldLabel: string, operatorLabel: string) { expect(screen.queryByPlaceholderText(/"type": "customer"/)).toBeNull(); } -describe('READER — the old and the new shape open as the same builder row (objectui#10790)', () => { +describe('READER — every shape the pair was ever stored in opens as the same builder row (objectui#10790, objectui#10813)', () => { const CASES: ReadonlyArray<{ name: string; stored: unknown; operator: string }> = [ - { name: 'old "is empty" ($in: [null, \'\'])', stored: { name: { $in: [null, ''] } }, operator: 'Is empty' }, - { name: 'old "is not empty" ($nin: [null, \'\'])', stored: { name: { $nin: [null, ''] } }, operator: 'Is not empty' }, - { name: 'new "is empty"', stored: isEmpty('name'), operator: 'Is empty' }, - { name: 'new "is not empty"', stored: isNotEmpty('name'), operator: 'Is not empty' }, + { name: 'pre-objectui#10790 "is empty" ($in: [null, \'\'])', stored: { name: { $in: [null, ''] } }, operator: 'Is empty' }, + { name: 'pre-objectui#10790 "is not empty" ($nin: [null, \'\'])', stored: { name: { $nin: [null, ''] } }, operator: 'Is not empty' }, + { name: 'objectui#10790 "is empty" ($or)', stored: legacyIsEmpty('name'), operator: 'Is empty' }, + { name: 'objectui#10790 "is not empty" ($nin + $null)', stored: legacyIsNotEmpty('name'), operator: 'Is not empty' }, + { name: '"is empty" ($empty: true)', stored: isEmpty('name'), operator: 'Is empty' }, + { name: '"is not empty" ($empty: false)', stored: isNotEmpty('name'), operator: 'Is not empty' }, ]; it.each(CASES)('$name opens as one "$operator" row and is not rewritten', async ({ stored, operator }) => { @@ -218,11 +254,13 @@ describe('READER — the old and the new shape open as the same builder row (obj }); }); -describe('RE-SAVE — an old rule is written in the new shape once it is edited (objectui#10790)', () => { +describe('RE-SAVE — a legacy rule is written as `$empty` once it is edited (objectui#10813)', () => { it.each([ - { name: '"is empty"', old: { name: { $in: [null, ''] } }, fresh: isEmpty('name') }, - { name: '"is not empty"', old: { name: { $nin: [null, ''] } }, fresh: isNotEmpty('name') }, - ])('an old $name row is rewritten when ANOTHER row is edited', async ({ old, fresh }) => { + { name: 'pre-objectui#10790 "is empty"', old: { name: { $in: [null, ''] } }, fresh: isEmpty('name') }, + { name: 'pre-objectui#10790 "is not empty"', old: { name: { $nin: [null, ''] } }, fresh: isNotEmpty('name') }, + { name: 'objectui#10790 "is empty"', old: legacyIsEmpty('name'), fresh: isEmpty('name') }, + { name: 'objectui#10790 "is not empty"', old: legacyIsNotEmpty('name'), fresh: isNotEmpty('name') }, + ])('a $name row is rewritten when ANOTHER row is edited', async ({ old, fresh }) => { const { onChange } = renderWidget(JSON.stringify({ ...old, amount: 5 })); const box = await screen.findByDisplayValue('5'); expect(onChange).not.toHaveBeenCalled(); @@ -233,7 +271,7 @@ describe('RE-SAVE — an old rule is written in the new shape once it is edited }); }); -describe('GROUPS — the "is empty" entry round-trips inside a group (objectui#10790)', () => { +describe('GROUPS — the "is empty" row round-trips inside a group (objectui#10813)', () => { it('beside a field key (AND, merged): written, accepted, and read back as the two rows', async () => { const { onChange } = await freshRowOn('Name'); await pickFrom(1, 'Is empty'); @@ -248,8 +286,22 @@ describe('GROUPS — the "is empty" entry round-trips inside a group (objectui#1 expect(triggers.map((t) => t.textContent)).toEqual(['Name', 'Is empty', 'Amount', 'Equals']); }); - it('beside a second "is empty" (the $and form): read back as two rows', async () => { - const stored = { $and: [isEmpty('name'), isEmpty('amount')] }; + it('two "is empty" rows are two field keys of one AND object — no `$and` form needed', async () => { + const { onChange } = await freshRowOn('Name'); + await pickFrom(1, 'Is empty'); + await addRow(); + await pickFrom(2, 'Amount'); + await pickFrom(3, 'Is empty'); + const stored = lastEmitted(onChange); + expect(stored).toBe(JSON.stringify({ ...isEmpty('name'), ...isEmpty('amount') })); + expectAcceptedByTheFaces(stored); + expect(screen.getAllByRole('combobox').map((t) => t.textContent)).toEqual([ + 'Name', 'Is empty', 'Amount', 'Is empty', + ]); + }); + + it('a legacy pair of "is empty" entries (the `$and` form) still reads back as two rows', async () => { + const stored = { $and: [legacyIsEmpty('name'), legacyIsEmpty('amount')] }; expectAcceptedByTheFaces(JSON.stringify(stored)); const { onChange } = renderWidget(JSON.stringify(stored)); await waitFor(() => { @@ -271,8 +323,17 @@ describe('GROUPS — the "is empty" entry round-trips inside a group (objectui#1 expect(lastEmitted(onChange)).toBe(JSON.stringify({ $or: [{ amount: 6 }, isEmpty('name')] })); }); - it('CONTROL: an OR of the two halves in the OTHER order stays the OR group it is', async () => { - // Only the exact entry the builder writes is folded into one row; the same + it('a legacy entry inside an OR group is re-written as `$empty` once the group is edited', async () => { + const stored = { $or: [{ amount: 5 }, legacyIsEmpty('name')] }; + const { onChange } = renderWidget(JSON.stringify(stored)); + const box = await screen.findByDisplayValue('5'); + expect(onChange).not.toHaveBeenCalled(); + fireEvent.change(box, { target: { value: '6' } }); + expect(lastEmitted(onChange)).toBe(JSON.stringify({ $or: [{ amount: 6 }, isEmpty('name')] })); + }); + + it('CONTROL: an OR of the two legacy halves in the OTHER order stays the OR group it is', async () => { + // Only the exact entry the builder wrote is folded into one row; the same // predicate spelled another way is not guessed at. // A select column, whose bucket offers both `is_null` and `in`. const stored = { $or: [{ stage: { $null: true } }, { stage: { $in: [''] } }] }; @@ -283,4 +344,18 @@ describe('GROUPS — the "is empty" entry round-trips inside a group (objectui#1 ]); }); }); + + it('CONTROL: a non-boolean `$empty` flag is not opened as a row — the raw criteria stays as written', async () => { + // Every evaluator refuses it (`$empty` is declared `z.boolean()`); opening + // it as an "Is empty" row would let the next save turn it into a runnable + // predicate the author never wrote. + const stored = { name: { $empty: 'yes' } }; + const { onChange } = renderWidget(JSON.stringify(stored)); + // The raw-JSON editor, holding the stored bytes — the widget's fallback for + // a criteria it cannot draw. + const raw = await screen.findByPlaceholderText(/"type": "customer"/); + expect(raw).toHaveValue(JSON.stringify(stored)); + expect(screen.queryAllByRole('combobox')).toHaveLength(0); + expect(onChange).not.toHaveBeenCalled(); + }); }); diff --git a/packages/fields/src/widgets/__tests__/FilterConditionField.operators.test.ts b/packages/fields/src/widgets/__tests__/FilterConditionField.operators.test.ts index e7ef01cc84..7c5bdfec5b 100644 --- a/packages/fields/src/widgets/__tests__/FilterConditionField.operators.test.ts +++ b/packages/fields/src/widgets/__tests__/FilterConditionField.operators.test.ts @@ -90,30 +90,27 @@ const noTypes = () => undefined; * not a run. */ /* - * `$empty` arrived with `@objectstack/spec` 17.5.0 (objectui#11073) as a STAGED - * operator: declared, absent from `FILTER_OPERATORS`, refused by every query - * executor, so a dropdown row emitting it would have authored a filter nothing - * could run. The entry was to leave this set the day `FILTER_OPERATORS` admitted - * it, and the expiry row below reddened on `@objectstack/spec` 17.6.0 - * (objectstack#20446), which admitted it and lowers `is_empty` / `is_not_empty` - * to it. + * `$empty` was here from `@objectstack/spec` 17.5.0 (objectui#11073) until + * objectui#10813, and its two reasons expired in turn: * - * ⚠️ It has NOT left, and the reason changed rather than expired. The staging - * reason is gone; what remains is which builder row should author `$empty`. - * This widget's `is_empty` writes "no value OR `''`" on every field type - * (objectui#10790), one of the three meanings of 「is empty」 objectui#10813 - * reconciles. Moving that row onto `$empty` changes what a stored sharing rule - * or roll-up filter selects, so it is that card's decision, not the 17.6.0 - * bump's (objectui#11094 executed the operator and left the builders alone). - * The entry now leaves the day a builder operator emits `$empty`: the second - * row below the ratchet reddens then. + * 1. STAGED — declared, absent from `FILTER_OPERATORS`, refused by every + * query executor, so a row emitting it would have authored a filter + * nothing could run. Expired on `@objectstack/spec` 17.6.0 + * (objectstack#20446), which admitted it and lowers `is_empty` / + * `is_not_empty` to it; the first expiry row below holds that. + * 2. No builder row authored it — this widget's `is_empty` wrote "no value OR + * `''`" on every field type (objectui#10790), one of the three meanings of + * 「is empty」. Expired with objectui#10813, which moved the pair onto + * `$empty`; the second expiry row below holds that, and the sweep above it + * now holds the token to the spec like every other. */ -const KNOWN_UNREACHABLE = new Set(['$eq', '$between', '$like', '$ilike', '$empty']); +const KNOWN_UNREACHABLE = new Set(['$eq', '$between', '$like', '$ilike']); /** * Pull the operator keys out of a `{ field: { $op: v } }` fragment — descending - * into a `$or` / `$and` entry, which is how "is empty" is stored since - * objectui#10790, so its inner operators are judged like every other row's. + * into a `$or` / `$and` entry, so a row stored as a combinator (as "is empty" + * was from objectui#10790 until objectui#10813) has its inner operators judged + * like every other row's. */ function operatorsOf(frag: Record | null): string[] { if (!frag) return []; @@ -197,12 +194,12 @@ describe('every spec field operator is reachable from the builder (#2942)', () = expect(FILTER_OPERATORS as readonly string[]).toContain('$null'); }); - it('the `$empty` exclusion now expires when a builder operator emits it (objectui#10813)', () => { - // The entry's remaining reason, held mechanically: no drawable builder id - // writes `$empty` today. The day objectui#10813 moves a row onto it, this - // reddens and `$empty` leaves KNOWN_UNREACHABLE, so the sweep above starts - // holding that row to the spec like every other. Derived from the same - // drawable vocabulary the sweep feeds `condToMongo`. + it('the `$empty` exclusion expired: a builder operator emits it (objectui#10813)', () => { + // The objectui#11094 expiry row, flipped. It read "no drawable builder id + // writes `$empty`" and reddened when objectui#10813 moved the empty pair + // onto it, which is why `$empty` left KNOWN_UNREACHABLE. Derived from the + // same drawable vocabulary the sweep feeds `condToMongo`, and the pair is + // named so the row says WHICH operators author it. const emitted = new Set(); for (const operator of FILTER_BUILDER_OPERATORS) { const value = operator === 'in' || operator === 'not_in' ? ['a'] : operator === 'between' ? [1, 5] : 'a'; @@ -211,7 +208,13 @@ describe('every spec field operator is reachable from the builder (#2942)', () = } // Lit control: the sweep really reads emitted operators. expect(emitted.has('$null')).toBe(true); - expect(emitted.has('$empty')).toBe(false); + expect(emitted.has('$empty')).toBe(true); + expect(FILTER_BUILDER_OPERATORS).toContain('is_empty'); + expect(FILTER_BUILDER_OPERATORS).toContain('is_not_empty'); + expect(operatorsOf(condToMongo({ id: 'c1', field: 'f', operator: 'is_empty', value: '' } as any, noTypes))) + .toEqual(['$empty']); + expect(operatorsOf(condToMongo({ id: 'c1', field: 'f', operator: 'is_not_empty', value: '' } as any, noTypes))) + .toEqual(['$empty']); }); it('every KNOWN_UNREACHABLE token is still a spec operator (the exclusion ratchet)', () => { @@ -302,6 +305,10 @@ describe('kvToCondition round-trips what condToMongo writes', () => { ['is_not_null', ''], ['exists', ''], ['notExists', ''], + // objectui#10813: the pair is a field key again (`$empty`), so it rides the + // same per-field round trip as every other row. + ['is_empty', ''], + ['is_not_empty', ''], ]; it.each(cases)('%s survives the round trip', (operator, value) => { @@ -323,6 +330,16 @@ describe('kvToCondition round-trips what condToMongo writes', () => { it('rejects an operator it cannot represent rather than guessing', () => { expect(kvToCondition('name', { $nope: 'x' }, 0)).toBeNull(); }); + + it('reads `$empty` only with a boolean flag — any other flag is a criteria it cannot represent', () => { + // `$empty` is declared `z.boolean()` and every evaluator refuses another + // flag; opened as a row, the next save would make it runnable. + expect(kvToCondition('name', { $empty: true }, 0)).toMatchObject({ operator: 'is_empty' }); + expect(kvToCondition('name', { $empty: false }, 0)).toMatchObject({ operator: 'is_not_empty' }); + for (const flag of ['yes', 1, null, 'true']) { + expect(kvToCondition('name', { $empty: flag }, 0), JSON.stringify(flag)).toBeNull(); + } + }); }); /** @@ -379,12 +396,12 @@ describe('objectui#8748 — an unfinished text row is dropped, not emitted', () .toEqual({ name: { $exists: true } }); expect(condToMongo({ id: 'c3', field: 'name', operator: 'notExists', value: '' } as any, noTypes)) .toEqual({ name: { $exists: false } }); - // objectui#10790: no `null` list member — the shapes the objectstack - // faces accept (`FilterConditionField.emptyOperators-10790.test.tsx`). + // objectui#10813: the spec's `$empty` (pinned per column type in + // `FilterConditionField.emptyOperators-10790.test.tsx`). expect(condToMongo({ id: 'c4', field: 'name', operator: 'is_empty', value: '' } as any, noTypes)) - .toEqual({ $or: [{ name: { $in: [''] } }, { name: { $null: true } }] }); + .toEqual({ name: { $empty: true } }); expect(condToMongo({ id: 'c5', field: 'name', operator: 'is_not_empty', value: '' } as any, noTypes)) - .toEqual({ name: { $nin: [''], $null: false } }); + .toEqual({ name: { $empty: false } }); }); it('equals with an empty comparand still emits — it is a real predicate', () => { diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index 5cb221ca85..5295667cef 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -454,6 +454,15 @@ export function mapOperator(op: string) { case 'between': return 'between'; case 'isnull': return 'isnull'; case 'isnotnull': return 'isnotnull'; + // objectui#10813 — the empty pair, which the spec lowers to its ONE + // 「is empty」 operator, `$empty` (objectstack#20446), expanded by the + // column's declared type on the server. It used to be answered by two arms + // in `convertFilterGroupToAST` as an equality to `null` — a null-only test + // that never counted `''` or `[]`, while the SAME rule saved into the view + // (`foldFilterGroupToSpecRules` persists `is_empty`) ran as `$empty`: one + // panel, two record sets, depending on whether the view had been saved. + case 'isempty': return 'isempty'; + case 'isnotempty': return 'isnotempty'; default: return op; } } @@ -729,14 +738,16 @@ export function convertFilterGroupToAST(group: FilterGroup): any[] { return isFilterValueComplete(c.operator, c.value); }) .map(c => { - // Folded, not compared raw (objectui#9359). These two arms resolve to a - // null comparison BEFORE `mapOperator` is consulted, so leaving them on - // literal camelCase ids would have made the repair below reach `is_null` - // and not `is_empty` — trading one spelling-dependent answer for another, - // which is the defect this card is about rather than a fix for it. - const canonicalOperator = String(normalizeFilterOperator(c.operator)); - if (canonicalOperator === 'is_empty') return [c.field, '=', null]; - if (canonicalOperator === 'is_not_empty') return [c.field, '!=', null]; + // objectui#10813 — `is_empty` / `is_not_empty` no longer have arms of + // their own here. They were answered as `[field, '=' | '!=', null]`, a + // null test; they now take the value-less path below like `is_null`, and + // `mapOperator` emits the spec's `isempty` / `isnotempty`, which the + // spec lowers to `$empty` — the same operator a saved view's `is_empty` + // rule already ran as. The fold objectui#9359 added for those two arms + // lives on in `isValuelessFilterOperator` and in `mapOperator`'s + // case- and underscore-insensitive match, so every spelling of the pair + // still lands on one node. + // // A value-less row's third slot is emitted as `null` rather than as // whatever `c.value` still holds: the operator dropdown PRESERVES the // previous operator's value, so an `Is null` row can carry a leftover @@ -744,7 +755,9 @@ export function convertFilterGroupToAST(group: FilterGroup): any[] { // (`convertComparison`, `@objectstack/spec/data`) ignores the third slot // for `isnull`/`isnotnull` — it emits `{ [field]: { $null: true|false } }` // — so `null` is inert on the wire and keeps the emission a function of - // the operator alone. Same shape the `isEmpty` arms above already use. + // the operator alone. The spec discards the slot for `isempty` / + // `isnotempty` too (`parseFilterAST(['x', 'isempty', null])` is + // `{ x: { $empty: true } }`). // The same fold as the short-circuit above (objectui#9359): a row kept // BECAUSE it is value-less must also be EMITTED as value-less, or the // canonical spelling would carry its stale `value` into the third slot diff --git a/packages/plugin-list/src/__tests__/convertFilterGroupToAST.canonicalSpelling.test.ts b/packages/plugin-list/src/__tests__/convertFilterGroupToAST.canonicalSpelling.test.ts index 4a31548eda..e9a7883326 100644 --- a/packages/plugin-list/src/__tests__/convertFilterGroupToAST.canonicalSpelling.test.ts +++ b/packages/plugin-list/src/__tests__/convertFilterGroupToAST.canonicalSpelling.test.ts @@ -131,14 +131,14 @@ const ROWS: ReadonlyArray<{ { operator: 'is_null', emitted: ['title', 'isnull', null], dialect: 'dropdown' }, { operator: 'isNotNull', emitted: ['title', 'isnotnull', null], dialect: 'deprecated' }, { operator: 'is_not_null', emitted: ['title', 'isnotnull', null], dialect: 'dropdown' }, - // `isEmpty` / `isNotEmpty` are resolved to a null comparison BEFORE - // `mapOperator` is consulted, so their canonical twins must land on the same - // arm — otherwise the repair would trade one spelling-dependent answer for - // another, which is the defect this card is about. - { operator: 'isEmpty', emitted: ['title', '=', null], dialect: 'deprecated' }, - { operator: 'is_empty', emitted: ['title', '=', null], dialect: 'dropdown' }, - { operator: 'isNotEmpty', emitted: ['title', '!=', null], dialect: 'deprecated' }, - { operator: 'is_not_empty', emitted: ['title', '!=', null], dialect: 'dropdown' }, + // The empty pair, on the spec's `isempty` / `isnotempty` since + // objectui#10813 (lowered to `$empty`). It was resolved to a null comparison + // (`'=' | '!=', null`) before `mapOperator` was consulted; both spellings + // must still land on ONE node, now through `mapOperator`'s fold. + { operator: 'isEmpty', emitted: ['title', 'isempty', null], dialect: 'deprecated' }, + { operator: 'is_empty', emitted: ['title', 'isempty', null], dialect: 'dropdown' }, + { operator: 'isNotEmpty', emitted: ['title', 'isnotempty', null], dialect: 'deprecated' }, + { operator: 'is_not_empty', emitted: ['title', 'isnotempty', null], dialect: 'dropdown' }, // No canonical twin exists for these two. { operator: 'exists', emitted: ['title', 'exists', null], dialect: 'dropdown' }, { operator: 'notExists', emitted: ['title', 'notExists', null], dialect: 'dropdown' }, diff --git a/packages/plugin-list/src/__tests__/convertFilterGroupToAST.test.ts b/packages/plugin-list/src/__tests__/convertFilterGroupToAST.test.ts index eb776ca0dc..316a739aaa 100644 --- a/packages/plugin-list/src/__tests__/convertFilterGroupToAST.test.ts +++ b/packages/plugin-list/src/__tests__/convertFilterGroupToAST.test.ts @@ -37,7 +37,9 @@ describe('convertFilterGroupToAST', () => { // `isEmpty` is a deprecated stored spelling, not a builder id: since // objectui#9306 the row type says so, hence the `unknown` hop. } as unknown as FilterGroup; - expect(convertFilterGroupToAST(group)).toEqual(['x', '=', null]); + // The spec's `isempty`, lowered to `$empty` (objectui#10813) — it was an + // equality to `null` before, a null-only test. + expect(convertFilterGroupToAST(group)).toEqual(['x', 'isempty', null]); }); it('keeps a fresh `Is null` row — no value is that row’s finished state', () => { @@ -94,10 +96,11 @@ describe('convertFilterGroupToAST — every value-less operator emits a real nod // Keyed by the builder's own ids, which are the protocol's canonical // spellings since objectui#9306 (camelCase when this table was written). const EMITTED: Record = { - // Resolved to a null comparison before `mapOperator` is consulted — these - // two already worked, and are pinned so the fix cannot regress them. - is_empty: ['f', '=', null], - is_not_empty: ['f', '!=', null], + // The spec's empty pair, lowered to `$empty` (objectui#10813). They were + // resolved to a null comparison (`'=' | '!=', null`) before `mapOperator` + // was consulted, a null-only test the spec's `is_empty` no longer means. + is_empty: ['f', 'isempty', null], + is_not_empty: ['f', 'isnotempty', null], // The defect. `mapOperator` has had these rows all along and both spellings // are members of `VALID_AST_OPERATORS`; the row simply never reached it. is_null: ['f', 'isnull', null], diff --git a/packages/plugin-list/src/__tests__/filter-operator-ast-parity.test.ts b/packages/plugin-list/src/__tests__/filter-operator-ast-parity.test.ts index 53b68dedfe..b5ae299a22 100644 --- a/packages/plugin-list/src/__tests__/filter-operator-ast-parity.test.ts +++ b/packages/plugin-list/src/__tests__/filter-operator-ast-parity.test.ts @@ -62,7 +62,7 @@ * gives this file its teeth. */ import { describe, it, expect } from 'vitest'; -import { VALID_AST_OPERATORS, isFilterAST } from '@objectstack/spec/data'; +import { VALID_AST_OPERATORS, isFilterAST, parseFilterAST } from '@objectstack/spec/data'; import { VIEW_FILTER_OPERATORS, VIEW_FILTER_OPERATOR_ALIASES } from '@objectstack/spec/ui'; import { mapOperator, normalizeFilterCondition } from '../ListView'; @@ -96,42 +96,16 @@ const EXPECTED_AST_TARGET: Record = { before: '<', // case 'before' ─┬ the pair that regressed after: '>', // case 'after' ─┘ between: 'between', // case 'between' - - // The only two rows with no arm of their own: they fall through to - // `default: return op`, so the expected target IS the view spelling. Pinned - // as identity rather than omitted, so that a future branch claiming to - // "handle" either of them has to come and say so here. - // - // Why identity is right for these two and was wrong for `before`/`after`: - // the FilterBuilder path never reaches `mapOperator` with them at all — - // `convertFilterGroupToAST` rewrites its own camelCase spellings - // (`isEmpty` / `isNotEmpty`) to `[field, '=' | '!=', null]` first — and the - // canonical snake_case spellings are themselves accepted by the AST gate, so - // passing them through unchanged is not a silent drop. That second half is a - // fact about the AST vocabulary, i.e. exactly the kind of fact that moved - // under this file before, so it is asserted rather than assumed: see - // 'the identity rows are ones the AST gate accepts unchanged' below. - is_empty: 'is_empty', - is_not_empty: 'is_not_empty', + // objectui#10813. These two were the only rows with no arm of their own — + // pinned as identity, because `convertFilterGroupToAST` resolved the pair + // to `[field, '=' | '!=', null]` before `mapOperator` was consulted. That + // null test is not what the spec's `is_empty` means since `$empty` (ruling B + // on objectstack#20311, lowered from the pair by objectstack#20446), so the + // pair now takes the value-less path like `is_null`, through real arms. + is_empty: 'isempty', // case 'isempty' + is_not_empty: 'isnotempty', // case 'isnotempty' }; -/** - * Operators this bridge deliberately resolves without reaching the AST gate. - * - * Every token here must still be a member of `VIEW_FILTER_OPERATORS` — the - * ratchet below enforces it. Subtracting a name the spec has retired excuses - * nothing and must be deleted rather than left as a dead subtraction (#3628). - * - * This set narrows the two secondary sweeps only. `EXPECTED_AST_TARGET` above - * subtracts nothing: it is total over the vocabulary, so no exclusion set can - * quietly hollow out the file's primary guarantee. - */ -const HANDLED_BEFORE_MAPPING = new Set([ - // convertFilterGroupToAST rewrites these to `[field, '=' | '!=', null]` - // before mapOperator is consulted, so they never need an AST spelling. - 'is_empty', 'is_not_empty', -]); - describe('mapOperator bridges the spec view vocabulary onto the AST vocabulary', () => { it('reads both vocabularies from the spec', () => { // Guards every assertion below against silently passing on an empty list. @@ -139,32 +113,6 @@ describe('mapOperator bridges the spec view vocabulary onto the AST vocabulary', expect(VALID_AST_OPERATORS.size).toBeGreaterThan(0); }); - // The exclusion ratchet (#3628). The two secondary sweeps below subtract a - // hand-written set from a spec-derived vocabulary, and that subtraction only - // excuses something while the spec still lists the subtracted tokens. Once - // upstream retires or renames one, the sweep stays green (it is still total - // over what remains) but the row becomes dead weight, and its comment goes on - // telling the next reader that "the view layer rewrites this first" about an - // operator no author can declare any more. That is the shape that rotted 37 of - // 82 deny-list entries in #3601 with nothing to report it — a hand-written - // list beside a spec-derived vocabulary and no assertion that its members - // still exist in that vocabulary. - // - // Collected rather than asserted per entry on purpose (same call as PR #3623): - // vocabulary retirements land as whole families, and failing on the first entry - // would hide the rest. - it('every HANDLED_BEFORE_MAPPING token is still in the spec view vocabulary', () => { - const vocabulary = new Set(VIEW_FILTER_OPERATORS); - const retired = [...HANDLED_BEFORE_MAPPING].filter((op) => !vocabulary.has(op)); - expect( - retired, - `VIEW_FILTER_OPERATORS no longer lists these HANDLED_BEFORE_MAPPING tokens: ` - + `${retired.join(', ')}. The spec has retired them, so subtracting them from ` - + 'the sweeps below excuses nothing — delete each from the set (with the comment ' - + 'claiming the view layer rewrites it) rather than leaving a dead subtraction', - ).toEqual([]); - }); - // The totality ratchet for the pin table (#3641). Both directions matter and // they fail for different reasons: // @@ -212,25 +160,23 @@ describe('mapOperator bridges the spec view vocabulary onto the AST vocabulary', }, ); - it('the identity rows are ones the AST gate accepts unchanged', () => { - // `is_empty` / `is_not_empty` are pinned to themselves above, which is only - // safe while the AST gate accepts those spellings verbatim. Asserting a - // fixed pair of literals here cannot be cancelled by vocabulary growth — it - // can only go red, which is the point: if upstream ever retires these - // spellings from the AST vocabulary, the identity stops being a pass-through - // and starts being a silent drop, and mapOperator needs real branches. - for (const op of ['is_empty', 'is_not_empty']) { - expect(EXPECTED_AST_TARGET[op], `${op} is expected to be pinned as identity`).toBe(op); - expect( - VALID_AST_OPERATORS.has(op), - `VALID_AST_OPERATORS no longer accepts '${op}', so mapOperator passing it ` - + 'through unchanged is now a silently dropped filter. Give it a real branch ' - + 'in mapOperator and pin the new target in EXPECTED_AST_TARGET', - ).toBe(true); - } + it('the empty pair reaches the wire as the spec\'s `$empty`, not as a null test (objectui#10813)', () => { + // The meaning, asked of the spec's own lowering rather than assumed: the + // node the live grid emits for each row of the pair lowers to `$empty`, + // which every evaluator expands by the column's declared type. The null + // pair is the control — a different operator, still lowered to `$null`. + expect(parseFilterAST(['f', mapOperator('is_empty'), null] as never)).toEqual({ f: { $empty: true } }); + expect(parseFilterAST(['f', mapOperator('is_not_empty'), null] as never)).toEqual({ f: { $empty: false } }); + expect(parseFilterAST(['f', mapOperator('isEmpty'), null] as never)).toEqual({ f: { $empty: true } }); + expect(parseFilterAST(['f', mapOperator('is_null'), null] as never)).toEqual({ f: { $null: true } }); }); - const bridged = VIEW_FILTER_OPERATORS.filter((op) => !HANDLED_BEFORE_MAPPING.has(op)); + // Every canonical view operator. A `HANDLED_BEFORE_MAPPING` set subtracted + // `is_empty` / `is_not_empty` here until objectui#10813, while + // `convertFilterGroupToAST` resolved the pair before mapOperator was asked; + // with no operator left in it, the set and its exclusion ratchet (#3628) were + // deleted rather than kept as a subtraction of nothing. + const bridged = [...VIEW_FILTER_OPERATORS]; // Secondary (#3641): this is why a wrong target matters, not what detects one. // On its own it does not discriminate — the AST vocabulary already spells the @@ -272,7 +218,6 @@ describe('mapOperator bridges the spec view vocabulary onto the AST vocabulary', // membership form an identity mapOperator passed this too, since the AST // vocabulary spells most of these aliases verbatim as well. const mismatched = Object.keys(VIEW_FILTER_OPERATOR_ALIASES) - .filter((alias) => !HANDLED_BEFORE_MAPPING.has(VIEW_FILTER_OPERATOR_ALIASES[alias])) .map((alias) => { const canonical = VIEW_FILTER_OPERATOR_ALIASES[alias]; return { alias, canonical, expected: EXPECTED_AST_TARGET[canonical], actual: mapOperator(alias) }; diff --git a/packages/plugin-list/src/__tests__/list-offered-operator-expressible-parity.test.ts b/packages/plugin-list/src/__tests__/list-offered-operator-expressible-parity.test.ts index 0de6393b1a..09b2a2eda6 100644 --- a/packages/plugin-list/src/__tests__/list-offered-operator-expressible-parity.test.ts +++ b/packages/plugin-list/src/__tests__/list-offered-operator-expressible-parity.test.ts @@ -146,9 +146,9 @@ function probeValue(operator: string): unknown { /** * Leg 1 — the live grid. Drives the REAL production path rather than reasoning - * about `mapOperator` alone, because `convertFilterGroupToAST` resolves some - * ids (`isEmpty` / `isNotEmpty`) to a null comparison before the bridge is ever - * consulted, and those are legitimately expressible without an AST spelling. + * about `mapOperator` alone: what reaches the wire is `convertFilterGroupToAST`'s + * node, and that function has resolved some ids before the bridge was ever + * consulted (the empty pair did, as a null comparison, until objectui#10813). * * The emitted node is required to be NON-EMPTY. Without that, the assertion is * a tautology waiting to happen: a condition dropped as incomplete yields `[]`, From 6c5011deda6be9d061a97435dfb8b1c340dcd429 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 11:29:13 +0000 Subject: [PATCH 28/36] =?UTF-8?q?test(console):=20book=20row=201=20of=20th?= =?UTF-8?q?e=2017.6.0=20bump,=20object-grid.keyboardNavigation,=20to=20obj?= =?UTF-8?q?ectui#11068=20under=20objectui#11438=20ruling=20A=E2=80=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @objectstack/spec 17.6.0's object-grid row declares `keyboardNavigation`, and the grid's `inputs` do not publish it: objectui#11068 is building the key's reader and publishes the key with it. objectui#11438 ruling A″ (record 5968177777) books a row whose slice is not accepted when the row-3 booking is pushed "the same way, to their own cards", in objectui#11111 decision 3 = B's shape (record 5902351047). On the trunk tip c4775ed3a the parity file had exactly two red rows, both reading ['keyboardNavigation']; with this booking it reads 255 / 255. - UNPUBLISHED_EXEMPTIONS books `object-grid.keyboardNavigation` OWED TO objectui#11068, with an expiry: 2026-11-02 (ruling A″ is of the same day as ruling A′), or when objectui#11068 lands, whichever is first. - objectui#11068 joins the ledger's owners as the fifth, routed by exact id; the `unpublishedKeys` cap goes 11 -> 12 and the per-owner totals gain objectui#11068: 1. No other cap moves (the key is boolean-armed, so no member pin is owed). - `the four GA blocks resolve their ruled split` refused any exemption on the four GA blocks except the ruled carve-out, so the ledger entry alone left it red. It now admits this one OWED id BY NAME: the OWED entries on the four blocks must equal ['object-grid.keyboardNavigation'] exactly, the undeclared set must equal the booked set (so the build's landing turns it red until the booking is struck), and every other exemption on the four blocks is still refused. objectui#4648's docblock states the exception. - Changeset: empty frontmatter; the file is unreachable from the console bundle and `plugin.*`. Claude-Session: https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn Co-authored-by: Claude --- .changeset/11068-row1-booking-17-6.md | 4 + .../registry-inputs-spec-parity.test.ts | 111 ++++++++++++++++-- 2 files changed, 102 insertions(+), 13 deletions(-) create mode 100644 .changeset/11068-row1-booking-17-6.md diff --git a/.changeset/11068-row1-booking-17-6.md b/.changeset/11068-row1-booking-17-6.md new file mode 100644 index 0000000000..3af92c9bdb --- /dev/null +++ b/.changeset/11068-row1-booking-17-6.md @@ -0,0 +1,4 @@ +--- +--- + +Test-only change in `@object-ui/console`; no published behaviour changes. Under objectui#11438 ruling A″, row 1 of the `@objectstack/spec` 17.6.0 bump, `object-grid.keyboardNavigation`, is booked as owed to objectui#11068 in `registry-inputs-spec-parity.test.ts`, with an expiry (2026-11-02, or when objectui#11068's build lands). The entry joins the file's objectui#11111 ledger, whose `unpublishedKeys` cap rises by exactly this one entry. The GA-block split row admits this one id by name and refuses every other exemption on the four GA blocks as before. The file sits under `apps/console/src/__tests__/`, which nothing outside `__tests__/` imports, so neither the console bundle nor `plugin.*` carries it. diff --git a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts index 4735f3c24e..5928e882c4 100644 --- a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts +++ b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts @@ -752,6 +752,18 @@ function isRetiredUpstream(type: string): boolean { * row declares that `inputs` does not publish, and the member pins its * array/object-armed inputs owe. * + * A fifth owner, under the same bump: objectui#11438 ruling A″ (record + * 5968177777, the maintainer's 「objectui应该尽快升级到17.6这个是第一优先」) + * books a row whose owner card's slice is not accepted when the row-3 booking + * is pushed "the same way, to their own cards". Row 1 is such a row: + * + * - objectui#11068 — `object-grid.keyboardNavigation`, the one key on a GA + * block that 17.6.0's row declares and `inputs` does not publish. That card + * is BUILDING the key's reader and publishes the key with it; this is the + * only OWED entry on any of the four GA blocks, pinned by name in `the four + * GA blocks resolve their ruled split`, because objectui#4648's split + * otherwise refuses any exemption on them (see THE FOUR GA BLOCKS below). + * * Each owner card decides every key by its own measurement: declare what the * renderer honours, refuse or retire what it does not. ⛔ Batch-declaring an * input a renderer does not honour is the failure decision 3 = B exists to @@ -763,7 +775,7 @@ function isRetiredUpstream(type: string): boolean { * new difference beyond the listed entries is still red: every direction below * subtracts exact ids, never a block or a prefix. Each owner card's landing * strikes its entries and lowers the matching cap in the same change; the last - * of the four to land restores the empty ledgers and the zero caps this file + * of the five to land restores the empty ledgers and the zero caps this file * held before the 17.5.0 bump. * * EXPIRY. None of these ledgers has a date field, and this repo's SUNSET @@ -773,23 +785,27 @@ function isRetiredUpstream(type: string): boolean { * clock. The 17.6.0 entries carry their own date, set the same distance from * their ruling as the 17.5.0 date is from its own: record 5902351047 was ruled * 2026-09-30 and its entries expire 2026-10-30; record 5965062249 was ruled - * 2026-10-03, so objectui#11536's entries expire 2026-11-02. + * 2026-10-03, so objectui#11536's entries expire 2026-11-02. Record 5968177777 + * (ruling A″) was ruled the same day, so objectui#11068's entry shares that + * date: it expires 2026-11-02, or when that card's build lands. */ const OBJECTUI_11111_EXPIRES = '2026-10-30'; -/** The 17.6.0 bookings' date: objectui#11438 ruling A′ (record 5965062249), plus thirty days. */ +/** The 17.6.0 bookings' date: objectui#11438 ruling A′ (record 5965062249), plus thirty days; ruling A″ (record 5968177777) is of the same day. */ const OBJECTUI_11438_EXPIRES = '2026-11-02'; /** * The owner cards, and nothing else may own an entry: the three decision 3 = B - * names, and objectui#11536, which objectui#11438 ruling A′ filed for the 17.6.0 - * bump's one group with no owner. + * names, objectui#11536, which objectui#11438 ruling A′ filed for the 17.6.0 + * bump's one group with no owner, and objectui#11068, whose row ruling A″ + * booked to it. */ const OBJECTUI_11111_OWNERS = [ 'objectui#11168', 'objectui#8652', 'objectui#8649', 'objectui#11536', + 'objectui#11068', ] as const; type Objectui11111Owner = (typeof OBJECTUI_11111_OWNERS)[number]; @@ -799,7 +815,8 @@ const DECISION_3_B = 'objectui#11111 decision 3 = B (record 5902351047)'; /** * Where each owner's entries were booked: the bump that surfaced them, the * record that booked them, and their expiry. The 17.5.0 owners were booked by - * decision 3 = B itself; objectui#11536 by the ruling that applies it to 17.6.0. + * decision 3 = B itself; objectui#11536 by the ruling that applies it to 17.6.0; + * objectui#11068 by the amendment of that ruling that books row 1. */ const OBJECTUI_11111_BOOKINGS: Record< Objectui11111Owner, @@ -813,6 +830,11 @@ const OBJECTUI_11111_BOOKINGS: Record< bookedBy: `objectui#11438 ruling A′ (record 5965062249), which applies ${DECISION_3_B} to the 17.6.0 bump`, expires: OBJECTUI_11438_EXPIRES, }, + 'objectui#11068': { + bump: '17.6.0', + bookedBy: `objectui#11438 ruling A″ (record 5968177777), which books row 1 of the 17.6.0 bump in the shape of ${DECISION_3_B}`, + expires: OBJECTUI_11438_EXPIRES, + }, }; /** The security triple 17.5.0 declares on exactly the three record blocks (objectui#8649's). */ @@ -844,6 +866,8 @@ const owedEntries = ( function objectui11111OwnerOf(id: string): Objectui11111Owner { // objectui#11438 ruling A′ routes by BLOCK: every entry on `record:line_items` is objectui#11536's. if (id.slice(0, id.indexOf('.')) === 'record:line_items') return 'objectui#11536'; + // Ruling A″ routes by exact ID: row 1 is this one key, and objectui#11068 owns nothing else. + if (id === 'object-grid.keyboardNavigation') return 'objectui#11068'; const key = id.slice(id.indexOf('.') + 1).split(':')[0]; if (FIELD_SECURITY_TRIPLE.includes(key)) return 'objectui#8649'; if (key === 'navigation') return 'objectui#8652'; @@ -865,7 +889,7 @@ const owedIdsOf = (ledger: Record): string[] => const OBJECTUI_11111_LEDGER_CAPS = { unjudgedBlocks: 0, // objectui#11168 loaded and judged all four: slice 3 object-map and object-tree, slice 4 object-gantt, slice 5 object-timeline offSpecInputs: 0, // objectui#11168 slice 1 retired action:group.name - unpublishedKeys: 11, // objectui#11168: 1 (action:button undoable; the two `endpoint` entries left at the 17.6.0 bump, objectui#11438, when the spec stopped declaring the key); objectui#8652: 0 and objectui#8649: 0 (each struck by its landing); objectui#11536: 10 (record:line_items, booked by objectui#11438 ruling A′) + unpublishedKeys: 12, // objectui#11168: 1 (action:button undoable; the two `endpoint` entries left at the 17.6.0 bump, objectui#11438, when the spec stopped declaring the key); objectui#8652: 0 and objectui#8649: 0 (each struck by its landing); objectui#11536: 10 (record:line_items, booked by objectui#11438 ruling A′); objectui#11068: 1 (object-grid keyboardNavigation, booked by objectui#11438 ruling A″) refusedArms: 0, // objectui#11168: slice 2 narrowed element:definition-list.columns, slice 3 object-form.layout memberPins: 2, // objectui#11168 slice 2 pinned element:definition-list.items and element:repeater ×3; objectui#11536: 2 (record:line_items columns and dataSource, booked by objectui#11438 ruling A′) } as const; @@ -1530,6 +1554,28 @@ const UNPUBLISHED_EXEMPTIONS: Record = { 'objectui#11536', 'A SPEC KEY NOT PUBLISHED: `record:line_items` entered `covered` with 17.6.0 and its `inputs` omit this key its spec row declares.', ), + + /* + * ⚠️ ROW 1 OF THE 17.6.0 BUMP — objectui#11438 ruling A″ (record 5968177777), + * which books a row whose owner card's slice is not accepted when the row-3 + * booking is pushed, "the same way, to their own cards". + * + * `object-grid.keyboardNavigation`: the 17.6.0 row declares it and the + * grid's `inputs` do not publish it. objectui#11068 is building its reader + * (arrow-key cell navigation on the WAI-ARIA grid pattern) and publishes the + * key in `GRID_QUERY_INPUTS` together with that reader, never ahead of it. + * `object-grid` is a GA block, where objectui#4648's split admits no + * exemption but the ruled carve-out, so this entry is the one exception and + * `the four GA blocks resolve their ruled split` pins it by name. Capped + * with the entries above at `OBJECTUI_11111_LEDGER_CAPS.unpublishedKeys`; + * the build's landing makes it stale, and strikes it and lowers the cap. + */ + ...owedEntries( + 'object-grid', + ['keyboardNavigation'], + 'objectui#11068', + 'A SPEC KEY NOT PUBLISHED, ITS READER IN FLIGHT: `object-grid`\'s 17.6.0 row declares `keyboardNavigation` and its `inputs` omit it; objectui#11068 publishes it with the reader it is building.', + ), }; /** @@ -1709,6 +1755,15 @@ const isDormantOnThisPin = (exemptionKey: string): boolean => { * The exemption list is therefore the carve-out and nothing else. A future key * these blocks gain is a plain A-class defect: declare it at the registration * site. Do not add an entry here to silence one. + * + * ⚠️ ONE BOOKED EXCEPTION, BY RULING AND WITH AN EXPIRY — not a silenced key. + * objectui#11438 ruling A″ (record 5968177777) books row 1 of the 17.6.0 bump, + * `object-grid.keyboardNavigation`, OWED TO objectui#11068, which declares it + * at the registration site together with the reader it is building. The entry + * expires 2026-11-02 or at that card's landing, whichever is first. + * `the four GA blocks resolve their ruled split` admits exactly that id and no + * other OWED entry on these four blocks, so the rule above still holds for + * every other key. */ const exemptedFor = (type: string): string[] => @@ -4904,21 +4959,42 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide) ).toContain(`object-grid.${key}`); } + // ⚠️ THE ONE BOOKED KEY — objectui#11438 ruling A″ (record 5968177777). + // Row 1 of the 17.6.0 bump, `object-grid.keyboardNavigation`, is OWED TO + // objectui#11068, which publishes it together with the reader it is + // building. It is neither declared nor carved out, so it is admitted BY + // NAME: the OWED entries on the four blocks must be exactly this list, and a + // second one is red here whatever the ledger cap says. It expires 2026-11-02 + // or at that card's landing; the landing declares the key, which turns the + // first assertion in the loop below red until this list and the entry are + // struck together. + const BOOKED_GA_KEYS = ['object-grid.keyboardNavigation']; + expect( + owedIdsOf(UNPUBLISHED_EXEMPTIONS).filter((id) => GA_ONLY_BLOCKS.includes(splitExemptionKey(id)[0])), + 'an OWED entry on a GA block that objectui#11438 ruling A″ did not book — declare the key instead', + ).toEqual(BOOKED_GA_KEYS); + // Everything else the four blocks' spec schemas declare is DECLARED, and // carries no exemption. Stated as an exact set difference rather than a // spot-check so a key added by a later GA cannot slip through as neither. for (const type of GA_ONLY_BLOCKS) { const carved = type === 'object-grid' ? CARVED_OUT_GRID_KEYS : []; + const booked = BOOKED_GA_KEYS.filter((id) => splitExemptionKey(id)[0] === type).map( + (id) => splitExemptionKey(id)[1], + ); const shouldPublish = specTopLevelKeys(type) .filter((key) => !(key in GLOBALLY_UNPUBLISHED_SPEC_KEYS)) .filter((key) => !carved.includes(key)); const declared = new Set(declaredInputs(type) ?? []); expect( shouldPublish.filter((key) => !declared.has(key)), - `${type} does not publish these spec keys, and they are not the ruled carve-out`, - ).toEqual([]); + `${type} does not publish these spec keys, and they are not the ruled carve-out or the ` + + 'booked key — or a booked key is declared now, so strike its booking', + ).toEqual(booked); expect( - shouldPublish.filter((key) => Object.keys(UNPUBLISHED_EXEMPTIONS).includes(`${type}.${key}`)), + shouldPublish + .filter((key) => !booked.includes(key)) + .filter((key) => Object.keys(UNPUBLISHED_EXEMPTIONS).includes(`${type}.${key}`)), `${type} exempts a key it declares — an exemption may not stand in for a declaration here`, ).toEqual([]); } @@ -5882,7 +5958,9 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide) // demands the cap come down with it), each must be routed to the card the // ruling names for it, and each must carry the ruling and its expiry. // objectui#11438 ruling A′ (record 5965062249) books the 17.6.0 bump's - // `record:line_items` entries the same way, to objectui#11536. + // `record:line_items` entries the same way, to objectui#11536, and ruling + // A″ (record 5968177777) books row 1, `object-grid.keyboardNavigation`, to + // objectui#11068. const ledgers: Array<[keyof typeof OBJECTUI_11111_LEDGER_CAPS, Record]> = [ ['unjudgedBlocks', UNJUDGED_SPEC_BLOCKS], ['offSpecInputs', OFF_SPEC_EXEMPTIONS], @@ -5909,7 +5987,8 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide) 'objectui#11111 decision 3 = B (record 5902351047)', ); // The record that booked it: decision 3 = B itself for the 17.5.0 - // owners, objectui#11438 ruling A′ for objectui#11536. + // owners, objectui#11438 ruling A′ for objectui#11536, ruling A″ for + // objectui#11068. expect(reason, `${id} does not cite the record that booked it`).toContain( `Booked by ${OBJECTUI_11111_BOOKINGS[owner].bookedBy}: `, ); @@ -5927,6 +6006,12 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide) reasons.filter((reason) => reason.startsWith(`${OWED_PREFIX}${owner}.`)).length, ]), ), - ).toEqual({ 'objectui#11168': 1, 'objectui#8652': 0, 'objectui#8649': 0, 'objectui#11536': 12 }); + ).toEqual({ + 'objectui#11168': 1, + 'objectui#8652': 0, + 'objectui#8649': 0, + 'objectui#11536': 12, + 'objectui#11068': 1, + }); }); }); From bebd7ecdfebc86025c9eeccc9f8aebfa36e21b0d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 11:44:44 +0000 Subject: [PATCH 29/36] =?UTF-8?q?test(console):=20the=20row-1=20booking's?= =?UTF-8?q?=20docblock=20cites=20ruling=20A=E2=80=B3=20by=20record,=20in?= =?UTF-8?q?=20English=20(objectui#11068)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner paragraph quoted the maintainer's order in Chinese; this repo's codebase rule keeps code comments English, so it now cites record 5968177777 and says what the order was. No assertion moves. Claude-Session: https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn Co-authored-by: Claude --- .../src/__tests__/registry-inputs-spec-parity.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts index 5928e882c4..1c88395bfd 100644 --- a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts +++ b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts @@ -753,9 +753,9 @@ function isRetiredUpstream(type: string): boolean { * array/object-armed inputs owe. * * A fifth owner, under the same bump: objectui#11438 ruling A″ (record - * 5968177777, the maintainer's 「objectui应该尽快升级到17.6这个是第一优先」) - * books a row whose owner card's slice is not accepted when the row-3 booking - * is pushed "the same way, to their own cards". Row 1 is such a row: + * 5968177777, the maintainer's order to land 17.6.0 first) books a row whose + * owner card's slice is not accepted when the row-3 booking is pushed "the + * same way, to their own cards". Row 1 is such a row: * * - objectui#11068 — `object-grid.keyboardNavigation`, the one key on a GA * block that 17.6.0's row declares and `inputs` does not publish. That card From 3ac224a63ca8068198030ea8ce8813f63457d33a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 11:56:44 +0000 Subject: [PATCH 30/36] test(fields): the expiry row's two new condToMongo calls take the row literal without an any cast (objectui#10813) Claude-Session: https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn Co-authored-by: Claude --- .../widgets/__tests__/FilterConditionField.operators.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/fields/src/widgets/__tests__/FilterConditionField.operators.test.ts b/packages/fields/src/widgets/__tests__/FilterConditionField.operators.test.ts index 7c5bdfec5b..38af86b30c 100644 --- a/packages/fields/src/widgets/__tests__/FilterConditionField.operators.test.ts +++ b/packages/fields/src/widgets/__tests__/FilterConditionField.operators.test.ts @@ -211,9 +211,9 @@ describe('every spec field operator is reachable from the builder (#2942)', () = expect(emitted.has('$empty')).toBe(true); expect(FILTER_BUILDER_OPERATORS).toContain('is_empty'); expect(FILTER_BUILDER_OPERATORS).toContain('is_not_empty'); - expect(operatorsOf(condToMongo({ id: 'c1', field: 'f', operator: 'is_empty', value: '' } as any, noTypes))) + expect(operatorsOf(condToMongo({ id: 'c1', field: 'f', operator: 'is_empty', value: '' }, noTypes))) .toEqual(['$empty']); - expect(operatorsOf(condToMongo({ id: 'c1', field: 'f', operator: 'is_not_empty', value: '' } as any, noTypes))) + expect(operatorsOf(condToMongo({ id: 'c1', field: 'f', operator: 'is_not_empty', value: '' }, noTypes))) .toEqual(['$empty']); }); From c6a0b0ab56175319e90d2c0469a2332ed731b20f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 11:58:54 +0000 Subject: [PATCH 31/36] chore(changeset): dated corrections to the two pending changesets objectui#10813 falsified .changeset/10790-empty-operators-accepted-shape.md said "Is empty" stores the $or of $in [''] and $null; .changeset/9359-list-ast-valueless-canonical-fold.md said the empty pair resolves to a null comparison ahead of mapOperator. Both end-states are replaced in this release by objectui#10813's move to $empty; each gets a dated correction paragraph instead of a rewrite. Claude-Session: https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn Co-authored-by: Claude --- .changeset/10790-empty-operators-accepted-shape.md | 7 +++++++ .changeset/9359-list-ast-valueless-canonical-fold.md | 6 ++++++ 2 files changed, 13 insertions(+) diff --git a/.changeset/10790-empty-operators-accepted-shape.md b/.changeset/10790-empty-operators-accepted-shape.md index 3294c08bdb..d00c96ef3b 100644 --- a/.changeset/10790-empty-operators-accepted-shape.md +++ b/.changeset/10790-empty-operators-accepted-shape.md @@ -21,3 +21,10 @@ value OR the empty string, and its complement: Criteria saved in the old shape still open in the builder as the same row, and opening one rewrites nothing; the builder writes the new shape the next time any row of that criteria is edited. + +**Correction, 2026-10-03 (objectui#10813).** The two shapes above are not what this release +writes. Later in the same release the pair moved to the spec's one 「is empty」 operator: +"Is empty" stores `{ FIELD: { $empty: true } }` and "Is not empty" `{ FIELD: { $empty: false } }`, +whose meaning is the field's declared row of the spec's per-type table rather than "no value OR +the empty string" on every type. Criteria saved in either earlier shape still open as the same +row and are written as `$empty` on their next edit (`.changeset/10813-filter-condition-empty-operator.md`). diff --git a/.changeset/9359-list-ast-valueless-canonical-fold.md b/.changeset/9359-list-ast-valueless-canonical-fold.md index e768243a91..a8c3be8ccf 100644 --- a/.changeset/9359-list-ast-valueless-canonical-fold.md +++ b/.changeset/9359-list-ast-valueless-canonical-fold.md @@ -75,3 +75,9 @@ objectui#9306's census), every pair emits the same node except folds onto `icontains` before a row leaves it. The vocabulary question this entry calls open is answered: the dropdown speaks the protocol's ids, and camelCase is the deprecated alias form. + +**Correction, 2026-10-03 (objectui#10813).** The two `isEmpty` / `isNotEmpty` arms that +"resolve to a null comparison ahead of `mapOperator`" are gone in this release. The empty pair +now takes the same value-less path as `is_null`, and `mapOperator` emits the spec's `isempty` / +`isnotempty`, which the spec lowers to `$empty`; every spelling of the pair still emits one node +(`.changeset/10813-list-view-empty-operator.md`). From a36c71417aa42143fdcfa347e4866d8359ba59d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 12:46:42 +0000 Subject: [PATCH 32/36] fix(fields,app-shell): raise the @objectstack/spec floor to ^17.6.0, since both packages now write $empty (objectui#10813) @object-ui/fields (FilterConditionField) and @object-ui/app-shell (the dataset filter bridge) write { FIELD: { $empty: bool } } into stored metadata. At @objectstack/spec 17.5.0, $empty is declared STAGED, absent from FILTER_OPERATORS, and refused by every executor, yet both manifests declared ^17.5.0, so a resolution inside the declared range refuses what the package emits. The floor now tracks that reality (objectui#5793), as objectui#9012 did for @object-ui/core. plugin-list's peer range ^17.0.0 is untouched: it emits isempty / isnotempty, which every spec in that range accepts. pnpm install moved only the two importers' specifier lines; the resolved @objectstack/spec stays 17.6.0. Claude-Session: https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn Co-authored-by: Claude --- packages/app-shell/package.json | 2 +- packages/fields/package.json | 2 +- pnpm-lock.yaml | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/app-shell/package.json b/packages/app-shell/package.json index c7dcba3277..1850cc8526 100644 --- a/packages/app-shell/package.json +++ b/packages/app-shell/package.json @@ -85,7 +85,7 @@ "@object-ui/types": "workspace:*", "@objectstack/formula": "^17.5.0", "@objectstack/lint": "^17.0.0", - "@objectstack/spec": "^17.5.0", + "@objectstack/spec": "^17.6.0", "@sentry/react": "^10.70.0", "jsonc-parser": "^3.3.1", "lucide-react": "^1.43.0", diff --git a/packages/fields/package.json b/packages/fields/package.json index 8130f899e2..af598172e8 100644 --- a/packages/fields/package.json +++ b/packages/fields/package.json @@ -37,7 +37,7 @@ "@object-ui/providers": "workspace:*", "@object-ui/react": "workspace:*", "@object-ui/types": "workspace:*", - "@objectstack/spec": "^17.5.0", + "@objectstack/spec": "^17.6.0", "lucide-react": "^1.43.0", "react-markdown": "^10.1.0", "rehype-sanitize": "^6.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 72539b4869..5801994acd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -780,7 +780,7 @@ importers: specifier: ^17.0.0 version: 17.6.0(ai@7.0.65(zod@4.6.5)) '@objectstack/spec': - specifier: ^17.5.0 + specifier: ^17.6.0 version: 17.6.0(ai@7.0.65(zod@4.6.5)) '@sentry/react': specifier: ^10.70.0 @@ -1292,7 +1292,7 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^17.5.0 + specifier: ^17.6.0 version: 17.6.0(ai@7.0.65(zod@4.6.5)) lucide-react: specifier: ^1.43.0 From 994d43249f7c856dbcb54e6807b52aa571aa76c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 12:48:49 +0000 Subject: [PATCH 33/36] docs(changeset): name the ^17.6.0 spec floor, the refusing analytics face and the 17.6.0 lowering scope (objectui#10813) - fields and app-shell changesets: one sentence each naming the raised @objectstack/spec floor (^17.5.0 to ^17.6.0) and why: the package now writes $empty, which spec 17.5.0 declares staged and refuses. - dataset changeset: name the face the spec's own FILTER_OPERATORS face table declares as refusing $empty, the driver-memory analytics (cube) face (the lowest-priority fallback strategy), which answered $exists. - plugin-list changeset: "lowers to $empty" holds from spec 17.6.0 on; an earlier reader in the ^17.0.0 peer range lowers isempty / isnotempty to $null, as it does the saved view's is_empty. The server-side re-scope sentence is scoped the same way. Claude-Session: https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn Co-authored-by: Claude --- .changeset/10813-dataset-filter-empty-operator.md | 4 +++- .changeset/10813-filter-condition-empty-operator.md | 2 ++ .changeset/10813-list-view-empty-operator.md | 4 ++-- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.changeset/10813-dataset-filter-empty-operator.md b/.changeset/10813-dataset-filter-empty-operator.md index 1d47373750..64af6cbfb6 100644 --- a/.changeset/10813-dataset-filter-empty-operator.md +++ b/.changeset/10813-dataset-filter-empty-operator.md @@ -4,7 +4,9 @@ The Studio dataset filter builder writes "Is empty" / "Is not empty" as the spec's `{ FIELD: { $empty: true } }` / `{ FIELD: { $empty: false } }` instead of `$exists` (objectui#10813). -`dataset.filter` and `measure.filter` stored the pair as `{ FIELD: { $exists: false } }` / `{ FIELD: { $exists: true } }`. `$exists` is the spec's has-a-value test (`!= null`), so a text value of `''` or a multi-value `[]` was never "empty" there, while the same operator in the sharing-rule widget and in a saved view meant something else. `@objectstack/spec` 17.6.0 admits `$empty` (objectstack#20446), whose meaning is the column's DECLARED row of the spec's per-type table (ruling B on objectstack#20311): null or `''` on a text-like column, null or `[]` on a multi-value one, null alone on every other type. The analytics service answers it from the field's declared type and `multiple` on its SQL and ObjectQL strategies. The bridge keeps no copy of the table. +`dataset.filter` and `measure.filter` stored the pair as `{ FIELD: { $exists: false } }` / `{ FIELD: { $exists: true } }`. `$exists` is the spec's has-a-value test (`!= null`), so a text value of `''` or a multi-value `[]` was never "empty" there, while the same operator in the sharing-rule widget and in a saved view meant something else. `@objectstack/spec` 17.6.0 admits `$empty` (objectstack#20446), whose meaning is the column's DECLARED row of the spec's per-type table (ruling B on objectstack#20311): null or `''` on a text-like column, null or `[]` on a multi-value one, null alone on every other type. The analytics service answers it from the field's declared type and `multiple` on its SQL and ObjectQL strategies. The spec's own face table declares one face that refuses it: the `driver-memory` analytics (cube) face, the lowest-priority fallback strategy (`MemoryAnalyticsService`), refuses a newly authored "Is empty" / "Is not empty" filter with `INVALID_FILTER` / 400, as it refuses `$null`, where it answered `$exists`. The bridge keeps no copy of the table. + +The `@objectstack/spec` dependency floor rises from `^17.5.0` to `^17.6.0`, because this package now writes `$empty`, which `@objectstack/spec` 17.5.0 declares staged and refuses. The read half reads `$empty` back as the pair, with a boolean flag only. diff --git a/.changeset/10813-filter-condition-empty-operator.md b/.changeset/10813-filter-condition-empty-operator.md index f650cd9100..94ab1e3ee2 100644 --- a/.changeset/10813-filter-condition-empty-operator.md +++ b/.changeset/10813-filter-condition-empty-operator.md @@ -6,6 +6,8 @@ The widget behind sharing-rule `criteria_json`, `relatedListFilter` and `summaryOperations.filter` used to write its own meaning of 「is empty」: `{ $or: [{ FIELD: { $in: [''] } }, { FIELD: { $null: true } }] }` and its complement `{ FIELD: { $nin: [''], $null: false } }`, i.e. "no value OR `''`" on every field type. The `''` member reached a number or date column as `IN ('')`, and a multi-value column (stored as JSON by the SQL driver) as an `$in` that driver refuses. `@objectstack/spec` 17.6.0 admits `$empty` to `FILTER_OPERATORS` (objectstack#20446), and its meaning is the field's DECLARED row of the spec's per-type table (ruling B on objectstack#20311): null or `''` on a text-like field, null or `[]` on a multi-value field, null alone on every other type. Every evaluator expands it itself (`expandEmptyOperator`), so the widget now writes the same token on every column type and keeps no copy of the table. +The `@objectstack/spec` dependency floor rises from `^17.5.0` to `^17.6.0`, because this package now writes `$empty`, which `@objectstack/spec` 17.5.0 declares staged and refuses. + `kvToCondition` reads `$empty` back as the pair, with a boolean flag only: any other flag stays the raw criteria it is, as every evaluator refuses it. **What moves for a stored rule.** Reading alone rewrites nothing: a criteria saved in either earlier shape (the objectui#10790 `$or` entry and `$nin` + `$null` pair, or the older `$in: [null, '']` / `$nin: [null, '']`) still opens as the same "Is empty" / "Is not empty" row, and an un-edited rule keeps its stored bytes and keeps matching as before. The next time the criteria is edited, those rows are written as `$empty`, which re-scopes the rule: diff --git a/.changeset/10813-list-view-empty-operator.md b/.changeset/10813-list-view-empty-operator.md index 829f61973c..a4e560fea6 100644 --- a/.changeset/10813-list-view-empty-operator.md +++ b/.changeset/10813-list-view-empty-operator.md @@ -2,8 +2,8 @@ '@object-ui/plugin-list': minor --- -The list view's live query sends "Is empty" / "Is not empty" as the spec's `isempty` / `isnotempty`, which the spec lowers to `$empty`, instead of an equality to `null` (objectui#10813). +The list view's live query sends "Is empty" / "Is not empty" as the spec's `isempty` / `isnotempty` instead of an equality to `null` (objectui#10813). `@objectstack/spec` 17.6.0 and later lower that pair to `$empty`; an earlier reader lowers it to `$null`, as it does the saved view's `is_empty`. `convertFilterGroupToAST` resolved the pair to `[FIELD, '=', null]` / `[FIELD, '!=', null]`, a null-only test, before `mapOperator` was consulted. The same rule saved into the view is persisted as `is_empty`, which `@objectstack/spec` 17.6.0 lowers to `$empty` (objectstack#20446): so one filter panel answered two record sets, depending on whether the view had been saved. The pair now takes the value-less path like `is_null`, and `mapOperator` gains `isempty` / `isnotempty` arms. The node is `[FIELD, 'isempty', null]`; the spec discards the third slot. -**What moves.** Nothing is stored by this path. On a text-like column a row holding `''` is now "empty", and on a multi-value column a row holding `[]` is, matching the server's per-type answer for the saved view. On a `provider: 'value'` list a row with no key at all is now "empty" too: the equality to `null` did not select it there. +**What moves.** Nothing is stored by this path. Against a 17.6.0 or later server, on a text-like column a row holding `''` is now "empty", and on a multi-value column a row holding `[]` is, matching the server's per-type answer for the saved view. On a `provider: 'value'` list a row with no key at all is now "empty" too: the equality to `null` did not select it there. From 20770e4e9d23662597402509a8fea6f35566ce58 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 12:48:49 +0000 Subject: [PATCH 34/36] docs(app-shell): the dataset filter inspector wrote the $exists pair until objectui#10813; correct the two drill sentences drillUrlFilters.ts and the drillNotNullDialect-9508 header said the dataset filter inspector's "is not empty" row writes the $exists pair. Since objectui#10813 it writes { $empty: false }; a filter stored before then still carries $exists. Prose only: the drill dialect's handling of $empty is objectui#11547's, and no code here changes. Claude-Session: https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn Co-authored-by: Claude --- .../app-shell/src/views/drillNotNullDialect-9508.test.tsx | 4 +++- packages/app-shell/src/views/drillUrlFilters.ts | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/app-shell/src/views/drillNotNullDialect-9508.test.tsx b/packages/app-shell/src/views/drillNotNullDialect-9508.test.tsx index 13265145ba..a6b05819b6 100644 --- a/packages/app-shell/src/views/drillNotNullDialect-9508.test.tsx +++ b/packages/app-shell/src/views/drillNotNullDialect-9508.test.tsx @@ -44,7 +44,9 @@ * * ⇒ reading only `$null` would have closed the composed route and left the * uncomposed one degrading exactly as before, for an author who picked "is not - * empty" in the dataset filter inspector (which writes the `$exists` pair). + * empty" in the dataset filter inspector, which wrote the `$exists` pair until + * objectui#10813; a filter stored before then still carries it, and the row + * now writes `$empty` instead. * * ## What this card did NOT change * diff --git a/packages/app-shell/src/views/drillUrlFilters.ts b/packages/app-shell/src/views/drillUrlFilters.ts index 28520ac5d3..14b1e14e87 100644 --- a/packages/app-shell/src/views/drillUrlFilters.ts +++ b/packages/app-shell/src/views/drillUrlFilters.ts @@ -109,8 +109,10 @@ export const RANGE_OP_PARAM: Record = { $gte: 'gte', $lte: 'lte' * that hands its own resolved filter straight to the escape hatch * (`ObjectMetricWidget`, whose drawer renders `OpenInListButton`) passes * through no canonicaliser at all, so `$exists` reaches this function - * verbatim — and the dataset filter inspector's "is not empty" row writes - * exactly that pair. + * verbatim. The dataset filter inspector's "is not empty" row wrote + * exactly that pair until objectui#10813, so a filter stored before then + * still carries it; that row now writes `{ $empty: false }`, which is not + * one of these two keys (objectui#11547). * * ⚠️ A NON-boolean under either key says nothing about emptiness and writes * nothing, which is what it did before this pair existed. From 61e7e62c79725f597b3abdb09957353c991db634 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 13:26:36 +0000 Subject: [PATCH 35/36] docs(changeset): drop the dataset changeset's stale "as it refuses $null" clause (objectui#10813) The clause copied the cube face's cell in the 17.6.0 FILTER_OPERATORS face table, which is stale at its own tag: driver-memory 17.6.0's MONGO_TO_CUBE_OPERATOR holds $null: 'isNull', so that face compiles $null. The sentence stays true without it: the face refuses $empty with INVALID_FILTER / 400, where it answered $exists. Claude-Session: https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn Co-authored-by: Claude --- .changeset/10813-dataset-filter-empty-operator.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/10813-dataset-filter-empty-operator.md b/.changeset/10813-dataset-filter-empty-operator.md index 64af6cbfb6..bdf87d553c 100644 --- a/.changeset/10813-dataset-filter-empty-operator.md +++ b/.changeset/10813-dataset-filter-empty-operator.md @@ -4,7 +4,7 @@ The Studio dataset filter builder writes "Is empty" / "Is not empty" as the spec's `{ FIELD: { $empty: true } }` / `{ FIELD: { $empty: false } }` instead of `$exists` (objectui#10813). -`dataset.filter` and `measure.filter` stored the pair as `{ FIELD: { $exists: false } }` / `{ FIELD: { $exists: true } }`. `$exists` is the spec's has-a-value test (`!= null`), so a text value of `''` or a multi-value `[]` was never "empty" there, while the same operator in the sharing-rule widget and in a saved view meant something else. `@objectstack/spec` 17.6.0 admits `$empty` (objectstack#20446), whose meaning is the column's DECLARED row of the spec's per-type table (ruling B on objectstack#20311): null or `''` on a text-like column, null or `[]` on a multi-value one, null alone on every other type. The analytics service answers it from the field's declared type and `multiple` on its SQL and ObjectQL strategies. The spec's own face table declares one face that refuses it: the `driver-memory` analytics (cube) face, the lowest-priority fallback strategy (`MemoryAnalyticsService`), refuses a newly authored "Is empty" / "Is not empty" filter with `INVALID_FILTER` / 400, as it refuses `$null`, where it answered `$exists`. The bridge keeps no copy of the table. +`dataset.filter` and `measure.filter` stored the pair as `{ FIELD: { $exists: false } }` / `{ FIELD: { $exists: true } }`. `$exists` is the spec's has-a-value test (`!= null`), so a text value of `''` or a multi-value `[]` was never "empty" there, while the same operator in the sharing-rule widget and in a saved view meant something else. `@objectstack/spec` 17.6.0 admits `$empty` (objectstack#20446), whose meaning is the column's DECLARED row of the spec's per-type table (ruling B on objectstack#20311): null or `''` on a text-like column, null or `[]` on a multi-value one, null alone on every other type. The analytics service answers it from the field's declared type and `multiple` on its SQL and ObjectQL strategies. The spec's own face table declares one face that refuses it: the `driver-memory` analytics (cube) face, the lowest-priority fallback strategy (`MemoryAnalyticsService`), refuses a newly authored "Is empty" / "Is not empty" filter with `INVALID_FILTER` / 400, where it answered `$exists`. The bridge keeps no copy of the table. The `@objectstack/spec` dependency floor rises from `^17.5.0` to `^17.6.0`, because this package now writes `$empty`, which `@objectstack/spec` 17.5.0 declares staged and refuses. From b914846c48c3a52f00d375dd16cf57154acd2bd0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 13:56:00 +0000 Subject: [PATCH 36/36] docs(app-shell): the inspector's { $empty: false } rides the drill dialect's [empty] arm (objectui#10813) objectui#11547 gave the drill URL dialect an [empty] arm (EMPTY_FILTER). The slice's two sentences said the dataset filter inspector's new "is not empty" write is not one of NULL_FILTER's two keys, which stays true; they now also say that the [empty] arm carries it. Comments only: no code line in drillUrlFilters.ts differs from objectui#11547's. Claude-Session: https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn Co-authored-by: Claude --- packages/app-shell/src/views/drillNotNullDialect-9508.test.tsx | 3 ++- packages/app-shell/src/views/drillUrlFilters.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/app-shell/src/views/drillNotNullDialect-9508.test.tsx b/packages/app-shell/src/views/drillNotNullDialect-9508.test.tsx index a6b05819b6..203216eeee 100644 --- a/packages/app-shell/src/views/drillNotNullDialect-9508.test.tsx +++ b/packages/app-shell/src/views/drillNotNullDialect-9508.test.tsx @@ -46,7 +46,8 @@ * uncomposed one degrading exactly as before, for an author who picked "is not * empty" in the dataset filter inspector, which wrote the `$exists` pair until * objectui#10813; a filter stored before then still carries it, and the row - * now writes `$empty` instead. + * now writes `{ $empty: false }` instead, which this dialect carries on its + * `[empty]` arm (objectui#11547). * * ## What this card did NOT change * diff --git a/packages/app-shell/src/views/drillUrlFilters.ts b/packages/app-shell/src/views/drillUrlFilters.ts index 412ea44287..67a9fe4374 100644 --- a/packages/app-shell/src/views/drillUrlFilters.ts +++ b/packages/app-shell/src/views/drillUrlFilters.ts @@ -123,7 +123,8 @@ export const RANGE_OP_PARAM: Record = { $gte: 'gte', $lte: 'lte' * verbatim. The dataset filter inspector's "is not empty" row wrote * exactly that pair until objectui#10813, so a filter stored before then * still carries it; that row now writes `{ $empty: false }`, which is not - * one of these two keys (objectui#11547). + * one of these two keys: it reaches this function verbatim too, and the + * `[empty]` arm, {@link EMPTY_FILTER}, carries it (objectui#11547). * * ⚠️ A NON-boolean under either key says nothing about emptiness and writes * nothing, which is what it did before this pair existed.