diff --git a/.agents/code-style.md b/.agents/code-style.md index 609bddc0c..7167dd2c6 100644 --- a/.agents/code-style.md +++ b/.agents/code-style.md @@ -20,6 +20,7 @@ ## Exports - If a particular interface or type is not exported but needs to be, change the file so it is exported. +- **No re-exports from other packages:** Do not re-export types or values from other libraries/packages. Consumers should import directly from the canonical source. This applies especially when refactoring or moving files — fix the references at each call site to point to the original package instead of creating proxy re-exports. ## String Comparison diff --git a/.agents/testing.md b/.agents/testing.md index b6bc5d540..f2c2a7d6f 100644 --- a/.agents/testing.md +++ b/.agents/testing.md @@ -34,7 +34,7 @@ This file outlines the testing strategy, patterns, and constraints for the Basep - **Integration Tests:** Use the `.int.test.ts` suffix. - **Test Helpers:** Place shared setup code or utilities in `src/tests/` or create `*.test-helper.ts` files. - Before creating mock data or test objects, check for an existing `*.test-helper.ts` or `*.test-utils.ts` file in the same package (see [Test Helper Reference](#test-helper-reference) below). - - Example: to mock a `ProjectDefinition`, use `createTestProjectDefinition()` from `packages/project-builder-lib/src/definition/project-definition-container.test-utils.ts`. + - Example: to mock a `ProjectDefinition`, use `createTestProjectDefinition()` from `packages/project-builder-lib/src/testing/project-definition-container.test-helper.ts`. - If no helper exists and the mock would be useful across multiple test files, create a new `*.test-helper.ts` file following the factory function pattern (e.g., `createTest()`) and add an entry to the reference table below. - **Manual Mocks:** Place manual mocks in `src/__mocks__/`. @@ -103,10 +103,10 @@ const files = await globby(['**/*.ts'], { fs: fsAdapter }); | sync | `src/tests/logger.test-utils.ts` | `createTestLogger`, `createConsoleLogger` | | sync | `src/templates/extractor/test-utils/plugin-test-utils.ts` | `createMockPluginApi`, `createPluginInstance` | | ui-components | `src/tests/render.test-helper.tsx` | `renderWithProviders` | -| project-builder-lib | `src/definition/project-definition-container.test-utils.ts` | `createTestProjectDefinition`, `createTestProjectDefinitionContainer` | -| project-builder-lib | `src/schema/definition.test-helper.ts` | `createTestFeature`, `createTestModel`, `createTestScalarField` | +| project-builder-lib | `src/testing/project-definition-container.test-helper.ts` | `createTestProjectDefinition`, `createTestProjectDefinitionContainer`, `createTestEntityServiceContext` | +| project-builder-lib | `src/testing/definition-helpers.test-helper.ts` | `createTestFeature`, `createTestModel`, `createTestScalarField` | | project-builder-lib | `src/plugins/plugins.test-utils.ts` | `createTestPluginMetadata`, `createTestMigration` | -| project-builder-lib | `src/references/expression-stub-parser.test-helper.ts` | `stubParser`, `StubParserWithSlots` | +| project-builder-lib | `src/testing/expression-stub-parser.test-helper.ts` | `stubParser`, `StubParserWithSlots` | | code-morph | `src/morphers/tests/morpher.test-helper.ts` | `runMorpherTests` | | project-builder-server | `src/tests/chokidar.test-helper.ts` | `MockFSWatcher`, `emitMockFsWatcherEvent` | | project-builder-cli | `e2e/fixtures/server-fixture.test-helper.ts` | Playwright test fixtures (`test`, `addProject`) | \ No newline at end of file diff --git a/.changeset/add-immutable-set-utility.md b/.changeset/add-immutable-set-utility.md new file mode 100644 index 000000000..53f617067 --- /dev/null +++ b/.changeset/add-immutable-set-utility.md @@ -0,0 +1,5 @@ +--- +'@baseplate-dev/utils': patch +--- + +Add `immutableSet` utility for immutable deep updates to objects and arrays. diff --git a/.changeset/mcp-definition-entity-actions.md b/.changeset/mcp-definition-entity-actions.md new file mode 100644 index 000000000..ae5c26eb6 --- /dev/null +++ b/.changeset/mcp-definition-entity-actions.md @@ -0,0 +1,7 @@ +--- +'@baseplate-dev/project-builder-server': patch +'@baseplate-dev/project-builder-lib': patch +'@baseplate-dev/project-builder-dev': patch +--- + +Add MCP actions for reading and writing project definition entities, including draft session support for staging changes before committing. diff --git a/examples/blog-with-auth/pnpm-lock.yaml b/examples/blog-with-auth/pnpm-lock.yaml index 0dd3f36c0..573d09764 100644 --- a/examples/blog-with-auth/pnpm-lock.yaml +++ b/examples/blog-with-auth/pnpm-lock.yaml @@ -182,7 +182,7 @@ importers: version: 7.1.12(@types/node@22.13.11)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1) vite-plugin-svgr: specifier: 4.5.0 - version: 4.5.0(rollup@4.50.1)(typescript@5.9.3)(vite@7.1.12(@types/node@22.13.11)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1)) + version: 4.5.0(rollup@4.59.0)(typescript@5.9.3)(vite@7.1.12(@types/node@22.13.11)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1)) vite-tsconfig-paths: specifier: 5.1.4 version: 5.1.4(typescript@5.9.3)(vite@7.1.12(@types/node@22.13.11)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1)) @@ -2447,119 +2447,141 @@ packages: rollup: optional: true - '@rollup/rollup-android-arm-eabi@4.50.1': - resolution: {integrity: sha512-HJXwzoZN4eYTdD8bVV22DN8gsPCAj3V20NHKOs8ezfXanGpmVPR7kalUHd+Y31IJp9stdB87VKPFbsGY3H/2ag==} + '@rollup/rollup-android-arm-eabi@4.59.0': + resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.50.1': - resolution: {integrity: sha512-PZlsJVcjHfcH53mOImyt3bc97Ep3FJDXRpk9sMdGX0qgLmY0EIWxCag6EigerGhLVuL8lDVYNnSo8qnTElO4xw==} + '@rollup/rollup-android-arm64@4.59.0': + resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.50.1': - resolution: {integrity: sha512-xc6i2AuWh++oGi4ylOFPmzJOEeAa2lJeGUGb4MudOtgfyyjr4UPNK+eEWTPLvmPJIY/pgw6ssFIox23SyrkkJw==} + '@rollup/rollup-darwin-arm64@4.59.0': + resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.50.1': - resolution: {integrity: sha512-2ofU89lEpDYhdLAbRdeyz/kX3Y2lpYc6ShRnDjY35bZhd2ipuDMDi6ZTQ9NIag94K28nFMofdnKeHR7BT0CATw==} + '@rollup/rollup-darwin-x64@4.59.0': + resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.50.1': - resolution: {integrity: sha512-wOsE6H2u6PxsHY/BeFHA4VGQN3KUJFZp7QJBmDYI983fgxq5Th8FDkVuERb2l9vDMs1D5XhOrhBrnqcEY6l8ZA==} + '@rollup/rollup-freebsd-arm64@4.59.0': + resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.50.1': - resolution: {integrity: sha512-A/xeqaHTlKbQggxCqispFAcNjycpUEHP52mwMQZUNqDUJFFYtPHCXS1VAG29uMlDzIVr+i00tSFWFLivMcoIBQ==} + '@rollup/rollup-freebsd-x64@4.59.0': + resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.50.1': - resolution: {integrity: sha512-54v4okehwl5TaSIkpp97rAHGp7t3ghinRd/vyC1iXqXMfjYUTm7TfYmCzXDoHUPTTf36L8pr0E7YsD3CfB3ZDg==} + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} cpu: [arm] os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm-musleabihf@4.50.1': - resolution: {integrity: sha512-p/LaFyajPN/0PUHjv8TNyxLiA7RwmDoVY3flXHPSzqrGcIp/c2FjwPPP5++u87DGHtw+5kSH5bCJz0mvXngYxw==} + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} cpu: [arm] os: [linux] libc: [musl] - '@rollup/rollup-linux-arm64-gnu@4.50.1': - resolution: {integrity: sha512-2AbMhFFkTo6Ptna1zO7kAXXDLi7H9fGTbVaIq2AAYO7yzcAsuTNWPHhb2aTA6GPiP+JXh85Y8CiS54iZoj4opw==} + '@rollup/rollup-linux-arm64-gnu@4.59.0': + resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} cpu: [arm64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm64-musl@4.50.1': - resolution: {integrity: sha512-Cgef+5aZwuvesQNw9eX7g19FfKX5/pQRIyhoXLCiBOrWopjo7ycfB292TX9MDcDijiuIJlx1IzJz3IoCPfqs9w==} + '@rollup/rollup-linux-arm64-musl@4.59.0': + resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} cpu: [arm64] os: [linux] libc: [musl] - '@rollup/rollup-linux-loongarch64-gnu@4.50.1': - resolution: {integrity: sha512-RPhTwWMzpYYrHrJAS7CmpdtHNKtt2Ueo+BlLBjfZEhYBhK00OsEqM08/7f+eohiF6poe0YRDDd8nAvwtE/Y62Q==} + '@rollup/rollup-linux-loong64-gnu@4.59.0': + resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} cpu: [loong64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-ppc64-gnu@4.50.1': - resolution: {integrity: sha512-eSGMVQw9iekut62O7eBdbiccRguuDgiPMsw++BVUg+1K7WjZXHOg/YOT9SWMzPZA+w98G+Fa1VqJgHZOHHnY0Q==} + '@rollup/rollup-linux-loong64-musl@4.59.0': + resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} cpu: [ppc64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-riscv64-gnu@4.50.1': - resolution: {integrity: sha512-S208ojx8a4ciIPrLgazF6AgdcNJzQE4+S9rsmOmDJkusvctii+ZvEuIC4v/xFqzbuP8yDjn73oBlNDgF6YGSXQ==} + '@rollup/rollup-linux-ppc64-musl@4.59.0': + resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} cpu: [riscv64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-riscv64-musl@4.50.1': - resolution: {integrity: sha512-3Ag8Ls1ggqkGUvSZWYcdgFwriy2lWo+0QlYgEFra/5JGtAd6C5Hw59oojx1DeqcA2Wds2ayRgvJ4qxVTzCHgzg==} + '@rollup/rollup-linux-riscv64-musl@4.59.0': + resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} cpu: [riscv64] os: [linux] libc: [musl] - '@rollup/rollup-linux-s390x-gnu@4.50.1': - resolution: {integrity: sha512-t9YrKfaxCYe7l7ldFERE1BRg/4TATxIg+YieHQ966jwvo7ddHJxPj9cNFWLAzhkVsbBvNA4qTbPVNsZKBO4NSg==} + '@rollup/rollup-linux-s390x-gnu@4.59.0': + resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} cpu: [s390x] os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-gnu@4.50.1': - resolution: {integrity: sha512-MCgtFB2+SVNuQmmjHf+wfI4CMxy3Tk8XjA5Z//A0AKD7QXUYFMQcns91K6dEHBvZPCnhJSyDWLApk40Iq/H3tA==} + '@rollup/rollup-linux-x64-gnu@4.59.0': + resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} cpu: [x64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-musl@4.50.1': - resolution: {integrity: sha512-nEvqG+0jeRmqaUMuwzlfMKwcIVffy/9KGbAGyoa26iu6eSngAYQ512bMXuqqPrlTyfqdlB9FVINs93j534UJrg==} + '@rollup/rollup-linux-x64-musl@4.59.0': + resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} cpu: [x64] os: [linux] libc: [musl] - '@rollup/rollup-openharmony-arm64@4.50.1': - resolution: {integrity: sha512-RDsLm+phmT3MJd9SNxA9MNuEAO/J2fhW8GXk62G/B4G7sLVumNFbRwDL6v5NrESb48k+QMqdGbHgEtfU0LCpbA==} + '@rollup/rollup-openbsd-x64@4.59.0': + resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.59.0': + resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==} cpu: [arm64] os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.50.1': - resolution: {integrity: sha512-hpZB/TImk2FlAFAIsoElM3tLzq57uxnGYwplg6WDyAxbYczSi8O2eQ+H2Lx74504rwKtZ3N2g4bCUkiamzS6TQ==} + '@rollup/rollup-win32-arm64-msvc@4.59.0': + resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.50.1': - resolution: {integrity: sha512-SXjv8JlbzKM0fTJidX4eVsH+Wmnp0/WcD8gJxIZyR6Gay5Qcsmdbi9zVtnbkGPG8v2vMR1AD06lGWy5FLMcG7A==} + '@rollup/rollup-win32-ia32-msvc@4.59.0': + resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.50.1': - resolution: {integrity: sha512-StxAO/8ts62KZVRAm4JZYq9+NqNsV7RvimNK+YM7ry//zebEH6meuugqW/P5OFUCjyQgui+9fUxT6d5NShvMvA==} + '@rollup/rollup-win32-x64-gnu@4.59.0': + resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.59.0': + resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==} cpu: [x64] os: [win32] @@ -5351,8 +5373,8 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} - rollup@4.50.1: - resolution: {integrity: sha512-78E9voJHwnXQMiQdiqswVLZwJIzdBKJ1GdI5Zx6XwoFKUIk09/sSrr+05QFzvYb8q6Y9pPV45zzDuYa3907TZA==} + rollup@4.59.0: + resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true @@ -8232,75 +8254,87 @@ snapshots: '@rolldown/pluginutils@1.0.0-beta.34': {} - '@rollup/pluginutils@5.3.0(rollup@4.50.1)': + '@rollup/pluginutils@5.3.0(rollup@4.59.0)': dependencies: '@types/estree': 1.0.8 estree-walker: 2.0.2 picomatch: 4.0.3 optionalDependencies: - rollup: 4.50.1 + rollup: 4.59.0 + + '@rollup/rollup-android-arm-eabi@4.59.0': + optional: true + + '@rollup/rollup-android-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-x64@4.59.0': + optional: true - '@rollup/rollup-android-arm-eabi@4.50.1': + '@rollup/rollup-freebsd-arm64@4.59.0': optional: true - '@rollup/rollup-android-arm64@4.50.1': + '@rollup/rollup-freebsd-x64@4.59.0': optional: true - '@rollup/rollup-darwin-arm64@4.50.1': + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': optional: true - '@rollup/rollup-darwin-x64@4.50.1': + '@rollup/rollup-linux-arm-musleabihf@4.59.0': optional: true - '@rollup/rollup-freebsd-arm64@4.50.1': + '@rollup/rollup-linux-arm64-gnu@4.59.0': optional: true - '@rollup/rollup-freebsd-x64@4.50.1': + '@rollup/rollup-linux-arm64-musl@4.59.0': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.50.1': + '@rollup/rollup-linux-loong64-gnu@4.59.0': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.50.1': + '@rollup/rollup-linux-loong64-musl@4.59.0': optional: true - '@rollup/rollup-linux-arm64-gnu@4.50.1': + '@rollup/rollup-linux-ppc64-gnu@4.59.0': optional: true - '@rollup/rollup-linux-arm64-musl@4.50.1': + '@rollup/rollup-linux-ppc64-musl@4.59.0': optional: true - '@rollup/rollup-linux-loongarch64-gnu@4.50.1': + '@rollup/rollup-linux-riscv64-gnu@4.59.0': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.50.1': + '@rollup/rollup-linux-riscv64-musl@4.59.0': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.50.1': + '@rollup/rollup-linux-s390x-gnu@4.59.0': optional: true - '@rollup/rollup-linux-riscv64-musl@4.50.1': + '@rollup/rollup-linux-x64-gnu@4.59.0': optional: true - '@rollup/rollup-linux-s390x-gnu@4.50.1': + '@rollup/rollup-linux-x64-musl@4.59.0': optional: true - '@rollup/rollup-linux-x64-gnu@4.50.1': + '@rollup/rollup-openbsd-x64@4.59.0': optional: true - '@rollup/rollup-linux-x64-musl@4.50.1': + '@rollup/rollup-openharmony-arm64@4.59.0': optional: true - '@rollup/rollup-openharmony-arm64@4.50.1': + '@rollup/rollup-win32-arm64-msvc@4.59.0': optional: true - '@rollup/rollup-win32-arm64-msvc@4.50.1': + '@rollup/rollup-win32-ia32-msvc@4.59.0': optional: true - '@rollup/rollup-win32-ia32-msvc@4.50.1': + '@rollup/rollup-win32-x64-gnu@4.59.0': optional: true - '@rollup/rollup-win32-x64-msvc@4.50.1': + '@rollup/rollup-win32-x64-msvc@4.59.0': optional: true '@selderee/plugin-htmlparser2@0.11.0': @@ -11299,31 +11333,35 @@ snapshots: rfdc@1.4.1: {} - rollup@4.50.1: + rollup@4.59.0: dependencies: '@types/estree': 1.0.8 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.50.1 - '@rollup/rollup-android-arm64': 4.50.1 - '@rollup/rollup-darwin-arm64': 4.50.1 - '@rollup/rollup-darwin-x64': 4.50.1 - '@rollup/rollup-freebsd-arm64': 4.50.1 - '@rollup/rollup-freebsd-x64': 4.50.1 - '@rollup/rollup-linux-arm-gnueabihf': 4.50.1 - '@rollup/rollup-linux-arm-musleabihf': 4.50.1 - '@rollup/rollup-linux-arm64-gnu': 4.50.1 - '@rollup/rollup-linux-arm64-musl': 4.50.1 - '@rollup/rollup-linux-loongarch64-gnu': 4.50.1 - '@rollup/rollup-linux-ppc64-gnu': 4.50.1 - '@rollup/rollup-linux-riscv64-gnu': 4.50.1 - '@rollup/rollup-linux-riscv64-musl': 4.50.1 - '@rollup/rollup-linux-s390x-gnu': 4.50.1 - '@rollup/rollup-linux-x64-gnu': 4.50.1 - '@rollup/rollup-linux-x64-musl': 4.50.1 - '@rollup/rollup-openharmony-arm64': 4.50.1 - '@rollup/rollup-win32-arm64-msvc': 4.50.1 - '@rollup/rollup-win32-ia32-msvc': 4.50.1 - '@rollup/rollup-win32-x64-msvc': 4.50.1 + '@rollup/rollup-android-arm-eabi': 4.59.0 + '@rollup/rollup-android-arm64': 4.59.0 + '@rollup/rollup-darwin-arm64': 4.59.0 + '@rollup/rollup-darwin-x64': 4.59.0 + '@rollup/rollup-freebsd-arm64': 4.59.0 + '@rollup/rollup-freebsd-x64': 4.59.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.59.0 + '@rollup/rollup-linux-arm-musleabihf': 4.59.0 + '@rollup/rollup-linux-arm64-gnu': 4.59.0 + '@rollup/rollup-linux-arm64-musl': 4.59.0 + '@rollup/rollup-linux-loong64-gnu': 4.59.0 + '@rollup/rollup-linux-loong64-musl': 4.59.0 + '@rollup/rollup-linux-ppc64-gnu': 4.59.0 + '@rollup/rollup-linux-ppc64-musl': 4.59.0 + '@rollup/rollup-linux-riscv64-gnu': 4.59.0 + '@rollup/rollup-linux-riscv64-musl': 4.59.0 + '@rollup/rollup-linux-s390x-gnu': 4.59.0 + '@rollup/rollup-linux-x64-gnu': 4.59.0 + '@rollup/rollup-linux-x64-musl': 4.59.0 + '@rollup/rollup-openbsd-x64': 4.59.0 + '@rollup/rollup-openharmony-arm64': 4.59.0 + '@rollup/rollup-win32-arm64-msvc': 4.59.0 + '@rollup/rollup-win32-ia32-msvc': 4.59.0 + '@rollup/rollup-win32-x64-gnu': 4.59.0 + '@rollup/rollup-win32-x64-msvc': 4.59.0 fsevents: 2.3.3 run-parallel@1.2.0: @@ -11851,9 +11889,9 @@ snapshots: value-or-promise@1.0.12: {} - vite-plugin-svgr@4.5.0(rollup@4.50.1)(typescript@5.9.3)(vite@7.1.12(@types/node@22.13.11)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1)): + vite-plugin-svgr@4.5.0(rollup@4.59.0)(typescript@5.9.3)(vite@7.1.12(@types/node@22.13.11)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1)): dependencies: - '@rollup/pluginutils': 5.3.0(rollup@4.50.1) + '@rollup/pluginutils': 5.3.0(rollup@4.59.0) '@svgr/core': 8.1.0(typescript@5.9.3) '@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0(typescript@5.9.3)) vite: 7.1.12(@types/node@22.13.11)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1) @@ -11879,7 +11917,7 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 postcss: 8.5.6 - rollup: 4.50.1 + rollup: 4.59.0 tinyglobby: 0.2.15 optionalDependencies: '@types/node': 22.13.11 diff --git a/examples/todo-with-better-auth/pnpm-lock.yaml b/examples/todo-with-better-auth/pnpm-lock.yaml index 74b9c148f..258089b25 100644 --- a/examples/todo-with-better-auth/pnpm-lock.yaml +++ b/examples/todo-with-better-auth/pnpm-lock.yaml @@ -191,7 +191,7 @@ importers: version: 7.1.12(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1) vite-plugin-svgr: specifier: 4.5.0 - version: 4.5.0(rollup@4.50.1)(typescript@5.9.3)(vite@7.1.12(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1)) + version: 4.5.0(rollup@4.59.0)(typescript@5.9.3)(vite@7.1.12(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1)) vite-tsconfig-paths: specifier: 5.1.4 version: 5.1.4(typescript@5.9.3)(vite@7.1.12(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1)) @@ -559,7 +559,7 @@ importers: version: 7.1.12(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1) vite-plugin-svgr: specifier: 4.5.0 - version: 4.5.0(rollup@4.50.1)(typescript@5.9.3)(vite@7.1.12(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1)) + version: 4.5.0(rollup@4.59.0)(typescript@5.9.3)(vite@7.1.12(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1)) vite-tsconfig-paths: specifier: 5.1.4 version: 5.1.4(typescript@5.9.3)(vite@7.1.12(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1)) @@ -2536,119 +2536,141 @@ packages: rollup: optional: true - '@rollup/rollup-android-arm-eabi@4.50.1': - resolution: {integrity: sha512-HJXwzoZN4eYTdD8bVV22DN8gsPCAj3V20NHKOs8ezfXanGpmVPR7kalUHd+Y31IJp9stdB87VKPFbsGY3H/2ag==} + '@rollup/rollup-android-arm-eabi@4.59.0': + resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.50.1': - resolution: {integrity: sha512-PZlsJVcjHfcH53mOImyt3bc97Ep3FJDXRpk9sMdGX0qgLmY0EIWxCag6EigerGhLVuL8lDVYNnSo8qnTElO4xw==} + '@rollup/rollup-android-arm64@4.59.0': + resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.50.1': - resolution: {integrity: sha512-xc6i2AuWh++oGi4ylOFPmzJOEeAa2lJeGUGb4MudOtgfyyjr4UPNK+eEWTPLvmPJIY/pgw6ssFIox23SyrkkJw==} + '@rollup/rollup-darwin-arm64@4.59.0': + resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.50.1': - resolution: {integrity: sha512-2ofU89lEpDYhdLAbRdeyz/kX3Y2lpYc6ShRnDjY35bZhd2ipuDMDi6ZTQ9NIag94K28nFMofdnKeHR7BT0CATw==} + '@rollup/rollup-darwin-x64@4.59.0': + resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.50.1': - resolution: {integrity: sha512-wOsE6H2u6PxsHY/BeFHA4VGQN3KUJFZp7QJBmDYI983fgxq5Th8FDkVuERb2l9vDMs1D5XhOrhBrnqcEY6l8ZA==} + '@rollup/rollup-freebsd-arm64@4.59.0': + resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.50.1': - resolution: {integrity: sha512-A/xeqaHTlKbQggxCqispFAcNjycpUEHP52mwMQZUNqDUJFFYtPHCXS1VAG29uMlDzIVr+i00tSFWFLivMcoIBQ==} + '@rollup/rollup-freebsd-x64@4.59.0': + resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.50.1': - resolution: {integrity: sha512-54v4okehwl5TaSIkpp97rAHGp7t3ghinRd/vyC1iXqXMfjYUTm7TfYmCzXDoHUPTTf36L8pr0E7YsD3CfB3ZDg==} + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} cpu: [arm] os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm-musleabihf@4.50.1': - resolution: {integrity: sha512-p/LaFyajPN/0PUHjv8TNyxLiA7RwmDoVY3flXHPSzqrGcIp/c2FjwPPP5++u87DGHtw+5kSH5bCJz0mvXngYxw==} + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} cpu: [arm] os: [linux] libc: [musl] - '@rollup/rollup-linux-arm64-gnu@4.50.1': - resolution: {integrity: sha512-2AbMhFFkTo6Ptna1zO7kAXXDLi7H9fGTbVaIq2AAYO7yzcAsuTNWPHhb2aTA6GPiP+JXh85Y8CiS54iZoj4opw==} + '@rollup/rollup-linux-arm64-gnu@4.59.0': + resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} cpu: [arm64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm64-musl@4.50.1': - resolution: {integrity: sha512-Cgef+5aZwuvesQNw9eX7g19FfKX5/pQRIyhoXLCiBOrWopjo7ycfB292TX9MDcDijiuIJlx1IzJz3IoCPfqs9w==} + '@rollup/rollup-linux-arm64-musl@4.59.0': + resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} cpu: [arm64] os: [linux] libc: [musl] - '@rollup/rollup-linux-loongarch64-gnu@4.50.1': - resolution: {integrity: sha512-RPhTwWMzpYYrHrJAS7CmpdtHNKtt2Ueo+BlLBjfZEhYBhK00OsEqM08/7f+eohiF6poe0YRDDd8nAvwtE/Y62Q==} + '@rollup/rollup-linux-loong64-gnu@4.59.0': + resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} cpu: [loong64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-ppc64-gnu@4.50.1': - resolution: {integrity: sha512-eSGMVQw9iekut62O7eBdbiccRguuDgiPMsw++BVUg+1K7WjZXHOg/YOT9SWMzPZA+w98G+Fa1VqJgHZOHHnY0Q==} + '@rollup/rollup-linux-loong64-musl@4.59.0': + resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} cpu: [ppc64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-riscv64-gnu@4.50.1': - resolution: {integrity: sha512-S208ojx8a4ciIPrLgazF6AgdcNJzQE4+S9rsmOmDJkusvctii+ZvEuIC4v/xFqzbuP8yDjn73oBlNDgF6YGSXQ==} + '@rollup/rollup-linux-ppc64-musl@4.59.0': + resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} cpu: [riscv64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-riscv64-musl@4.50.1': - resolution: {integrity: sha512-3Ag8Ls1ggqkGUvSZWYcdgFwriy2lWo+0QlYgEFra/5JGtAd6C5Hw59oojx1DeqcA2Wds2ayRgvJ4qxVTzCHgzg==} + '@rollup/rollup-linux-riscv64-musl@4.59.0': + resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} cpu: [riscv64] os: [linux] libc: [musl] - '@rollup/rollup-linux-s390x-gnu@4.50.1': - resolution: {integrity: sha512-t9YrKfaxCYe7l7ldFERE1BRg/4TATxIg+YieHQ966jwvo7ddHJxPj9cNFWLAzhkVsbBvNA4qTbPVNsZKBO4NSg==} + '@rollup/rollup-linux-s390x-gnu@4.59.0': + resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} cpu: [s390x] os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-gnu@4.50.1': - resolution: {integrity: sha512-MCgtFB2+SVNuQmmjHf+wfI4CMxy3Tk8XjA5Z//A0AKD7QXUYFMQcns91K6dEHBvZPCnhJSyDWLApk40Iq/H3tA==} + '@rollup/rollup-linux-x64-gnu@4.59.0': + resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} cpu: [x64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-musl@4.50.1': - resolution: {integrity: sha512-nEvqG+0jeRmqaUMuwzlfMKwcIVffy/9KGbAGyoa26iu6eSngAYQ512bMXuqqPrlTyfqdlB9FVINs93j534UJrg==} + '@rollup/rollup-linux-x64-musl@4.59.0': + resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} cpu: [x64] os: [linux] libc: [musl] - '@rollup/rollup-openharmony-arm64@4.50.1': - resolution: {integrity: sha512-RDsLm+phmT3MJd9SNxA9MNuEAO/J2fhW8GXk62G/B4G7sLVumNFbRwDL6v5NrESb48k+QMqdGbHgEtfU0LCpbA==} + '@rollup/rollup-openbsd-x64@4.59.0': + resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.59.0': + resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==} cpu: [arm64] os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.50.1': - resolution: {integrity: sha512-hpZB/TImk2FlAFAIsoElM3tLzq57uxnGYwplg6WDyAxbYczSi8O2eQ+H2Lx74504rwKtZ3N2g4bCUkiamzS6TQ==} + '@rollup/rollup-win32-arm64-msvc@4.59.0': + resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.50.1': - resolution: {integrity: sha512-SXjv8JlbzKM0fTJidX4eVsH+Wmnp0/WcD8gJxIZyR6Gay5Qcsmdbi9zVtnbkGPG8v2vMR1AD06lGWy5FLMcG7A==} + '@rollup/rollup-win32-ia32-msvc@4.59.0': + resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.50.1': - resolution: {integrity: sha512-StxAO/8ts62KZVRAm4JZYq9+NqNsV7RvimNK+YM7ry//zebEH6meuugqW/P5OFUCjyQgui+9fUxT6d5NShvMvA==} + '@rollup/rollup-win32-x64-gnu@4.59.0': + resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.59.0': + resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==} cpu: [x64] os: [win32] @@ -5762,8 +5784,8 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} - rollup@4.50.1: - resolution: {integrity: sha512-78E9voJHwnXQMiQdiqswVLZwJIzdBKJ1GdI5Zx6XwoFKUIk09/sSrr+05QFzvYb8q6Y9pPV45zzDuYa3907TZA==} + rollup@4.59.0: + resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true @@ -9005,75 +9027,87 @@ snapshots: '@rolldown/pluginutils@1.0.0-beta.34': {} - '@rollup/pluginutils@5.3.0(rollup@4.50.1)': + '@rollup/pluginutils@5.3.0(rollup@4.59.0)': dependencies: '@types/estree': 1.0.8 estree-walker: 2.0.2 picomatch: 4.0.3 optionalDependencies: - rollup: 4.50.1 + rollup: 4.59.0 + + '@rollup/rollup-android-arm-eabi@4.59.0': + optional: true + + '@rollup/rollup-android-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-x64@4.59.0': + optional: true - '@rollup/rollup-android-arm-eabi@4.50.1': + '@rollup/rollup-freebsd-arm64@4.59.0': optional: true - '@rollup/rollup-android-arm64@4.50.1': + '@rollup/rollup-freebsd-x64@4.59.0': optional: true - '@rollup/rollup-darwin-arm64@4.50.1': + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': optional: true - '@rollup/rollup-darwin-x64@4.50.1': + '@rollup/rollup-linux-arm-musleabihf@4.59.0': optional: true - '@rollup/rollup-freebsd-arm64@4.50.1': + '@rollup/rollup-linux-arm64-gnu@4.59.0': optional: true - '@rollup/rollup-freebsd-x64@4.50.1': + '@rollup/rollup-linux-arm64-musl@4.59.0': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.50.1': + '@rollup/rollup-linux-loong64-gnu@4.59.0': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.50.1': + '@rollup/rollup-linux-loong64-musl@4.59.0': optional: true - '@rollup/rollup-linux-arm64-gnu@4.50.1': + '@rollup/rollup-linux-ppc64-gnu@4.59.0': optional: true - '@rollup/rollup-linux-arm64-musl@4.50.1': + '@rollup/rollup-linux-ppc64-musl@4.59.0': optional: true - '@rollup/rollup-linux-loongarch64-gnu@4.50.1': + '@rollup/rollup-linux-riscv64-gnu@4.59.0': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.50.1': + '@rollup/rollup-linux-riscv64-musl@4.59.0': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.50.1': + '@rollup/rollup-linux-s390x-gnu@4.59.0': optional: true - '@rollup/rollup-linux-riscv64-musl@4.50.1': + '@rollup/rollup-linux-x64-gnu@4.59.0': optional: true - '@rollup/rollup-linux-s390x-gnu@4.50.1': + '@rollup/rollup-linux-x64-musl@4.59.0': optional: true - '@rollup/rollup-linux-x64-gnu@4.50.1': + '@rollup/rollup-openbsd-x64@4.59.0': optional: true - '@rollup/rollup-linux-x64-musl@4.50.1': + '@rollup/rollup-openharmony-arm64@4.59.0': optional: true - '@rollup/rollup-openharmony-arm64@4.50.1': + '@rollup/rollup-win32-arm64-msvc@4.59.0': optional: true - '@rollup/rollup-win32-arm64-msvc@4.50.1': + '@rollup/rollup-win32-ia32-msvc@4.59.0': optional: true - '@rollup/rollup-win32-ia32-msvc@4.50.1': + '@rollup/rollup-win32-x64-gnu@4.59.0': optional: true - '@rollup/rollup-win32-x64-msvc@4.50.1': + '@rollup/rollup-win32-x64-msvc@4.59.0': optional: true '@sentry-internal/browser-utils@10.39.0': @@ -12493,31 +12527,35 @@ snapshots: rfdc@1.4.1: {} - rollup@4.50.1: + rollup@4.59.0: dependencies: '@types/estree': 1.0.8 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.50.1 - '@rollup/rollup-android-arm64': 4.50.1 - '@rollup/rollup-darwin-arm64': 4.50.1 - '@rollup/rollup-darwin-x64': 4.50.1 - '@rollup/rollup-freebsd-arm64': 4.50.1 - '@rollup/rollup-freebsd-x64': 4.50.1 - '@rollup/rollup-linux-arm-gnueabihf': 4.50.1 - '@rollup/rollup-linux-arm-musleabihf': 4.50.1 - '@rollup/rollup-linux-arm64-gnu': 4.50.1 - '@rollup/rollup-linux-arm64-musl': 4.50.1 - '@rollup/rollup-linux-loongarch64-gnu': 4.50.1 - '@rollup/rollup-linux-ppc64-gnu': 4.50.1 - '@rollup/rollup-linux-riscv64-gnu': 4.50.1 - '@rollup/rollup-linux-riscv64-musl': 4.50.1 - '@rollup/rollup-linux-s390x-gnu': 4.50.1 - '@rollup/rollup-linux-x64-gnu': 4.50.1 - '@rollup/rollup-linux-x64-musl': 4.50.1 - '@rollup/rollup-openharmony-arm64': 4.50.1 - '@rollup/rollup-win32-arm64-msvc': 4.50.1 - '@rollup/rollup-win32-ia32-msvc': 4.50.1 - '@rollup/rollup-win32-x64-msvc': 4.50.1 + '@rollup/rollup-android-arm-eabi': 4.59.0 + '@rollup/rollup-android-arm64': 4.59.0 + '@rollup/rollup-darwin-arm64': 4.59.0 + '@rollup/rollup-darwin-x64': 4.59.0 + '@rollup/rollup-freebsd-arm64': 4.59.0 + '@rollup/rollup-freebsd-x64': 4.59.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.59.0 + '@rollup/rollup-linux-arm-musleabihf': 4.59.0 + '@rollup/rollup-linux-arm64-gnu': 4.59.0 + '@rollup/rollup-linux-arm64-musl': 4.59.0 + '@rollup/rollup-linux-loong64-gnu': 4.59.0 + '@rollup/rollup-linux-loong64-musl': 4.59.0 + '@rollup/rollup-linux-ppc64-gnu': 4.59.0 + '@rollup/rollup-linux-ppc64-musl': 4.59.0 + '@rollup/rollup-linux-riscv64-gnu': 4.59.0 + '@rollup/rollup-linux-riscv64-musl': 4.59.0 + '@rollup/rollup-linux-s390x-gnu': 4.59.0 + '@rollup/rollup-linux-x64-gnu': 4.59.0 + '@rollup/rollup-linux-x64-musl': 4.59.0 + '@rollup/rollup-openbsd-x64': 4.59.0 + '@rollup/rollup-openharmony-arm64': 4.59.0 + '@rollup/rollup-win32-arm64-msvc': 4.59.0 + '@rollup/rollup-win32-ia32-msvc': 4.59.0 + '@rollup/rollup-win32-x64-gnu': 4.59.0 + '@rollup/rollup-win32-x64-msvc': 4.59.0 fsevents: 2.3.3 rou3@0.7.12: {} @@ -13054,9 +13092,9 @@ snapshots: value-or-promise@1.0.12: {} - vite-plugin-svgr@4.5.0(rollup@4.50.1)(typescript@5.9.3)(vite@7.1.12(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1)): + vite-plugin-svgr@4.5.0(rollup@4.59.0)(typescript@5.9.3)(vite@7.1.12(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1)): dependencies: - '@rollup/pluginutils': 5.3.0(rollup@4.50.1) + '@rollup/pluginutils': 5.3.0(rollup@4.59.0) '@svgr/core': 8.1.0(typescript@5.9.3) '@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0(typescript@5.9.3)) vite: 7.1.12(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1) @@ -13082,7 +13120,7 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 postcss: 8.5.6 - rollup: 4.50.1 + rollup: 4.59.0 tinyglobby: 0.2.15 optionalDependencies: '@types/node': 22.19.1 diff --git a/packages/project-builder-cli/src/utils/create-service-action-context.ts b/packages/project-builder-cli/src/utils/create-service-action-context.ts index 812f5fd17..56e574fe1 100644 --- a/packages/project-builder-cli/src/utils/create-service-action-context.ts +++ b/packages/project-builder-cli/src/utils/create-service-action-context.ts @@ -23,5 +23,6 @@ export async function createServiceActionContext( userConfig, plugins, cliVersion, + sessionId: 'default', }; } diff --git a/packages/project-builder-dev/src/commands/definition.ts b/packages/project-builder-dev/src/commands/definition.ts new file mode 100644 index 000000000..00f1204ff --- /dev/null +++ b/packages/project-builder-dev/src/commands/definition.ts @@ -0,0 +1,243 @@ +import type { Command } from 'commander'; + +import { + commitDraftAction, + discardDraftAction, + getEntityAction, + getEntitySchemaAction, + invokeServiceActionAsCli, + listEntitiesAction, + listEntityTypesAction, + showDraftAction, + stageCreateEntityAction, + stageDeleteEntityAction, + stageUpdateEntityAction, +} from '@baseplate-dev/project-builder-server/actions'; + +import { createServiceActionContext } from '#src/utils/create-service-action-context.js'; +import { resolveProject } from '#src/utils/list-projects.js'; + +/** + * Adds definition inspection and mutation commands to the program. + * @param program - The program to add the commands to. + */ +export function addDefinitionCommand(program: Command): void { + const definitionCommand = program + .command('definition') + .alias('def') + .description('Inspect and modify project definition entities'); + + // --- Read commands --- + + definitionCommand + .command('list-entity-types [project]') + .description('List all available entity types in the project definition') + .action(async (project: string | undefined) => { + const resolvedProject = await resolveProject(project); + const context = await createServiceActionContext(resolvedProject); + + await invokeServiceActionAsCli( + listEntityTypesAction, + { project: resolvedProject.name }, + context, + ); + }); + + definitionCommand + .command('list-entities [project]') + .description('List entities of a given type in the project definition') + .option( + '--parent ', + 'Parent entity ID (required for nested types)', + ) + .action( + async ( + entityType: string, + project: string | undefined, + options: { parent?: string }, + ) => { + const resolvedProject = await resolveProject(project); + const context = await createServiceActionContext(resolvedProject); + + await invokeServiceActionAsCli( + listEntitiesAction, + { + project: resolvedProject.name, + entityTypeName: entityType, + parentEntityId: options.parent, + }, + context, + ); + }, + ); + + definitionCommand + .command('get-entity [project]') + .description('Get the full serialized data for a specific entity by ID') + .action(async (entityId: string, project: string | undefined) => { + const resolvedProject = await resolveProject(project); + const context = await createServiceActionContext(resolvedProject); + + await invokeServiceActionAsCli( + getEntityAction, + { project: resolvedProject.name, entityId }, + context, + ); + }); + + definitionCommand + .command('get-entity-schema [project]') + .description('Get the JSON Schema for a given entity type') + .action(async (entityType: string, project: string | undefined) => { + const resolvedProject = await resolveProject(project); + const context = await createServiceActionContext(resolvedProject); + + await invokeServiceActionAsCli( + getEntitySchemaAction, + { project: resolvedProject.name, entityTypeName: entityType }, + context, + ); + }); + + // --- Write commands --- + + definitionCommand + .command('stage-create [project]') + .description('Stage a new entity creation in the draft session') + .option( + '--parent ', + 'Parent entity ID (required for nested types)', + ) + .action( + async ( + entityType: string, + entityDataJson: string, + project: string | undefined, + options: { parent?: string }, + ) => { + const resolvedProject = await resolveProject(project); + const context = await createServiceActionContext(resolvedProject); + let entityData: Record; + try { + entityData = JSON.parse(entityDataJson) as Record; + } catch { + throw new Error( + 'Invalid JSON for entity data. Please provide valid JSON.', + ); + } + + await invokeServiceActionAsCli( + stageCreateEntityAction, + { + project: resolvedProject.name, + entityTypeName: entityType, + entityData, + parentEntityId: options.parent, + }, + context, + ); + }, + ); + + definitionCommand + .command('stage-update [project]') + .description('Stage an entity update in the draft session') + .action( + async ( + entityType: string, + entityId: string, + entityDataJson: string, + project: string | undefined, + ) => { + const resolvedProject = await resolveProject(project); + const context = await createServiceActionContext(resolvedProject); + let entityData: Record; + try { + entityData = JSON.parse(entityDataJson) as Record; + } catch { + throw new Error( + 'Invalid JSON for entity data. Please provide valid JSON.', + ); + } + + await invokeServiceActionAsCli( + stageUpdateEntityAction, + { + project: resolvedProject.name, + entityTypeName: entityType, + entityId, + entityData, + }, + context, + ); + }, + ); + + definitionCommand + .command('stage-delete [project]') + .description('Stage an entity deletion in the draft session') + .action( + async ( + entityType: string, + entityId: string, + project: string | undefined, + ) => { + const resolvedProject = await resolveProject(project); + const context = await createServiceActionContext(resolvedProject); + + await invokeServiceActionAsCli( + stageDeleteEntityAction, + { + project: resolvedProject.name, + entityTypeName: entityType, + entityId, + }, + context, + ); + }, + ); + + // --- Draft management commands --- + + definitionCommand + .command('commit [project]') + .description('Commit the draft session to project-definition.json') + .action(async (project: string | undefined) => { + const resolvedProject = await resolveProject(project); + const context = await createServiceActionContext(resolvedProject); + + await invokeServiceActionAsCli( + commitDraftAction, + { project: resolvedProject.name }, + context, + ); + }); + + definitionCommand + .command('discard [project]') + .description('Discard the current draft session') + .action(async (project: string | undefined) => { + const resolvedProject = await resolveProject(project); + const context = await createServiceActionContext(resolvedProject); + + await invokeServiceActionAsCli( + discardDraftAction, + { project: resolvedProject.name }, + context, + ); + }); + + definitionCommand + .command('show-draft [project]') + .description('Show the current draft session status') + .action(async (project: string | undefined) => { + const resolvedProject = await resolveProject(project); + const context = await createServiceActionContext(resolvedProject); + + await invokeServiceActionAsCli( + showDraftAction, + { project: resolvedProject.name }, + context, + ); + }); +} diff --git a/packages/project-builder-dev/src/index.ts b/packages/project-builder-dev/src/index.ts index 76ef4c551..df4804212 100644 --- a/packages/project-builder-dev/src/index.ts +++ b/packages/project-builder-dev/src/index.ts @@ -1,6 +1,7 @@ import { getPackageVersion } from '@baseplate-dev/utils/node'; import { program } from 'commander'; +import { addDefinitionCommand } from './commands/definition.js'; import { addDevServerCommand } from './commands/dev-server.js'; import { addDiffCommand } from './commands/diff.js'; import { addInitCommand } from './commands/init.js'; @@ -24,6 +25,7 @@ export async function runDevCli(): Promise { addSyncCommand(program); addDiffCommand(program); + addDefinitionCommand(program); addTemplatesCommand(program); addSnapshotCommand(program); addSyncExamplesCommand(program); diff --git a/packages/project-builder-dev/src/utils/create-service-action-context.ts b/packages/project-builder-dev/src/utils/create-service-action-context.ts index 5231a34cc..41836a937 100644 --- a/packages/project-builder-dev/src/utils/create-service-action-context.ts +++ b/packages/project-builder-dev/src/utils/create-service-action-context.ts @@ -52,5 +52,6 @@ export async function createServiceActionContext( userConfig, plugins, cliVersion, + sessionId: 'default', }; } diff --git a/packages/project-builder-lib/package.json b/packages/project-builder-lib/package.json index 25452be34..6bede57a0 100644 --- a/packages/project-builder-lib/package.json +++ b/packages/project-builder-lib/package.json @@ -57,7 +57,7 @@ "culori": "^4.0.1", "es-toolkit": "1.44.0", "globby": "^14.0.2", - "immer": "10.1.1", + "immer": "~11.1.4", "inflection": "3.0.0", "react": "catalog:", "react-hook-form": "7.71.1", diff --git a/packages/project-builder-lib/src/definition/index.ts b/packages/project-builder-lib/src/definition/index.ts index 2f40f909f..fb2317e25 100644 --- a/packages/project-builder-lib/src/definition/index.ts +++ b/packages/project-builder-lib/src/definition/index.ts @@ -7,4 +7,3 @@ export * from './model/model-utils.js'; export * from './packages/package-utils.js'; export * from './plugins/index.js'; export * from './project-definition-container.js'; -export * from './project-definition-container.test-utils.js'; diff --git a/packages/project-builder-lib/src/definition/plugins/plugin-utils.unit.test.ts b/packages/project-builder-lib/src/definition/plugins/plugin-utils.unit.test.ts index edc6ae7c8..e87a90121 100644 --- a/packages/project-builder-lib/src/definition/plugins/plugin-utils.unit.test.ts +++ b/packages/project-builder-lib/src/definition/plugins/plugin-utils.unit.test.ts @@ -3,16 +3,16 @@ import { describe, expect, test } from 'vitest'; import type { PluginConfigMigration } from '#src/plugins/spec/config-spec.js'; import type { BasePluginDefinition } from '#src/schema/plugins/definition.js'; -import { - createTestProjectDefinition, - createTestProjectDefinitionContainer, -} from '#src/definition/project-definition-container.test-utils.js'; import { createPluginModule } from '#src/plugins/index.js'; import { createTestMigration, createTestPluginMetadata, } from '#src/plugins/plugins.test-utils.js'; import { pluginConfigSpec } from '#src/plugins/spec/config-spec.js'; +import { + createTestProjectDefinition, + createTestProjectDefinitionContainer, +} from '#src/testing/project-definition-container.test-helper.js'; import { PluginUtils } from './plugin-utils.js'; diff --git a/packages/project-builder-lib/src/definition/project-definition-container.ts b/packages/project-builder-lib/src/definition/project-definition-container.ts index 3fbaa49f7..b20111df9 100644 --- a/packages/project-builder-lib/src/definition/project-definition-container.ts +++ b/packages/project-builder-lib/src/definition/project-definition-container.ts @@ -13,20 +13,23 @@ import type { ProjectDefinition, ProjectDefinitionSchema, } from '#src/schema/index.js'; +import type { + EntityServiceContext, + EntityTypeMap, +} from '#src/tools/entity-service/types.js'; -import { - createPluginSpecStore, - parseProjectDefinitionWithReferences, -} from '#src/parser/parser.js'; +import { createPluginSpecStore } from '#src/parser/parser.js'; import { deserializeSchemaWithTransformedReferences, fixRefDeletions, serializeSchema, + serializeSchemaFromRefPayload, } from '#src/references/index.js'; import { createDefinitionSchemaParserContext, createProjectDefinitionSchema, } from '#src/schema/index.js'; +import { collectEntityMetadata } from '#src/tools/entity-service/entity-type-map.js'; /** * Container for a project definition that includes references and entities. @@ -43,6 +46,8 @@ export class ProjectDefinitionContainer { pluginStore: PluginSpecStore; schema: ProjectDefinitionSchema; + private _entityTypeMap: EntityTypeMap | undefined; + constructor( config: ResolvedZodRefPayload, parserContext: SchemaParserContext, @@ -115,37 +120,37 @@ export class ProjectDefinitionContainer { } /** - * Serializes the project definition resolving references to their names for easier reading. + * Creates an EntityServiceContext for use with entity read/write operations. * - * @returns The serialized contents of the project definition + * Builds the entity type map from the schema and serializes the definition + * with references resolved to names. */ - toSerializedContents(): string { - const serializedContents = serializeSchema(this.schema, this.definition); - return stringifyPrettyStable(serializedContents); + private getEntityTypeMap(): EntityTypeMap { + this._entityTypeMap ??= collectEntityMetadata(this.schema); + return this._entityTypeMap; + } + + toEntityServiceContext(): EntityServiceContext { + const entityTypeMap = this.getEntityTypeMap(); + const serializedDefinition = serializeSchemaFromRefPayload( + this.refPayload, + ) as Record; + + return { + serializedDefinition, + entityTypeMap, + lookupEntity: (id) => this.entityFromId(id), + }; } /** - * Creates a new ProjectDefinitionContainer from a raw project definition. + * Serializes the project definition resolving references to their names for easier reading. * - * @param definition The raw project definition - * @param context The parser context to use - * @returns A new ProjectDefinitionContainer + * @returns The serialized contents of the project definition */ - static fromDefinition( - definition: ProjectDefinition, - context: SchemaParserContext, - ): ProjectDefinitionContainer { - const { definition: parsedDefinition, pluginStore } = - parseProjectDefinitionWithReferences(definition, context); - const schema = createProjectDefinitionSchema( - createDefinitionSchemaParserContext({ plugins: pluginStore }), - ); - return new ProjectDefinitionContainer( - parsedDefinition, - context, - pluginStore, - schema, - ); + toSerializedContents(): string { + const serializedContents = serializeSchema(this.schema, this.definition); + return stringifyPrettyStable(serializedContents); } /** diff --git a/packages/project-builder-lib/src/parser/walk-schema-structure.ts b/packages/project-builder-lib/src/parser/walk-schema-structure.ts new file mode 100644 index 000000000..c219247af --- /dev/null +++ b/packages/project-builder-lib/src/parser/walk-schema-structure.ts @@ -0,0 +1,284 @@ +import type { z, ZodDiscriminatedUnion } from 'zod'; + +import { getSchemaChildren } from './schema-structure.js'; + +// --------------------------------------------------------------------------- +// Path types +// --------------------------------------------------------------------------- + +/** + * Represents a single deterministic step in navigating from a parent entity + * (or definition root) to a child entity array. + * + * Only deterministic navigation steps are represented: + * - `object-key`: navigate into an object property + * - `tuple-index`: navigate into a specific tuple position + * - `discriminated-union-array`: enter an array and pick the unique element + * matching a discriminator value + * + * Non-deterministic structures (plain arrays, records) are not represented + * in the path — the walker descends into them without adding path elements. + */ +export type SchemaPathElement = + | { type: 'object-key'; key: string } + | { type: 'tuple-index'; index: number } + | { + type: 'discriminated-union-array'; + discriminatorKey: string; + value: string; + }; + +// --------------------------------------------------------------------------- +// Visitor types +// --------------------------------------------------------------------------- + +/** + * The context passed to visitors during a schema structure walk. + * Carries the current path as `SchemaPathElement[]`. + */ +export interface SchemaStructureWalkContext { + /** The absolute path to the current node in the schema. */ + readonly path: SchemaPathElement[]; +} + +/** + * A visitor that plugs into `walkSchemaStructure`. + * + * Called for every node in the schema tree. If the visitor returns a + * cleanup function, it will be called after all children have been visited — + * similar to the React `useEffect` cleanup pattern. + */ +export interface SchemaStructureVisitor { + visit( + schema: z.ZodType, + ctx: SchemaStructureWalkContext, + ): (() => void) | undefined; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Walks a Zod schema structure (without data) invoking registered visitors + * at every schema node. + * + * Unlike `walkDataWithSchema`, this operates on the schema alone. + * Only deterministic navigation steps produce path elements: + * - Object keys and tuple indices add path elements + * - Arrays of discriminated unions add `discriminated-union-array` elements + * (one per branch) + * - Discriminated unions on objects are transparent (no path element) + * - Plain arrays and records are descended into without path elements + * + * Uses a `Set` circular-reference guard with delete-on-backtrack + * so the same schema can appear at different paths. + */ +export function walkSchemaStructure( + schema: z.ZodType, + visitors: readonly SchemaStructureVisitor[], +): void { + walkNode(schema, [], visitors, new Set()); +} + +// --------------------------------------------------------------------------- +// Internal walker +// --------------------------------------------------------------------------- + +function walkNode( + schema: z.ZodType, + path: SchemaPathElement[], + visitors: readonly SchemaStructureVisitor[], + visited: Set, +): void { + // Circular reference guard + if (visited.has(schema)) { + return; + } + visited.add(schema); + + const ctx: SchemaStructureWalkContext = { path }; + + // Step 1: Call all visitors, collect any cleanup functions returned. + const cleanups: (() => void)[] = []; + for (const visitor of visitors) { + const cleanup = visitor.visit(schema, ctx); + if (cleanup) cleanups.push(cleanup); + } + + // Step 2: Structural descent based on schema children (no data). + const children = getSchemaChildren(schema, undefined, []); + switch (children.kind) { + case 'leaf': + case 'leaf-union': { + break; + } + + case 'wrapper': { + walkNode(children.innerSchema, path, visitors, visited); + break; + } + + case 'object': { + for (const [key, fieldSchema] of children.entries) { + walkNode( + fieldSchema, + [...path, { type: 'object-key', key }], + visitors, + visited, + ); + } + break; + } + + case 'array': { + // Check if the element schema is a discriminated union. + // If so, walk each branch with a discriminated-union-array path element. + // Otherwise, walk the element schema with no path element (non-deterministic). + const unwrappedElement = unwrapSchema(children.elementSchema); + const elementChildren = getSchemaChildren( + unwrappedElement, + undefined, + [], + ); + + if (elementChildren.kind === 'discriminated-union') { + walkDiscriminatedUnionArrayBranches( + unwrappedElement as ZodDiscriminatedUnion, + path, + visitors, + visited, + ); + } else { + // Plain array — descend without adding a path element + walkNode(children.elementSchema, path, visitors, visited); + } + break; + } + + case 'discriminated-union': { + // Transparent on objects — walk all branches with the same path. + const unwrapped = unwrapSchema(schema) as ZodDiscriminatedUnion; + for (const option of unwrapped.options as z.ZodType[]) { + walkNode(option, path, visitors, visited); + } + break; + } + + case 'tuple': { + for (const [i, itemSchema] of children.items.entries()) { + walkNode( + itemSchema, + [...path, { type: 'tuple-index', index: i }], + visitors, + visited, + ); + } + if (children.rest) { + // Rest elements don't have a fixed index; walk without path element + walkNode(children.rest, path, visitors, visited); + } + break; + } + + case 'record': { + // Non-deterministic — walk without path element + walkNode(children.valueSchema, path, visitors, visited); + break; + } + + case 'intersection': { + walkNode(children.left, path, visitors, visited); + walkNode(children.right, path, visitors, visited); + break; + } + } + + // Step 3: Run cleanup functions in reverse order (innermost first). + for (let i = cleanups.length - 1; i >= 0; i--) { + cleanups[i](); + } + + visited.delete(schema); +} + +/** + * Walks each branch of a discriminated union that is an array element, + * pushing a `discriminated-union-array` path element for each branch. + */ +function walkDiscriminatedUnionArrayBranches( + unionSchema: ZodDiscriminatedUnion, + path: SchemaPathElement[], + visitors: readonly SchemaStructureVisitor[], + visited: Set, +): void { + const discriminatorKey = unionSchema._zod.def.discriminator; + for (const option of unionSchema.options as z.ZodType[]) { + const literalValue = extractDiscriminatorValue(option, discriminatorKey); + if (literalValue == null) { + // Fallback: walk without path element + walkNode(option, path, visitors, visited); + continue; + } + + walkNode( + option, + [ + ...path, + { + type: 'discriminated-union-array', + discriminatorKey, + value: literalValue, + }, + ], + visitors, + visited, + ); + } +} + +/** + * Extracts the literal discriminator value from a union branch schema. + */ +function extractDiscriminatorValue( + branchSchema: z.ZodType, + discriminatorKey: string, +): string | undefined { + const branchChildren = getSchemaChildren(branchSchema, undefined, []); + if (branchChildren.kind !== 'object') { + return undefined; + } + + const discEntry = branchChildren.entries.find( + ([key]) => key === discriminatorKey, + ); + if (!discEntry) { + return undefined; + } + + const discSchema = unwrapSchema(discEntry[1]); + const discChildren = getSchemaChildren(discSchema, undefined, []); + if (discChildren.kind !== 'leaf') { + return undefined; + } + + const { values } = discSchema._zod.def as unknown as { + values: unknown[]; + }; + return values[0] as string | undefined; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Unwraps wrapper schemas (optional, nullable, default, etc.) to the underlying schema. + */ +function unwrapSchema(schema: z.ZodType): z.ZodType { + const children = getSchemaChildren(schema, undefined, []); + if (children.kind === 'wrapper') { + return unwrapSchema(children.innerSchema); + } + return schema; +} diff --git a/packages/project-builder-lib/src/parser/walk-schema-structure.unit.test.ts b/packages/project-builder-lib/src/parser/walk-schema-structure.unit.test.ts new file mode 100644 index 000000000..76bb962b0 --- /dev/null +++ b/packages/project-builder-lib/src/parser/walk-schema-structure.unit.test.ts @@ -0,0 +1,450 @@ +import { describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; + +import { withEnt } from '#src/references/extend-parser-context-with-refs.js'; +import { createRefContextSlot } from '#src/references/ref-context-slot.js'; +import { createEntityType } from '#src/references/types.js'; +import { collectEntityMetadata } from '#src/tools/entity-service/entity-type-map.js'; + +import type { + SchemaPathElement, + SchemaStructureVisitor, +} from './walk-schema-structure.js'; + +import { walkSchemaStructure } from './walk-schema-structure.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Creates a visitor that records every (path, schemaType) pair it sees. */ +function makeRecordingVisitor(): SchemaStructureVisitor & { + calls: { path: SchemaPathElement[]; type: string }[]; +} { + const calls: { path: SchemaPathElement[]; type: string }[] = []; + return { + calls, + visit(schema, ctx) { + calls.push({ path: [...ctx.path], type: schema._zod.def.type }); + return undefined; + }, + }; +} + +// --------------------------------------------------------------------------- +// Simple schema types +// --------------------------------------------------------------------------- + +describe('walkSchemaStructure — simple schemas', () => { + it('visits a flat object and all its fields', () => { + const schema = z.object({ name: z.string(), age: z.number() }); + const visitor = makeRecordingVisitor(); + walkSchemaStructure(schema, [visitor]); + + expect(visitor.calls).toEqual([ + { path: [], type: 'object' }, + { path: [{ type: 'object-key', key: 'name' }], type: 'string' }, + { path: [{ type: 'object-key', key: 'age' }], type: 'number' }, + ]); + }); + + it('visits nested objects with compound paths', () => { + const schema = z.object({ + user: z.object({ name: z.string() }), + }); + const visitor = makeRecordingVisitor(); + walkSchemaStructure(schema, [visitor]); + + expect(visitor.calls).toContainEqual({ + path: [ + { type: 'object-key', key: 'user' }, + { type: 'object-key', key: 'name' }, + ], + type: 'string', + }); + }); + + it('unwraps optional/nullable/default wrappers transparently', () => { + const schema = z.object({ + a: z.string().optional(), + b: z.number().nullable(), + c: z.boolean().default(false), + }); + const visitor = makeRecordingVisitor(); + walkSchemaStructure(schema, [visitor]); + + const types = visitor.calls.map((c) => c.type); + expect(types).toContain('string'); + expect(types).toContain('number'); + expect(types).toContain('boolean'); + // wrapper nodes are visited too + expect(types).toContain('optional'); + expect(types).toContain('nullable'); + expect(types).toContain('default'); + }); +}); + +// --------------------------------------------------------------------------- +// Array handling +// --------------------------------------------------------------------------- + +describe('walkSchemaStructure — arrays', () => { + it('walks plain array elements without adding a path element', () => { + const schema = z.object({ tags: z.array(z.string()) }); + const visitor = makeRecordingVisitor(); + walkSchemaStructure(schema, [visitor]); + + // The string inside the array should have the same path as the array field + // (no array marker in path — non-deterministic) + expect(visitor.calls).toContainEqual({ + path: [{ type: 'object-key', key: 'tags' }], + type: 'array', + }); + expect(visitor.calls).toContainEqual({ + path: [{ type: 'object-key', key: 'tags' }], + type: 'string', + }); + }); + + it('walks array of discriminated union with discriminated-union-array path elements', () => { + const schema = z.object({ + items: z.array( + z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('a'), valueA: z.string() }), + z.object({ kind: z.literal('b'), valueB: z.number() }), + ]), + ), + }); + const visitor = makeRecordingVisitor(); + walkSchemaStructure(schema, [visitor]); + + // Each branch should have a discriminated-union-array path element + expect(visitor.calls).toContainEqual({ + path: [ + { type: 'object-key', key: 'items' }, + { + type: 'discriminated-union-array', + discriminatorKey: 'kind', + value: 'a', + }, + ], + type: 'object', + }); + expect(visitor.calls).toContainEqual({ + path: [ + { type: 'object-key', key: 'items' }, + { + type: 'discriminated-union-array', + discriminatorKey: 'kind', + value: 'a', + }, + { type: 'object-key', key: 'valueA' }, + ], + type: 'string', + }); + expect(visitor.calls).toContainEqual({ + path: [ + { type: 'object-key', key: 'items' }, + { + type: 'discriminated-union-array', + discriminatorKey: 'kind', + value: 'b', + }, + ], + type: 'object', + }); + expect(visitor.calls).toContainEqual({ + path: [ + { type: 'object-key', key: 'items' }, + { + type: 'discriminated-union-array', + discriminatorKey: 'kind', + value: 'b', + }, + { type: 'object-key', key: 'valueB' }, + ], + type: 'number', + }); + }); + + it('walks array of optional discriminated union (unwraps wrapper)', () => { + const schema = z.object({ + items: z.array( + z + .discriminatedUnion('type', [ + z.object({ type: z.literal('x'), data: z.string() }), + ]) + .optional(), + ), + }); + const visitor = makeRecordingVisitor(); + walkSchemaStructure(schema, [visitor]); + + // Should still detect the discriminated union through the optional wrapper + expect(visitor.calls).toContainEqual({ + path: [ + { type: 'object-key', key: 'items' }, + { + type: 'discriminated-union-array', + discriminatorKey: 'type', + value: 'x', + }, + { type: 'object-key', key: 'data' }, + ], + type: 'string', + }); + }); +}); + +// --------------------------------------------------------------------------- +// Discriminated unions on objects (transparent) +// --------------------------------------------------------------------------- + +describe('walkSchemaStructure — discriminated unions', () => { + it('walks all branches of a discriminated union without adding path elements', () => { + const schema = z.discriminatedUnion('shape', [ + z.object({ shape: z.literal('circle'), radius: z.number() }), + z.object({ shape: z.literal('rect'), width: z.number() }), + ]); + const visitor = makeRecordingVisitor(); + walkSchemaStructure(schema, [visitor]); + + // Both branches visited with no discriminated-union path element + expect(visitor.calls).toContainEqual({ + path: [{ type: 'object-key', key: 'radius' }], + type: 'number', + }); + expect(visitor.calls).toContainEqual({ + path: [{ type: 'object-key', key: 'width' }], + type: 'number', + }); + }); +}); + +// --------------------------------------------------------------------------- +// Tuples +// --------------------------------------------------------------------------- + +describe('walkSchemaStructure — tuples', () => { + it('visits tuple items with tuple-index path elements', () => { + const schema = z.tuple([z.string(), z.number(), z.boolean()]); + const visitor = makeRecordingVisitor(); + walkSchemaStructure(schema, [visitor]); + + expect(visitor.calls).toContainEqual({ + path: [{ type: 'tuple-index', index: 0 }], + type: 'string', + }); + expect(visitor.calls).toContainEqual({ + path: [{ type: 'tuple-index', index: 1 }], + type: 'number', + }); + expect(visitor.calls).toContainEqual({ + path: [{ type: 'tuple-index', index: 2 }], + type: 'boolean', + }); + }); + + it('walks rest elements without a path element', () => { + const schema = z.tuple([z.string()]).rest(z.number()); + const visitor = makeRecordingVisitor(); + walkSchemaStructure(schema, [visitor]); + + // The first item has a tuple-index, rest is walked without path element + expect(visitor.calls).toContainEqual({ + path: [{ type: 'tuple-index', index: 0 }], + type: 'string', + }); + // Rest element is visited at the same path as the tuple itself (no element) + expect(visitor.calls).toContainEqual({ + path: [], + type: 'number', + }); + }); +}); + +// --------------------------------------------------------------------------- +// Records +// --------------------------------------------------------------------------- + +describe('walkSchemaStructure — records', () => { + it('walks record values without a path element', () => { + const schema = z.object({ + data: z.record(z.string(), z.number()), + }); + const visitor = makeRecordingVisitor(); + walkSchemaStructure(schema, [visitor]); + + // Record value schema visited at the same path as the record field + expect(visitor.calls).toContainEqual({ + path: [{ type: 'object-key', key: 'data' }], + type: 'record', + }); + expect(visitor.calls).toContainEqual({ + path: [{ type: 'object-key', key: 'data' }], + type: 'number', + }); + }); +}); + +// --------------------------------------------------------------------------- +// Cleanup functions +// --------------------------------------------------------------------------- + +describe('walkSchemaStructure — cleanup functions', () => { + it('calls cleanup after children are visited', () => { + const log: string[] = []; + const schema = z.object({ child: z.string() }); + + const visitor: SchemaStructureVisitor = { + visit(_schema, ctx) { + const depth = ctx.path.length; + log.push(`enter:${depth}`); + return () => log.push(`exit:${depth}`); + }, + }; + + walkSchemaStructure(schema, [visitor]); + + // object entered, then child entered, child exited, object exited + expect(log).toEqual(['enter:0', 'enter:1', 'exit:1', 'exit:0']); + }); + + it('only runs cleanup when visitor returns one', () => { + const cleanupCalled = vi.fn(); + const schema = z.object({ a: z.string(), b: z.number() }); + + const visitor: SchemaStructureVisitor = { + visit(_schema, ctx) { + // Only return cleanup for the root node + if (ctx.path.length === 0) return cleanupCalled; + return undefined; + }, + }; + + walkSchemaStructure(schema, [visitor]); + expect(cleanupCalled).toHaveBeenCalledOnce(); + }); +}); + +// --------------------------------------------------------------------------- +// Circular reference handling +// --------------------------------------------------------------------------- + +describe('walkSchemaStructure — circular references', () => { + it('visits the same schema at different paths (delete-on-backtrack)', () => { + // The walker uses delete-on-backtrack, so the same schema instance + // can be visited at multiple paths without infinite loops. + const inner = z.object({ value: z.string() }); + const schema = z.object({ a: inner, b: inner }); + const visitor = makeRecordingVisitor(); + + walkSchemaStructure(schema, [visitor]); + + // Root + inner (via a) + inner (via b) — visited at both paths + const objectVisits = visitor.calls.filter((c) => c.type === 'object'); + expect(objectVisits.length).toBe(3); + }); + + it('prevents infinite loops on truly circular schemas', () => { + // A schema that references itself during the same walk path + // would be caught by the visited set. Since Zod doesn't easily allow + // truly circular schemas without z.lazy(), we just verify the walker + // completes without hanging. + const schema = z.object({ + nested: z.object({ deep: z.string() }), + }); + const visitor = makeRecordingVisitor(); + + walkSchemaStructure(schema, [visitor]); + expect(visitor.calls.length).toBeGreaterThan(0); + }); +}); + +// --------------------------------------------------------------------------- +// Entity detection with withEnt +// --------------------------------------------------------------------------- + +describe('walkSchemaStructure — entity detection via collectEntityMetadata', () => { + it('detects entity arrays in the schema and provides correct paths', () => { + const parentType = createEntityType('parent'); + const parentSlot = createRefContextSlot('parent-slot', parentType); + const childType = createEntityType('child', { parentType }); + + const childSchema = z.object({ id: z.string(), name: z.string() }); + const parentSchema = z.object({ + id: z.string(), + children: z.array( + childSchema.apply(withEnt({ type: childType, parentSlot })), + ), + }); + const rootSchema = z.object({ + parents: z.array( + parentSchema.apply(withEnt({ type: parentType, provides: parentSlot })), + ), + }); + + const map = collectEntityMetadata(rootSchema); + + const parentMeta = map.get('parent'); + expect(parentMeta).toBeDefined(); + expect(parentMeta?.relativePath).toEqual([ + { type: 'object-key', key: 'parents' }, + ]); + expect(parentMeta?.parentEntityTypeName).toBeUndefined(); + + const childMeta = map.get('child'); + expect(childMeta).toBeDefined(); + expect(childMeta?.relativePath).toEqual([ + { type: 'object-key', key: 'children' }, + ]); + expect(childMeta?.parentEntityTypeName).toBe('parent'); + }); + + it('handles entity arrays inside discriminated union arrays', () => { + const parentType = createEntityType('du-parent'); + const parentSlot = createRefContextSlot('du-parent-slot', parentType); + const childType = createEntityType('du-child', { parentType }); + + const childSchema = z.object({ id: z.string() }); + const branchA = z.object({ + type: z.literal('a'), + items: z.array( + childSchema.apply(withEnt({ type: childType, parentSlot })), + ), + }); + const branchB = z.object({ + type: z.literal('b'), + other: z.string(), + }); + + const rootSchema = z.object({ + things: z.array( + z + .discriminatedUnion('type', [branchA, branchB]) + .apply(withEnt({ type: parentType, provides: parentSlot })), + ), + }); + + const map = collectEntityMetadata(rootSchema); + + const parentMeta = map.get('du-parent'); + expect(parentMeta).toBeDefined(); + expect(parentMeta?.relativePath).toEqual([ + { type: 'object-key', key: 'things' }, + ]); + + const childMeta = map.get('du-child'); + expect(childMeta).toBeDefined(); + // Child is inside branch 'a' of the discriminated union array + expect(childMeta?.relativePath).toEqual([ + { + type: 'discriminated-union-array', + discriminatorKey: 'type', + value: 'a', + }, + { type: 'object-key', key: 'items' }, + ]); + expect(childMeta?.parentEntityTypeName).toBe('du-parent'); + }); +}); diff --git a/packages/project-builder-lib/src/testing/index.ts b/packages/project-builder-lib/src/testing/index.ts index 197c73554..93d901f91 100644 --- a/packages/project-builder-lib/src/testing/index.ts +++ b/packages/project-builder-lib/src/testing/index.ts @@ -1,3 +1,4 @@ export * from './definition-helpers.test-helper.js'; export * from './expression-stub-parser.test-helper.js'; export * from './parser-context.test-helper.js'; +export * from './project-definition-container.test-helper.js'; diff --git a/packages/project-builder-lib/src/definition/project-definition-container.test-utils.ts b/packages/project-builder-lib/src/testing/project-definition-container.test-helper.ts similarity index 84% rename from packages/project-builder-lib/src/definition/project-definition-container.test-utils.ts rename to packages/project-builder-lib/src/testing/project-definition-container.test-helper.ts index 15b6a07f8..f70ef9e08 100644 --- a/packages/project-builder-lib/src/definition/project-definition-container.test-utils.ts +++ b/packages/project-builder-lib/src/testing/project-definition-container.test-helper.ts @@ -3,15 +3,15 @@ import type { ProjectDefinition, ProjectDefinitionInput, } from '#src/schema/project-definition.js'; +import type { EntityServiceContext } from '#src/tools/entity-service/types.js'; +import { ProjectDefinitionContainer } from '#src/definition/project-definition-container.js'; import { getLatestMigrationVersion } from '#src/migrations/index.js'; import { createPluginSpecStore } from '#src/parser/parser.js'; import { deserializeSchemaWithTransformedReferences } from '#src/references/deserialize-schema.js'; import { createDefinitionSchemaParserContext } from '#src/schema/index.js'; import { createProjectDefinitionSchema } from '#src/schema/project-definition.js'; -import { ProjectDefinitionContainer } from './project-definition-container.js'; - export function createTestProjectDefinition( input: Partial = {}, ): ProjectDefinition { @@ -77,3 +77,9 @@ export function createTestProjectDefinitionContainer( schema, ); } + +export function createTestEntityServiceContext( + input: Partial = {}, +): EntityServiceContext { + return createTestProjectDefinitionContainer(input).toEntityServiceContext(); +} diff --git a/packages/project-builder-lib/src/tools/assign-entity-ids.ts b/packages/project-builder-lib/src/tools/assign-entity-ids.ts new file mode 100644 index 000000000..1af461480 --- /dev/null +++ b/packages/project-builder-lib/src/tools/assign-entity-ids.ts @@ -0,0 +1,51 @@ +import type { z } from 'zod'; + +import { immutableSet } from '@baseplate-dev/utils'; +import { get } from 'es-toolkit/compat'; + +import { transformDataWithSchema } from '#src/parser/transform-data-with-schema.js'; + +import { getEntityMeta } from './merge-schema/entity-utils.js'; + +interface AssignEntityIdsOptions { + /** If returns true for an ID, that ID is preserved (not regenerated). */ + isExistingId?: (id: string) => boolean; +} + +/** + * Recursively assigns IDs to an entity and all nested child entities. + * + * Walks the schema using `transformDataWithSchema` to detect entity nodes + * (via `getEntityMeta`). At each entity boundary, generates a new ID unless + * the existing ID is preserved by the `isExistingId` callback. + * + * Returns a new object with IDs assigned (immutable, structural sharing). + */ +export function assignEntityIds( + schema: z.ZodType, + data: T, + options?: AssignEntityIdsOptions, +): T { + return transformDataWithSchema(schema, data, (value, ctx) => { + const entityMeta = getEntityMeta(ctx.schema); + if (!entityMeta) { + return value; + } + if (value === null || value === undefined || typeof value !== 'object') { + return value; + } + + const currentId = get(value, entityMeta.idPath) as unknown; + if (currentId !== undefined && typeof currentId !== 'string') { + throw new TypeError( + `Expected string id at path "${entityMeta.idPath.join('.')}" but got ${typeof currentId}`, + ); + } + if (currentId && options?.isExistingId?.(currentId)) { + return value; + } + + const newId = entityMeta.type.generateNewId(); + return immutableSet(value, entityMeta.idPath, newId); + }); +} diff --git a/packages/project-builder-lib/src/tools/entity-service/entity-navigation.ts b/packages/project-builder-lib/src/tools/entity-service/entity-navigation.ts new file mode 100644 index 000000000..94dab5b3a --- /dev/null +++ b/packages/project-builder-lib/src/tools/entity-service/entity-navigation.ts @@ -0,0 +1,131 @@ +import { isPlainObject } from 'es-toolkit'; +import { get } from 'es-toolkit/compat'; + +import type { SchemaPathElement } from '#src/parser/walk-schema-structure.js'; +import type { ReferencePath } from '#src/references/types.js'; + +import type { EntityServiceContext } from './types.js'; + +/** + * Navigates from a starting object through a schema relative path to reach the entity array. + * Returns the array at the end of the path, or throws an error if the path is invalid. + */ +function navigateToEntityArrayFromSchemaPath( + obj: Record, + relativePath: SchemaPathElement[], + pathPrefix: ReferencePath, +): { array: unknown[]; path: ReferencePath } { + let current: unknown = obj; + const path: ReferencePath = [...pathPrefix]; + + for (const element of relativePath) { + switch (element.type) { + case 'object-key': { + if (!isPlainObject(current)) { + throw new TypeError( + `Expected object at path "${path.join('.')}" but got ${typeof current}`, + ); + } + current = current[element.key]; + path.push(element.key); + break; + } + case 'tuple-index': { + if (!Array.isArray(current)) { + throw new TypeError( + `Expected array at path "${path.join('.')}" but got ${typeof current}`, + ); + } + current = current[element.index]; + path.push(element.index); + break; + } + case 'discriminated-union-array': { + // Enter the array and find the unique element matching the discriminator + if (!Array.isArray(current)) { + throw new TypeError( + `Expected array at path "${path.join('.')}" but got ${typeof current}`, + ); + } + const match = current.findIndex((item) => { + if (!isPlainObject(item)) { + throw new TypeError( + `Expected object at path "${path.join('.')}" but got ${typeof item}`, + ); + } + return item[element.discriminatorKey] === element.value; + }); + if (match === -1) { + throw new Error( + `No element found in array at path "${path.join('.')}" with discriminator key "${element.discriminatorKey}" and value "${element.value}"`, + ); + } + current = current[match]; + path.push(match); + break; + } + } + } + + if (!Array.isArray(current)) { + throw new TypeError( + `Expected array at path "${path.join('.')}" but got ${typeof current}`, + ); + } + + return { array: current, path }; +} + +/** + * Finds the entity array containing entities of the given type. + * + * For top-level types: navigates from the definition root. + * For nested types: finds the parent entity by ID, then navigates from there. + */ +export function resolveEntityArray( + entityTypeName: string, + parentEntityId: string | undefined, + { entityTypeMap, lookupEntity, serializedDefinition }: EntityServiceContext, +): { array: unknown[]; path: ReferencePath } { + const metadata = entityTypeMap.get(entityTypeName); + if (!metadata) { + throw new Error(`Unknown entity type: ${entityTypeName}`); + } + + let startPath: ReferencePath = []; + + if (metadata.parentEntityTypeName) { + // Nested entity: find the parent first + if (!parentEntityId) { + throw new Error( + `Entity type "${entityTypeName}" requires a parent entity ID (parent type: "${metadata.parentEntityTypeName}")`, + ); + } + + const parentResult = lookupEntity(parentEntityId); + if (!parentResult) { + throw new Error( + `Parent entity "${parentEntityId}" not found for entity type "${entityTypeName}"`, + ); + } + + startPath = parentResult.path; + } + + const parentEntity = ( + startPath.length === 0 + ? serializedDefinition + : get(serializedDefinition, startPath) + ) as unknown; + if (!isPlainObject(parentEntity)) { + throw new Error( + `Parent entity at path "${startPath.join('.')}" is not a plain object`, + ); + } + + return navigateToEntityArrayFromSchemaPath( + parentEntity, + metadata.relativePath, + startPath, + ); +} diff --git a/packages/project-builder-lib/src/tools/entity-service/entity-read.ts b/packages/project-builder-lib/src/tools/entity-service/entity-read.ts new file mode 100644 index 000000000..162c88078 --- /dev/null +++ b/packages/project-builder-lib/src/tools/entity-service/entity-read.ts @@ -0,0 +1,92 @@ +import { isPlainObject } from 'es-toolkit'; +import { get } from 'es-toolkit/compat'; + +import type { EntityServiceContext } from './types.js'; + +import { getEntityName } from '../merge-schema/entity-utils.js'; +import { resolveEntityArray } from './entity-navigation.js'; + +export interface ListEntitiesInput { + entityTypeName: string; + parentEntityId?: string; +} + +/** + * Lightweight entity stub returned by listEntities. + */ +export interface EntityStub { + id: string; + name: string; + type: string; +} + +/** + * Lists all entities of a given type, returning lightweight stubs. + * + * For nested entity types, `parentEntityId` is required to scope the listing + * to entities within a specific parent. + * + * @param container - The project definition container + * @param entityTypeMap - The entity type map built from the schema + * @param entityTypeName - The entity type to list + * @param parentEntityId - Required for nested entity types + * @returns Array of entity stubs with id, name, and type + */ +export function listEntities( + { entityTypeName, parentEntityId }: ListEntitiesInput, + context: EntityServiceContext, +): EntityStub[] { + const metadata = context.entityTypeMap.get(entityTypeName); + if (!metadata) { + throw new Error(`Unknown entity type: ${entityTypeName}`); + } + + const { array, path } = resolveEntityArray( + entityTypeName, + parentEntityId, + context, + ); + + return array.map((item, index) => { + if (!isPlainObject(item)) { + throw new TypeError( + `Expected plain object at path "${path.join('.')}[${index}]" but got ${typeof item}`, + ); + } + const id = get(item, metadata.entityMeta.idPath) as unknown; + if (typeof id !== 'string') { + throw new TypeError( + `Expected string id at path "${metadata.entityMeta.idPath.join('.')}" but got ${typeof id}`, + ); + } + const name = getEntityName(metadata.entityMeta, item); + return { id, name, type: entityTypeName }; + }); +} + +/** + * Gets a single entity by ID, returning its full serialized (name-based) data. + * + * @param container - The project definition container + * @param entityTypeMap - The entity type map built from the schema + * @param serializedDef - The serialized project definition (with names) + * @param entityTypeName - The entity type to get + * @param entityId - The entity ID + * @returns The serialized entity data, or undefined if not found + */ +export function getEntity( + entityId: string, + context: EntityServiceContext, +): Record | undefined { + const result = context.lookupEntity(entityId); + if (!result) { + return undefined; + } + const item = get(context.serializedDefinition, result.path) as unknown; + if (!isPlainObject(item)) { + throw new TypeError( + `Expected plain object at path "${result.path.join('.')}" but got ${typeof item}`, + ); + } + return item; +} diff --git a/packages/project-builder-lib/src/tools/entity-service/entity-read.unit.test.ts b/packages/project-builder-lib/src/tools/entity-service/entity-read.unit.test.ts new file mode 100644 index 000000000..db26c2c82 --- /dev/null +++ b/packages/project-builder-lib/src/tools/entity-service/entity-read.unit.test.ts @@ -0,0 +1,156 @@ +import { assert, describe, expect, it } from 'vitest'; + +import { + createTestFeature, + createTestModel, + createTestScalarField, +} from '#src/testing/definition-helpers.test-helper.js'; +import { createTestEntityServiceContext } from '#src/testing/project-definition-container.test-helper.js'; + +import { getEntity, listEntities } from './entity-read.js'; + +describe('listEntities', () => { + it('should list top-level entities', () => { + const feature = createTestFeature({ name: 'billing' }); + const context = createTestEntityServiceContext({ features: [feature] }); + + const result = listEntities({ entityTypeName: 'feature' }, context); + + expect(result).toEqual([ + { id: feature.id, name: 'billing', type: 'feature' }, + ]); + }); + + it('should list multiple entities', () => { + const feature1 = createTestFeature({ name: 'billing' }); + const feature2 = createTestFeature({ name: 'auth' }); + const context = createTestEntityServiceContext({ + features: [feature1, feature2], + }); + + const result = listEntities({ entityTypeName: 'feature' }, context); + + expect(result).toHaveLength(2); + expect(result[0].name).toBe('billing'); + expect(result[1].name).toBe('auth'); + }); + + it('should return empty array when no entities exist', () => { + const context = createTestEntityServiceContext(); + + const result = listEntities({ entityTypeName: 'feature' }, context); + + expect(result).toEqual([]); + }); + + it('should list nested entities with parent ID', () => { + const feature = createTestFeature({ name: 'billing' }); + const nameField = createTestScalarField({ name: 'name', type: 'string' }); + const model = createTestModel({ + name: 'Invoice', + featureRef: feature.name, + model: { + fields: [ + createTestScalarField({ + name: 'id', + type: 'uuid', + options: { genUuid: true }, + }), + nameField, + ], + primaryKeyFieldRefs: ['id'], + }, + }); + const context = createTestEntityServiceContext({ + features: [feature], + models: [model], + }); + + const result = listEntities( + { + entityTypeName: 'model-scalar-field', + parentEntityId: model.id, + }, + context, + ); + + // Model has an id field and a name field + expect(result.length).toBeGreaterThanOrEqual(2); + const fieldNames = result.map((stub) => stub.name); + expect(fieldNames).toContain('id'); + expect(fieldNames).toContain('name'); + }); + + it('should throw for unknown entity type', () => { + const context = createTestEntityServiceContext(); + + expect(() => + listEntities({ entityTypeName: 'nonexistent' }, context), + ).toThrow('Unknown entity type: nonexistent'); + }); + + it('should throw for nested entity type without parent ID', () => { + const feature = createTestFeature({ name: 'billing' }); + const model = createTestModel({ + name: 'Invoice', + featureRef: feature.name, + }); + const context = createTestEntityServiceContext({ + features: [feature], + models: [model], + }); + + expect(() => + listEntities({ entityTypeName: 'model-scalar-field' }, context), + ).toThrow(/requires a parent entity ID/); + }); +}); + +describe('getEntity', () => { + it('should get an entity by ID', () => { + const feature = createTestFeature({ name: 'billing' }); + const context = createTestEntityServiceContext({ features: [feature] }); + + const result = getEntity(feature.id, context); + + assert(result); + expect(result.name).toBe('billing'); + }); + + it('should return undefined for nonexistent entity ID', () => { + const context = createTestEntityServiceContext(); + + const result = getEntity('feature:nonexistent', context); + + expect(result).toBeUndefined(); + }); + + it('should get a nested entity by ID', () => { + const feature = createTestFeature({ name: 'billing' }); + const nameField = createTestScalarField({ name: 'title', type: 'string' }); + const model = createTestModel({ + name: 'Post', + featureRef: feature.name, + model: { + fields: [ + createTestScalarField({ + name: 'id', + type: 'uuid', + options: { genUuid: true }, + }), + nameField, + ], + primaryKeyFieldRefs: ['id'], + }, + }); + const context = createTestEntityServiceContext({ + features: [feature], + models: [model], + }); + + const result = getEntity(nameField.id, context); + + assert(result); + expect(result.name).toBe('title'); + }); +}); diff --git a/packages/project-builder-lib/src/tools/entity-service/entity-type-map.ts b/packages/project-builder-lib/src/tools/entity-service/entity-type-map.ts new file mode 100644 index 000000000..c9c9d61a8 --- /dev/null +++ b/packages/project-builder-lib/src/tools/entity-service/entity-type-map.ts @@ -0,0 +1,99 @@ +import type { z } from 'zod'; + +import type { SchemaStructureWalkContext } from '#src/parser/walk-schema-structure.js'; + +import { getSchemaChildren } from '#src/parser/schema-structure.js'; +import { walkSchemaStructure } from '#src/parser/walk-schema-structure.js'; + +import type { EntityTypeMap } from './types.js'; + +import { getEntityMeta } from '../merge-schema/entity-utils.js'; + +/** + * Collects entity metadata from a Zod schema by walking its structure. + * + * Walks the schema once, detecting entity arrays (via `definitionRefRegistry` + * annotations) and building metadata for each entity type including its + * relative path from the parent entity (or root) and parent entity type linkage. + * + * Parent relationships are determined by `entityType.parentType` on the + * `DefinitionEntityType` instance — when a parent type is set, the walker + * finds the matching ancestor entity in its stack and computes the relative + * path from there. + * + * Call once per schema and reuse the result. + */ +export function collectEntityMetadata(schema: z.ZodType): EntityTypeMap { + const map: EntityTypeMap = new Map(); + + // Stack of entity types encountered during the walk, used to determine + // parent-child relationships and compute relative paths. + const entityStack: { + entityTypeName: string; + pathAtEntity: SchemaStructureWalkContext['path']; + }[] = []; + + walkSchemaStructure(schema, [ + { + visit(innerSchema, ctx) { + const children = getSchemaChildren(innerSchema, undefined, []); + if (children.kind !== 'array') { + return undefined; + } + + const entityMeta = getEntityMeta(children.elementSchema); + if (!entityMeta) { + return undefined; + } + + const entityTypeName = entityMeta.type.name; + const { parentType } = entityMeta.type; + + let parentEntityTypeName: string | undefined; + let relativePath: SchemaStructureWalkContext['path']; + + if (parentType) { + // Find the parent entity in the stack + const parentEntry = entityStack.findLast( + (entry) => entry.entityTypeName === parentType.name, + ); + + if (!parentEntry) { + throw new Error( + `Entity type "${entityTypeName}" declares parent type "${parentType.name}" ` + + `but no such entity was found in the ancestor chain`, + ); + } + + parentEntityTypeName = parentType.name; + relativePath = ctx.path.slice(parentEntry.pathAtEntity.length); + } else { + // Top-level entity: relative path is the full path + relativePath = ctx.path; + } + + map.set(entityTypeName, { + name: entityTypeName, + entityType: entityMeta.type, + entityMeta, + elementSchema: children.elementSchema, + relativePath, + parentEntityTypeName, + }); + + // Push onto entity stack. Child relative paths are computed by + // slicing from this position — the walker adds discriminated-union-array + // elements when descending into array branches. + entityStack.push({ + entityTypeName, + pathAtEntity: ctx.path, + }); + return () => { + entityStack.pop(); + }; + }, + }, + ]); + + return map; +} diff --git a/packages/project-builder-lib/src/tools/entity-service/entity-write.ts b/packages/project-builder-lib/src/tools/entity-service/entity-write.ts new file mode 100644 index 000000000..da598b257 --- /dev/null +++ b/packages/project-builder-lib/src/tools/entity-service/entity-write.ts @@ -0,0 +1,158 @@ +import { isPlainObject } from 'es-toolkit'; +import { get } from 'es-toolkit/compat'; +import { produce } from 'immer'; + +import type { EntityServiceContext } from './types.js'; + +import { assignEntityIds } from '../assign-entity-ids.js'; +import { resolveEntityArray } from './entity-navigation.js'; + +export interface CreateEntityInput { + entityTypeName: string; + entityData: Record; + parentEntityId?: string; +} + +/** + * Creates a new entity in the definition. + * + * Generates a new ID for the entity using the entity type's ID generation. + * For nested entity types, `parentEntityId` specifies which parent to add to. + * + * @returns A new definition with the entity added (original is not modified) + */ +export function createEntity( + { entityTypeName, entityData, parentEntityId }: CreateEntityInput, + context: EntityServiceContext, +): Record { + const metadata = context.entityTypeMap.get(entityTypeName); + if (!metadata) { + throw new Error(`Unknown entity type: ${entityTypeName}`); + } + + // Resolve the array path on the original definition + const { path } = resolveEntityArray(entityTypeName, parentEntityId, context); + + // Assign IDs to the entity and all nested child entities + const entityWithIds = assignEntityIds(metadata.elementSchema, entityData); + + return produce(context.serializedDefinition, (draft) => { + const array = get(draft, path) as unknown; + if (!Array.isArray(array)) { + throw new TypeError( + `Expected array at path "${path.join('.')}" but got ${typeof array}`, + ); + } + array.push(entityWithIds); + }); +} + +export interface UpdateEntityInput { + entityTypeName: string; + entityId: string; + entityData: Record; +} + +/** + * Updates an existing entity by ID. + * + * Replaces the entity data while preserving the entity's ID. + * + * @returns A new definition with the entity updated (original is not modified) + */ +export function updateEntity( + { entityTypeName, entityId, entityData }: UpdateEntityInput, + context: EntityServiceContext, +): Record { + const metadata = context.entityTypeMap.get(entityTypeName); + if (!metadata) { + throw new Error(`Unknown entity type: ${entityTypeName}`); + } + + // Verify the entity exists and get its path + const entity = context.lookupEntity(entityId); + if (!entity) { + throw new Error( + `Entity "${entityId}" of type "${entityTypeName}" not found`, + ); + } + + // Preserve the ID in the updated data but populate any children entities with new IDs if they don't have an ID yet. + const updatedEntity = assignEntityIds(metadata.elementSchema, entityData, { + isExistingId: (id) => !!context.lookupEntity(id), + }); + + return produce(context.serializedDefinition, (draft) => { + const target = get(draft, entity.path) as unknown; + if (!isPlainObject(target)) { + throw new TypeError( + `Expected plain object at path "${entity.path.join('.')}" but got ${typeof target}`, + ); + } + + // Find the parent array and the entity's index within it + const parentPath = entity.path.slice(0, -1); + const entityIndex = entity.path.at(-1); + if (typeof entityIndex !== 'number') { + throw new TypeError( + `Expected numeric index at end of entity path "${entity.path.join('.')}"`, + ); + } + const parentArray = get(draft, parentPath) as unknown; + if (!Array.isArray(parentArray)) { + throw new TypeError( + `Expected array at path "${parentPath.join('.')}" but got ${typeof parentArray}`, + ); + } + + parentArray[entityIndex] = updatedEntity; + }); +} + +export interface DeleteEntityInput { + entityTypeName: string; + entityId: string; +} + +/** + * Deletes an entity by ID. + * + * @returns A new definition with the entity removed (original is not modified) + */ +export function deleteEntity( + { entityTypeName, entityId }: DeleteEntityInput, + context: EntityServiceContext, +): Record { + const metadata = context.entityTypeMap.get(entityTypeName); + if (!metadata) { + throw new Error(`Unknown entity type: ${entityTypeName}`); + } + + // Verify the entity exists and get its path + const entity = context.lookupEntity(entityId); + if (!entity) { + throw new Error( + `Entity "${entityId}" of type "${entityTypeName}" not found`, + ); + } + + // Find the parent array and remove the entity by index + const parentPath = entity.path.slice(0, -1); + const entityIndex = entity.path.at(-1); + if (typeof entityIndex !== 'number') { + throw new TypeError( + `Expected numeric index at end of entity path "${entity.path.join('.')}"`, + ); + } + + return produce(context.serializedDefinition, (draft) => { + const parentArray = get(draft, parentPath) as unknown; + if (!Array.isArray(parentArray)) { + throw new TypeError( + `Expected array at path "${parentPath.join('.')}" but got ${typeof parentArray}`, + ); + } + + parentArray.splice(entityIndex, 1); + }); +} diff --git a/packages/project-builder-lib/src/tools/entity-service/entity-write.unit.test.ts b/packages/project-builder-lib/src/tools/entity-service/entity-write.unit.test.ts new file mode 100644 index 000000000..4d3ce652e --- /dev/null +++ b/packages/project-builder-lib/src/tools/entity-service/entity-write.unit.test.ts @@ -0,0 +1,210 @@ +import { describe, expect, it } from 'vitest'; + +import { + createTestFeature, + createTestModel, +} from '#src/testing/definition-helpers.test-helper.js'; +import { createTestEntityServiceContext } from '#src/testing/project-definition-container.test-helper.js'; + +import type { EntityServiceContext } from './types.js'; + +import { listEntities } from './entity-read.js'; +import { createEntity, deleteEntity, updateEntity } from './entity-write.js'; + +describe('createEntity', () => { + it('should create a top-level entity and return a new definition', () => { + const context = createTestEntityServiceContext(); + + const newDef = createEntity( + { + entityTypeName: 'feature', + entityData: { name: 'payments' }, + }, + context, + ); + + // Original definition should not be modified + const originalFeatures = context.serializedDefinition.features as unknown[]; + expect(originalFeatures).toHaveLength(0); + + // New definition should have the feature + const newFeatures = newDef.features as Record[]; + expect(newFeatures).toHaveLength(1); + expect(newFeatures[0].name).toBe('payments'); + // Should have an assigned ID + expect(newFeatures[0].id).toEqual(expect.stringContaining('feature:')); + }); + + it('should create a nested entity under a parent', () => { + const feature = createTestFeature({ name: 'billing' }); + const model = createTestModel({ + name: 'Invoice', + featureRef: feature.name, + }); + const context = createTestEntityServiceContext({ + features: [feature], + models: [model], + }); + + // Get the current field count + const fieldsBefore = listEntities( + { entityTypeName: 'model-scalar-field', parentEntityId: model.id }, + context, + ); + + const newDef = createEntity( + { + entityTypeName: 'model-scalar-field', + entityData: { name: 'amount', type: 'int' }, + parentEntityId: model.id, + }, + context, + ); + + // Use the new definition to list fields — need a fresh context + const newContext: EntityServiceContext = { + ...context, + serializedDefinition: newDef, + }; + const fieldsAfter = listEntities( + { entityTypeName: 'model-scalar-field', parentEntityId: model.id }, + newContext, + ); + + expect(fieldsAfter.length).toBe(fieldsBefore.length + 1); + const fieldNames = fieldsAfter.map((f) => f.name); + expect(fieldNames).toContain('amount'); + }); + + it('should throw for unknown entity type', () => { + const context = createTestEntityServiceContext(); + + expect(() => + createEntity( + { entityTypeName: 'nonexistent', entityData: { name: 'test' } }, + context, + ), + ).toThrow('Unknown entity type: nonexistent'); + }); +}); + +describe('updateEntity', () => { + it('should update an existing entity', () => { + const feature = createTestFeature({ name: 'billing' }); + const context = createTestEntityServiceContext({ features: [feature] }); + + const newDef = updateEntity( + { + entityTypeName: 'feature', + entityId: feature.id, + entityData: { id: feature.id, name: 'payments' }, + }, + context, + ); + + // Original should be unchanged + const origFeatures = context.serializedDefinition.features as Record< + string, + unknown + >[]; + expect(origFeatures[0].name).toBe('billing'); + + // New definition should have updated name + const newFeatures = newDef.features as Record[]; + expect(newFeatures[0].name).toBe('payments'); + // ID should be preserved + expect(newFeatures[0].id).toBe(feature.id); + }); + + it('should throw for nonexistent entity ID', () => { + const context = createTestEntityServiceContext(); + + expect(() => + updateEntity( + { + entityTypeName: 'feature', + entityId: 'feature:nonexistent', + entityData: { name: 'test' }, + }, + context, + ), + ).toThrow(/not found/); + }); + + it('should throw for unknown entity type', () => { + const context = createTestEntityServiceContext(); + + expect(() => + updateEntity( + { + entityTypeName: 'nonexistent', + entityId: 'nonexistent:123', + entityData: { name: 'test' }, + }, + context, + ), + ).toThrow('Unknown entity type: nonexistent'); + }); +}); + +describe('deleteEntity', () => { + it('should delete an existing entity', () => { + const feature1 = createTestFeature({ name: 'billing' }); + const feature2 = createTestFeature({ name: 'auth' }); + const context = createTestEntityServiceContext({ + features: [feature1, feature2], + }); + + const newDef = deleteEntity( + { entityTypeName: 'feature', entityId: feature1.id }, + context, + ); + + // Original should be unchanged + const origFeatures = context.serializedDefinition.features as Record< + string, + unknown + >[]; + expect(origFeatures).toHaveLength(2); + + // New definition should have one fewer feature + const newFeatures = newDef.features as Record[]; + expect(newFeatures).toHaveLength(1); + expect(newFeatures[0].name).toBe('auth'); + }); + + it('should delete the only entity in an array', () => { + const feature = createTestFeature({ name: 'billing' }); + const context = createTestEntityServiceContext({ features: [feature] }); + + const newDef = deleteEntity( + { entityTypeName: 'feature', entityId: feature.id }, + context, + ); + + const newFeatures = newDef.features as unknown[]; + expect(newFeatures).toHaveLength(0); + }); + + it('should throw for nonexistent entity ID', () => { + const context = createTestEntityServiceContext(); + + expect(() => + deleteEntity( + { entityTypeName: 'feature', entityId: 'feature:nonexistent' }, + context, + ), + ).toThrow(/not found/); + }); + + it('should throw for unknown entity type', () => { + const context = createTestEntityServiceContext(); + + expect(() => + deleteEntity( + { entityTypeName: 'nonexistent', entityId: 'nonexistent:123' }, + context, + ), + ).toThrow('Unknown entity type: nonexistent'); + }); +}); diff --git a/packages/project-builder-lib/src/tools/entity-service/index.ts b/packages/project-builder-lib/src/tools/entity-service/index.ts new file mode 100644 index 000000000..8eb204047 --- /dev/null +++ b/packages/project-builder-lib/src/tools/entity-service/index.ts @@ -0,0 +1,8 @@ +export { getEntity, listEntities } from './entity-read.js'; +export { collectEntityMetadata } from './entity-type-map.js'; +export { createEntity, deleteEntity, updateEntity } from './entity-write.js'; +export type { + EntityServiceContext, + EntityTypeMap, + EntityTypeMetadata, +} from './types.js'; diff --git a/packages/project-builder-lib/src/tools/entity-service/types.ts b/packages/project-builder-lib/src/tools/entity-service/types.ts new file mode 100644 index 000000000..af3047c0f --- /dev/null +++ b/packages/project-builder-lib/src/tools/entity-service/types.ts @@ -0,0 +1,47 @@ +import type { z } from 'zod'; + +import type { SchemaPathElement } from '#src/parser/walk-schema-structure.js'; +import type { EntitySchemaMeta } from '#src/references/definition-ref-registry.js'; +import type { + DefinitionEntity, + DefinitionEntityType, +} from '#src/references/types.js'; + +/** + * Metadata about an entity type's location in the definition schema. + */ +export interface EntityTypeMetadata { + /** Entity type name (e.g., "model", "model-scalar-field") */ + name: string; + /** The DefinitionEntityType instance */ + entityType: DefinitionEntityType; + /** Entity metadata from schema annotations */ + entityMeta: EntitySchemaMeta; + /** Element schema for the entity array */ + elementSchema: z.ZodType; + /** Path from parent entity (or root) to this entity's array */ + relativePath: SchemaPathElement[]; + /** Parent entity type name, if this is a nested entity */ + parentEntityTypeName?: string; +} + +/** + * Map from entity type name to its metadata. + * Built once from the schema and reused for all operations. + */ +export type EntityTypeMap = Map; + +export interface EntityServiceContext { + /** + * The serialized definition but with defaults provided. + */ + serializedDefinition: Record; + /** + * The entity type map built from the schema. + */ + entityTypeMap: EntityTypeMap; + /** + * Looks up an entity by its ID. + */ + lookupEntity: (entityId: string) => DefinitionEntity | undefined; +} diff --git a/packages/project-builder-lib/src/tools/index.ts b/packages/project-builder-lib/src/tools/index.ts index 7b79eed80..b113a4498 100644 --- a/packages/project-builder-lib/src/tools/index.ts +++ b/packages/project-builder-lib/src/tools/index.ts @@ -1 +1,2 @@ +export * from './entity-service/index.js'; export * from './merge-schema/index.js'; diff --git a/packages/project-builder-lib/src/tools/merge-schema/diff-definition.ts b/packages/project-builder-lib/src/tools/merge-schema/diff-definition.ts index 95b1420cb..21fbcf704 100644 --- a/packages/project-builder-lib/src/tools/merge-schema/diff-definition.ts +++ b/packages/project-builder-lib/src/tools/merge-schema/diff-definition.ts @@ -53,18 +53,18 @@ export interface DefinitionDiff { // --------------------------------------------------------------------------- /** - * Diffs an entity array by the `name` field, scoped to only the entities - * named in the partial definition. + * Diffs an entity array by the `name` field. * - * Entities not mentioned in the partial are ignored — this prevents showing - * "removed" entries for entities that the partial doesn't care about. + * When `scopeToNames` is provided, only those entities are compared — this prevents + * showing "removed" entries for entities that the partial doesn't care about. + * When omitted, all entities in both arrays are compared. */ function diffEntityArray( path: string, entityMeta: EntitySchemaMeta, currentArray: PlainObject[], - mergedArray: PlainObject[], - partialNames: Set, + otherArray: PlainObject[], + scopeToNames?: Set, ): DefinitionDiffEntry[] { const entries: DefinitionDiffEntry[] = []; const label = capitalize(entityMeta.type.name); @@ -72,24 +72,26 @@ function diffEntityArray( const currentByName = new Map( currentArray.map((item) => [getEntityName(entityMeta, item), item]), ); - const mergedByName = new Map( - mergedArray.map((item) => [getEntityName(entityMeta, item), item]), + const otherByName = new Map( + otherArray.map((item) => [getEntityName(entityMeta, item), item]), ); - // Only diff entities named in the partial definition - for (const name of partialNames) { + const namesToDiff = + scopeToNames ?? new Set([...currentByName.keys(), ...otherByName.keys()]); + + for (const name of namesToDiff) { const currentItem = currentByName.get(name); - const mergedItem = mergedByName.get(name); + const otherItem = otherByName.get(name); - if (!currentItem && mergedItem) { + if (!currentItem && otherItem) { entries.push({ path, label: `${label}: ${name}`, type: 'added', current: undefined, - merged: mergedItem, + merged: otherItem, }); - } else if (currentItem && !mergedItem) { + } else if (currentItem && !otherItem) { entries.push({ path, label: `${label}: ${name}`, @@ -97,13 +99,13 @@ function diffEntityArray( current: currentItem, merged: undefined, }); - } else if (currentItem && mergedItem && !isEqual(currentItem, mergedItem)) { + } else if (currentItem && otherItem && !isEqual(currentItem, otherItem)) { entries.push({ path, label: `${label}: ${name}`, type: 'updated', current: currentItem, - merged: mergedItem, + merged: otherItem, }); } } @@ -112,36 +114,41 @@ function diffEntityArray( } // --------------------------------------------------------------------------- -// Main diff function +// Core diffing of two serialized definitions // --------------------------------------------------------------------------- +export interface DiffSerializedDefinitionsOptions { + /** + * When provided, only these top-level keys are compared. + * When omitted, all keys from both definitions are compared. + */ + scopeToKeys?: Set; + + /** + * When provided, entity array diffs are scoped to only these entity names + * per top-level key. When omitted, all entities in both arrays are compared. + */ + entityNamesByKey?: Map>; +} + /** - * Computes a structured, entity-grouped diff between the current project definition - * and the result of merging a partial definition into it. + * Compares two serialized project definitions at the entity level, producing + * diff entries for added, updated, and removed entities/fields. * - * For top-level entity arrays (models, apps, enums, libraries, features), produces - * one entry per entity that was added, updated, or removed — detected via schema - * entity metadata (`withEnt` annotations) using `collectEntityArrays`. For other - * top-level fields (settings, plugins, etc.), produces one entry per changed field. + * Both definitions should be in serialized form (with entity names, not IDs). * * @param schema - The project definition Zod schema - * @param definition - The current parsed project definition (with IDs) - * @param partialDef - A partial serialized definition to merge in - * @returns A structured diff with entity-level entries + * @param currentDef - The current serialized definition + * @param otherDef - The other serialized definition to compare against + * @param options - Optional scoping options + * @returns Entity-level diff entries */ -export function diffDefinition( +export function diffSerializedDefinitions( schema: z.ZodType, - definition: ProjectDefinition, - partialDef: PartialProjectDefinitionInput, + currentDef: PlainObject, + otherDef: PlainObject, + options?: DiffSerializedDefinitionsOptions, ): DefinitionDiff { - const serializedDef = serializeSchema(schema, definition) as PlainObject; - const mergedDef = mergeDefinition( - schema, - definition, - partialDef, - ) as PlainObject; - - // Collect top-level entity arrays (path has no dots — single key like "models") const entityArrayInfoByKey = new Map( collectEntityArrays(schema) .filter((info) => !info.path.includes('.')) @@ -149,14 +156,16 @@ export function diffDefinition( ); const entries: DefinitionDiffEntry[] = []; - const partialObj = partialDef as PlainObject; - // Only diff keys present in the partial definition - for (const key of Object.keys(partialObj)) { - const currentValue = serializedDef[key]; - const mergedValue = mergedDef[key]; + const keysToCompare = + options?.scopeToKeys ?? + new Set([...Object.keys(currentDef), ...Object.keys(otherDef)]); + + for (const key of keysToCompare) { + const currentValue = currentDef[key]; + const otherValue = otherDef[key]; - if (isEqual(currentValue, mergedValue)) { + if (isEqual(currentValue, otherValue)) { continue; } @@ -165,29 +174,22 @@ export function diffDefinition( const currentArray = ( Array.isArray(currentValue) ? currentValue : [] ) as PlainObject[]; - const mergedArray = ( - Array.isArray(mergedValue) ? mergedValue : [] + const otherArray = ( + Array.isArray(otherValue) ? otherValue : [] ) as PlainObject[]; - // Collect entity names from the partial definition to scope the diff - const partialArray = ( - Array.isArray(partialObj[key]) ? partialObj[key] : [] - ) as PlainObject[]; - const partialNames = new Set( - partialArray.map((item) => getEntityName(entityInfo.entityMeta, item)), - ); + const scopeToNames = options?.entityNamesByKey?.get(key); entries.push( ...diffEntityArray( key, entityInfo.entityMeta, currentArray, - mergedArray, - partialNames, + otherArray, + scopeToNames, ), ); } else { - // Non-entity field: single entry const label = capitalize(key); if (currentValue === undefined) { entries.push({ @@ -195,9 +197,9 @@ export function diffDefinition( label, type: 'added', current: undefined, - merged: mergedValue, + merged: otherValue, }); - } else if (mergedValue === undefined) { + } else if (otherValue === undefined) { entries.push({ path: key, label, @@ -211,7 +213,7 @@ export function diffDefinition( label, type: 'updated', current: currentValue, - merged: mergedValue, + merged: otherValue, }); } } @@ -222,3 +224,67 @@ export function diffDefinition( entries, }; } + +// --------------------------------------------------------------------------- +// High-level diff with merge +// --------------------------------------------------------------------------- + +/** + * Computes a structured, entity-grouped diff between the current project definition + * and the result of merging a partial definition into it. + * + * For top-level entity arrays (models, apps, enums, libraries, features), produces + * one entry per entity that was added, updated, or removed — detected via schema + * entity metadata (`withEnt` annotations) using `collectEntityArrays`. For other + * top-level fields (settings, plugins, etc.), produces one entry per changed field. + * + * @param schema - The project definition Zod schema + * @param definition - The current parsed project definition (with IDs) + * @param partialDef - A partial serialized definition to merge in + * @returns A structured diff with entity-level entries + */ +export function diffDefinition( + schema: z.ZodType, + definition: ProjectDefinition, + partialDef: PartialProjectDefinitionInput, +): DefinitionDiff { + const serializedDef = serializeSchema(schema, definition) as PlainObject; + const mergedDef = mergeDefinition( + schema, + definition, + partialDef, + ) as PlainObject; + + const partialObj = partialDef as PlainObject; + const scopeToKeys = new Set(Object.keys(partialObj)); + + // Build entity name scopes from the partial definition + const entityArrayInfoByKey = new Map( + collectEntityArrays(schema) + .filter((info) => !info.path.includes('.')) + .map((info) => [info.path, info]), + ); + + const entityNamesByKey = new Map>(); + for (const key of scopeToKeys) { + const entityInfo = entityArrayInfoByKey.get(key); + if (entityInfo) { + const partialArray = ( + Array.isArray(partialObj[key]) ? partialObj[key] : [] + ) as PlainObject[]; + entityNamesByKey.set( + key, + new Set( + partialArray.map((item) => + getEntityName(entityInfo.entityMeta, item), + ), + ), + ); + } + } + + return diffSerializedDefinitions(schema, serializedDef, mergedDef, { + scopeToKeys, + entityNamesByKey, + }); +} diff --git a/packages/project-builder-lib/src/tools/merge-schema/diff-definition.unit.test.ts b/packages/project-builder-lib/src/tools/merge-schema/diff-definition.unit.test.ts index e21a0d52c..3aa16f744 100644 --- a/packages/project-builder-lib/src/tools/merge-schema/diff-definition.unit.test.ts +++ b/packages/project-builder-lib/src/tools/merge-schema/diff-definition.unit.test.ts @@ -2,12 +2,12 @@ import { describe, expect, it } from 'vitest'; import type { ProjectDefinitionContainer } from '#src/definition/project-definition-container.js'; -import { createTestProjectDefinitionContainer } from '#src/definition/project-definition-container.test-utils.js'; import { createTestFeature, createTestModel, createTestScalarField, } from '#src/testing/definition-helpers.test-helper.js'; +import { createTestProjectDefinitionContainer } from '#src/testing/project-definition-container.test-helper.js'; import { diffDefinition } from './diff-definition.js'; diff --git a/packages/project-builder-lib/src/tools/merge-schema/index.ts b/packages/project-builder-lib/src/tools/merge-schema/index.ts index ee68cd504..c0e968188 100644 --- a/packages/project-builder-lib/src/tools/merge-schema/index.ts +++ b/packages/project-builder-lib/src/tools/merge-schema/index.ts @@ -1,5 +1,13 @@ -export { diffDefinition } from './diff-definition.js'; -export type { DefinitionDiff, DefinitionDiffEntry } from './diff-definition.js'; +export { + diffDefinition, + diffSerializedDefinitions, +} from './diff-definition.js'; +export type { + DefinitionDiff, + DefinitionDiffEntry, + DiffSerializedDefinitionsOptions, +} from './diff-definition.js'; +export { getEntityName } from './entity-utils.js'; export { mergeDataWithSchema } from './merge-data-with-schema.js'; export { applyMergedDefinition, diff --git a/packages/project-builder-lib/src/tools/merge-schema/merge-data-with-schema.ts b/packages/project-builder-lib/src/tools/merge-schema/merge-data-with-schema.ts index 6c2c46919..5c4646b1b 100644 --- a/packages/project-builder-lib/src/tools/merge-schema/merge-data-with-schema.ts +++ b/packages/project-builder-lib/src/tools/merge-schema/merge-data-with-schema.ts @@ -1,13 +1,13 @@ import type { PartialDeep } from 'type-fest'; import type { z } from 'zod'; -import { cloneDeep, toMerged } from 'es-toolkit'; -import { set } from 'es-toolkit/compat'; +import { toMerged } from 'es-toolkit'; import type { EntitySchemaMeta } from '#src/references/definition-ref-registry.js'; import { getSchemaChildren } from '#src/parser/schema-structure.js'; +import { assignEntityIds } from '../assign-entity-ids.js'; import { getEntityMeta, getEntityName } from './entity-utils.js'; import { getMergeRule } from './merge-rule-registry.js'; @@ -187,11 +187,7 @@ function mergeEntityArray( for (const desiredItem of desired) { const name = getEntityName(entityMeta, desiredItem); if (!seen.has(name)) { - const baseItem = set( - cloneDeep(desiredItem), - entityMeta.idPath, - entityMeta.type.generateNewId(), - ); + const baseItem = assignEntityIds(elementSchema, desiredItem); result.push( mergeDataWithSchemaInternal(elementSchema, {}, baseItem) as PlainObject, ); diff --git a/packages/project-builder-lib/src/tools/merge-schema/merge-data-with-schema.unit.test.ts b/packages/project-builder-lib/src/tools/merge-schema/merge-data-with-schema.unit.test.ts index 589c32158..ea4fa5ddc 100644 --- a/packages/project-builder-lib/src/tools/merge-schema/merge-data-with-schema.unit.test.ts +++ b/packages/project-builder-lib/src/tools/merge-schema/merge-data-with-schema.unit.test.ts @@ -3,12 +3,12 @@ import { z } from 'zod'; import type { ProjectDefinitionContainer } from '#src/definition/project-definition-container.js'; -import { createTestProjectDefinitionContainer } from '#src/definition/project-definition-container.test-utils.js'; import { createTestFeature, createTestModel, createTestScalarField, } from '#src/testing/definition-helpers.test-helper.js'; +import { createTestProjectDefinitionContainer } from '#src/testing/project-definition-container.test-helper.js'; import { mergeDataWithSchema } from './merge-data-with-schema.js'; import { mergeDefinitionContainer } from './merge-definition.js'; diff --git a/packages/project-builder-lib/src/tools/merge-schema/merge-definition.unit.test.ts b/packages/project-builder-lib/src/tools/merge-schema/merge-definition.unit.test.ts index 0c53ebbc2..eeb5bea48 100644 --- a/packages/project-builder-lib/src/tools/merge-schema/merge-definition.unit.test.ts +++ b/packages/project-builder-lib/src/tools/merge-schema/merge-definition.unit.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from 'vitest'; -import { createTestProjectDefinitionContainer } from '#src/definition/project-definition-container.test-utils.js'; import { serializeSchema } from '#src/references/serialize-schema.js'; import { createTestFeature, createTestModel, createTestScalarField, } from '#src/testing/definition-helpers.test-helper.js'; +import { createTestProjectDefinitionContainer } from '#src/testing/project-definition-container.test-helper.js'; import { mergeDefinitionContainer } from './merge-definition.js'; diff --git a/packages/project-builder-lib/src/tools/merge-schema/walk-schema.unit.test.ts b/packages/project-builder-lib/src/tools/merge-schema/walk-schema.unit.test.ts index ad845fc7f..7fa3edcb2 100644 --- a/packages/project-builder-lib/src/tools/merge-schema/walk-schema.unit.test.ts +++ b/packages/project-builder-lib/src/tools/merge-schema/walk-schema.unit.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { createTestProjectDefinitionContainer } from '#src/definition/project-definition-container.test-utils.js'; import { createTestFeature } from '#src/testing/definition-helpers.test-helper.js'; +import { createTestProjectDefinitionContainer } from '#src/testing/project-definition-container.test-helper.js'; import { collectEntityArrays } from './walk-schema.js'; diff --git a/packages/project-builder-server/package.json b/packages/project-builder-server/package.json index 191f7e595..1a575d48c 100644 --- a/packages/project-builder-server/package.json +++ b/packages/project-builder-server/package.json @@ -67,12 +67,13 @@ "diff": "^8.0.3", "es-toolkit": "1.44.0", "execa": "9.3.0", + "fast-json-patch": "^3.1.1", "fastify": "5.7.4", "fastify-plugin": "5.1.0", "fastify-type-provider-zod": "^6.0.0", "globby": "^14.0.2", "ignore": "^7.0.5", - "immer": "10.1.1", + "immer": "~11.1.4", "inflection": "3.0.0", "isbinaryfile": "^5.0.4", "micromatch": "^4.0.8", @@ -81,7 +82,8 @@ "p-limit": "6.1.0", "pino": "9.5.0", "ts-morph": "27.0.2", - "zod": "catalog:" + "zod": "catalog:", + "zod-to-ts": "^2.0.0" }, "devDependencies": { "@baseplate-dev/tools": "workspace:*", diff --git a/packages/project-builder-server/src/actions/__tests__/action-test-utils.ts b/packages/project-builder-server/src/actions/__tests__/action-test-utils.ts new file mode 100644 index 000000000..14b17178c --- /dev/null +++ b/packages/project-builder-server/src/actions/__tests__/action-test-utils.ts @@ -0,0 +1,60 @@ +import type { + ProjectDefinitionInput, + SchemaParserContext, +} from '@baseplate-dev/project-builder-lib'; + +import { createTestProjectDefinitionContainer } from '@baseplate-dev/project-builder-lib/testing'; +import { createConsoleLogger } from '@baseplate-dev/sync'; + +import type { ServiceActionContext } from '#src/actions/types.js'; + +import type { EntityServiceContextResult } from '../definition/load-entity-service-context.js'; + +/** + * Creates a minimal ServiceActionContext for testing. + * + * @param overrides - Partial overrides for context properties. + * @returns A ServiceActionContext with sensible test defaults. + */ +export function createTestActionContext( + overrides: Partial = {}, +): ServiceActionContext { + return { + projects: [ + { + id: 'test-project', + name: 'test-project', + directory: '/test-project', + baseplateDirectory: '/test-project/baseplate', + type: 'user', + }, + ], + plugins: [], + userConfig: {}, + logger: createConsoleLogger('warn'), + cliVersion: '0.0.0-test', + sessionId: 'default', + ...overrides, + }; +} + +/** + * Creates a test EntityServiceContext from a partial project definition input. + * + * Uses `createTestProjectDefinitionContainer` to build the container in-memory, + * then converts it to an EntityServiceContext. No file I/O required. + * + * @param input - Partial ProjectDefinitionInput to customise the test definition. + * @returns An EntityServiceContextResult matching the shape returned by `loadEntityServiceContext`. + */ +export interface TestEntityServiceContextResult extends EntityServiceContextResult { + parserContext: SchemaParserContext; +} + +export function createTestEntityServiceContext( + input: Partial = {}, +): TestEntityServiceContextResult { + const container = createTestProjectDefinitionContainer(input); + const entityContext = container.toEntityServiceContext(); + return { entityContext, container, parserContext: container.parserContext }; +} diff --git a/packages/project-builder-server/src/actions/definition/commit-draft.action.ts b/packages/project-builder-server/src/actions/definition/commit-draft.action.ts new file mode 100644 index 000000000..bb53adff6 --- /dev/null +++ b/packages/project-builder-server/src/actions/definition/commit-draft.action.ts @@ -0,0 +1,103 @@ +import { + collectDefinitionIssues, + ProjectDefinitionContainer, +} from '@baseplate-dev/project-builder-lib'; +import { writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { z } from 'zod'; + +import { createServiceAction } from '#src/actions/types.js'; +import { resolveBaseplateDir } from '#src/diff/snapshot/snapshot-utils.js'; +import { createNodeSchemaParserContext } from '#src/plugins/node-plugin-store.js'; + +import { getProjectByNameOrId } from '../utils/projects.js'; +import { + deleteDraftSession, + loadDefinitionHash, + loadDraftSession, +} from './draft-session.js'; +import { definitionIssueSchema } from './validate-draft.js'; + +const commitDraftInputSchema = z.object({ + project: z.string().describe('The name or ID of the project.'), +}); + +const commitDraftOutputSchema = z.object({ + message: z.string().describe('A summary of the commit result.'), + issues: z + .array(definitionIssueSchema) + .optional() + .describe('Definition issues that blocked the commit.'), +}); + +export const commitDraftAction = createServiceAction({ + name: 'commit-draft', + title: 'Commit Draft', + description: + 'Commit the staged draft changes to the project-definition.json file.', + inputSchema: commitDraftInputSchema, + outputSchema: commitDraftOutputSchema, + handler: async (input, context) => { + const project = getProjectByNameOrId(context.projects, input.project); + + const session = await loadDraftSession(project.directory); + if (!session) { + throw new Error( + 'No draft session found. Stage changes first with stage-create-entity, stage-update-entity, or stage-delete-entity.', + ); + } + + // Verify the definition hasn't changed since the draft was created + const currentHash = await loadDefinitionHash(project.directory); + if (currentHash !== session.definitionHash) { + throw new Error( + 'The project definition has changed since the draft was created. ' + + 'Discard the draft with discard-draft and start over.', + ); + } + + // Convert the serialized draft back to a proper definition and serialize it + const parserContext = await createNodeSchemaParserContext( + project, + context.logger, + context.plugins, + context.cliVersion, + ); + + const container = ProjectDefinitionContainer.fromSerializedConfig( + session.draftDefinition, + parserContext, + ); + + // Validate the draft definition before committing + const issues = collectDefinitionIssues( + container.schema, + container.definition, + container.pluginStore, + ); + + if (issues.length > 0) { + const messages = issues + .map((i) => `[${i.severity}] ${i.message}`) + .join('; '); + throw new Error(`Commit blocked by definition issues: ${messages}`); + } + + const serializedContents = container.toSerializedContents(); + + // Write to project-definition.json + const baseplateDir = resolveBaseplateDir(project.directory); + const projectJsonPath = path.join(baseplateDir, 'project-definition.json'); + await writeFile(projectJsonPath, serializedContents, 'utf-8'); + + // Clean up draft session + await deleteDraftSession(project.directory); + + return { + message: 'Draft committed successfully to project-definition.json.', + }; + }, + writeCliOutput: (output) => { + console.info(`✓ ${output.message}`); + }, +}); diff --git a/packages/project-builder-server/src/actions/definition/definition-actions.int.test.ts b/packages/project-builder-server/src/actions/definition/definition-actions.int.test.ts new file mode 100644 index 000000000..c2fe957e5 --- /dev/null +++ b/packages/project-builder-server/src/actions/definition/definition-actions.int.test.ts @@ -0,0 +1,317 @@ +import { + createTestFeature, + createTestModel, + createTestScalarField, +} from '@baseplate-dev/project-builder-lib/testing'; +import { vol } from 'memfs'; +import { readFile } from 'node:fs/promises'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { invokeServiceActionAsCli } from '#src/actions/utils/cli.js'; + +import type { DraftSessionContext } from './draft-session.js'; + +import { + createTestActionContext, + createTestEntityServiceContext, +} from '../__tests__/action-test-utils.js'; +import { getOrCreateDraftSession } from './draft-session.js'; +import { getEntitySchemaAction } from './get-entity-schema.action.js'; +import { getEntityAction } from './get-entity.action.js'; +import { listEntitiesAction } from './list-entities.action.js'; +import { listEntityTypesAction } from './list-entity-types.action.js'; +import { loadEntityServiceContext } from './load-entity-service-context.js'; +import { stageCreateEntityAction } from './stage-create-entity.action.js'; +import { stageDeleteEntityAction } from './stage-delete-entity.action.js'; +import { stageUpdateEntityAction } from './stage-update-entity.action.js'; + +vi.mock('./load-entity-service-context.js'); +vi.mock('node:fs/promises'); + +// Only mock getOrCreateDraftSession — let saveDraftSession use memfs +vi.mock('./draft-session.js', async () => { + const actual = await vi.importActual('./draft-session.js'); + return { + ...actual, + getOrCreateDraftSession: vi.fn(), + }; +}); + +// -- Test fixtures ----------------------------------------------------------- + +const blogFeature = createTestFeature({ name: 'blog' }); + +const titleField = createTestScalarField({ name: 'title', type: 'string' }); +const contentField = createTestScalarField({ name: 'content', type: 'string' }); + +const blogPostModel = createTestModel({ + name: 'BlogPost', + featureRef: blogFeature.name, + model: { + fields: [ + createTestScalarField({ + name: 'id', + type: 'uuid', + options: { genUuid: true }, + }), + titleField, + contentField, + ], + primaryKeyFieldRefs: ['id'], + }, +}); + +const testEntityServiceContext = createTestEntityServiceContext({ + features: [blogFeature], + models: [blogPostModel], +}); + +const PROJECT_DIR = '/test-project'; + +const context = createTestActionContext(); + +function createMockDraftSessionContext(): DraftSessionContext { + return { + session: { + sessionId: 'default', + definitionHash: 'test-hash', + draftDefinition: + testEntityServiceContext.entityContext.serializedDefinition, + }, + entityContext: testEntityServiceContext.entityContext, + parserContext: testEntityServiceContext.parserContext, + projectDirectory: PROJECT_DIR, + }; +} + +// -- Setup ------------------------------------------------------------------- + +beforeEach(() => { + vol.reset(); + vi.mocked(loadEntityServiceContext).mockResolvedValue( + testEntityServiceContext, + ); + vi.mocked(getOrCreateDraftSession).mockResolvedValue( + createMockDraftSessionContext(), + ); +}); + +// -- Read action tests ------------------------------------------------------- + +describe('list-entity-types', () => { + it('should return available entity types', async () => { + const result = await invokeServiceActionAsCli( + listEntityTypesAction, + { project: 'test-project' }, + context, + ); + + expect(result.entityTypes).toBeDefined(); + expect(result.entityTypes.length).toBeGreaterThan(0); + + const typeNames = result.entityTypes.map((t: { name: string }) => t.name); + expect(typeNames).toContain('feature'); + expect(typeNames).toContain('model'); + }); +}); + +describe('list-entities', () => { + it('should list features', async () => { + const result = await invokeServiceActionAsCli( + listEntitiesAction, + { project: 'test-project', entityTypeName: 'feature' }, + context, + ); + + expect(result.entities).toHaveLength(1); + expect(result.entities[0]).toMatchObject({ + name: 'blog', + type: 'feature', + }); + }); + + it('should list models', async () => { + const result = await invokeServiceActionAsCli( + listEntitiesAction, + { project: 'test-project', entityTypeName: 'model' }, + context, + ); + + expect(result.entities).toHaveLength(1); + expect(result.entities[0]).toMatchObject({ + name: 'BlogPost', + type: 'model', + }); + }); + + it('should list nested model fields with parent ID', async () => { + const result = await invokeServiceActionAsCli( + listEntitiesAction, + { + project: 'test-project', + entityTypeName: 'model-scalar-field', + parentEntityId: blogPostModel.id, + }, + context, + ); + + const fieldNames = result.entities.map((e: { name: string }) => e.name); + expect(fieldNames).toContain('title'); + expect(fieldNames).toContain('content'); + }); +}); + +describe('get-entity', () => { + it('should retrieve a model by ID', async () => { + const result = await invokeServiceActionAsCli( + getEntityAction, + { project: 'test-project', entityId: blogPostModel.id }, + context, + ); + + expect(result.entity).not.toBeNull(); + expect(result.entity).toHaveProperty('name', 'BlogPost'); + }); + + it('should return null for a nonexistent entity ID', async () => { + const result = await invokeServiceActionAsCli( + getEntityAction, + { project: 'test-project', entityId: 'model:nonexistent' }, + context, + ); + + expect(result.entity).toBeNull(); + }); +}); + +describe('get-entity-schema', () => { + it('should return TypeScript type for a known entity type', async () => { + const result = await invokeServiceActionAsCli( + getEntitySchemaAction, + { project: 'test-project', entityTypeName: 'model' }, + context, + ); + + expect(result.entityTypeName).toBe('model'); + expect(typeof result.schema).toBe('string'); + expect(result.schema).toContain('name'); + }); + + it('should throw for an unknown entity type', async () => { + await expect( + invokeServiceActionAsCli( + getEntitySchemaAction, + { project: 'test-project', entityTypeName: 'nonexistent' }, + context, + ), + ).rejects.toThrow(/Unknown entity type/); + }); +}); + +// -- Write action tests ------------------------------------------------------ + +describe('stage-create-entity', () => { + it('should stage a new feature and write draft files to disk', async () => { + const result = await invokeServiceActionAsCli( + stageCreateEntityAction, + { + project: 'test-project', + entityTypeName: 'feature', + entityData: { name: 'payments' }, + }, + context, + ); + + expect(result.message).toContain('Staged creation'); + + // Verify draft files were written via memfs + const sessionContents = await readFile( + `${PROJECT_DIR}/baseplate/.build/draft-session.json`, + 'utf-8', + ); + const session = JSON.parse(sessionContents) as { + sessionId: string; + definitionHash: string; + }; + expect(session.sessionId).toBe('default'); + expect(session.definitionHash).toBe('test-hash'); + + const defContents = await readFile( + `${PROJECT_DIR}/baseplate/.build/draft-definition.json`, + 'utf-8', + ); + const definition = JSON.parse(defContents) as { + features: { name: string }[]; + }; + // The new feature should be in the draft definition + expect(definition.features.some((f) => f.name === 'payments')).toBe(true); + }); +}); + +describe('stage-update-entity', () => { + it('should stage an entity update and write draft files', async () => { + const result = await invokeServiceActionAsCli( + stageUpdateEntityAction, + { + project: 'test-project', + entityTypeName: 'model', + entityId: blogPostModel.id, + entityData: { + id: blogPostModel.id, + name: 'BlogPostUpdated', + featureRef: 'blog', + model: { + fields: [ + { + name: 'id', + type: 'uuid', + isOptional: false, + options: { genUuid: true }, + }, + ], + primaryKeyFieldRefs: ['id'], + }, + }, + }, + context, + ); + + expect(result.message).toContain('Staged update'); + + const defContents = await readFile( + `${PROJECT_DIR}/baseplate/.build/draft-definition.json`, + 'utf-8', + ); + const definition = JSON.parse(defContents) as { + models: { name: string }[]; + }; + expect(definition.models.some((m) => m.name === 'BlogPostUpdated')).toBe( + true, + ); + }); +}); + +describe('stage-delete-entity', () => { + it('should stage an entity deletion and write draft files', async () => { + const result = await invokeServiceActionAsCli( + stageDeleteEntityAction, + { + project: 'test-project', + entityTypeName: 'feature', + entityId: blogFeature.id, + }, + context, + ); + + expect(result.message).toContain('Staged deletion'); + + const defContents = await readFile( + `${PROJECT_DIR}/baseplate/.build/draft-definition.json`, + 'utf-8', + ); + const definition = JSON.parse(defContents) as { + features: { name: string }[]; + }; + expect(definition.features.some((f) => f.name === 'blog')).toBe(false); + }); +}); diff --git a/packages/project-builder-server/src/actions/definition/discard-draft.action.ts b/packages/project-builder-server/src/actions/definition/discard-draft.action.ts new file mode 100644 index 000000000..db9ec9882 --- /dev/null +++ b/packages/project-builder-server/src/actions/definition/discard-draft.action.ts @@ -0,0 +1,38 @@ +import { z } from 'zod'; + +import { createServiceAction } from '#src/actions/types.js'; + +import { getProjectByNameOrId } from '../utils/projects.js'; +import { deleteDraftSession, loadDraftSession } from './draft-session.js'; + +const discardDraftInputSchema = z.object({ + project: z.string().describe('The name or ID of the project.'), +}); + +const discardDraftOutputSchema = z.object({ + message: z.string().describe('A summary of the discard result.'), +}); + +export const discardDraftAction = createServiceAction({ + name: 'discard-draft', + title: 'Discard Draft', + description: + 'Discard the current draft session, removing all staged changes.', + inputSchema: discardDraftInputSchema, + outputSchema: discardDraftOutputSchema, + handler: async (input, context) => { + const project = getProjectByNameOrId(context.projects, input.project); + + const session = await loadDraftSession(project.directory); + if (!session) { + return { message: 'No draft session to discard.' }; + } + + await deleteDraftSession(project.directory); + + return { message: 'Draft session discarded.' }; + }, + writeCliOutput: (output) => { + console.info(`✓ ${output.message}`); + }, +}); diff --git a/packages/project-builder-server/src/actions/definition/draft-session.ts b/packages/project-builder-server/src/actions/definition/draft-session.ts new file mode 100644 index 000000000..72ffccf97 --- /dev/null +++ b/packages/project-builder-server/src/actions/definition/draft-session.ts @@ -0,0 +1,205 @@ +import type { + EntityServiceContext, + SchemaParserContext, +} from '@baseplate-dev/project-builder-lib'; + +import { ProjectDefinitionContainer } from '@baseplate-dev/project-builder-lib'; +import { hashWithSHA256, stringifyPrettyStable } from '@baseplate-dev/utils'; +import { fileExists, handleFileNotFoundError } from '@baseplate-dev/utils/node'; +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { z } from 'zod'; + +import type { ServiceActionContext } from '#src/actions/types.js'; + +import { resolveBaseplateDir } from '#src/diff/snapshot/snapshot-utils.js'; +import { createNodeSchemaParserContext } from '#src/plugins/node-plugin-store.js'; +import { loadProjectDefinition } from '#src/project-definition/load-project-definition.js'; + +import { getProjectByNameOrId } from '../utils/projects.js'; + +const draftSessionMetadataSchema = z.object({ + /** ID of the session that created this draft. */ + sessionId: z.string(), + /** SHA-256 hash of the original project-definition.json when the draft was created. */ + definitionHash: z.string(), +}); + +/** + * Metadata for a draft session, stored separately from the large definition payload. + */ +export type DraftSessionMetadata = z.infer; + +/** + * A draft session holds staged entity changes before they are committed + * to the project-definition.json file. + */ +export interface DraftSession extends DraftSessionMetadata { + /** The modified serialized definition (with names, not IDs). */ + draftDefinition: Record; +} + +const DRAFT_SESSION_FILENAME = 'draft-session.json'; +const DRAFT_DEFINITION_FILENAME = 'draft-definition.json'; + +function getDraftDir(projectDirectory: string): string { + const baseplateDir = resolveBaseplateDir(projectDirectory); + return path.join(baseplateDir, '.build'); +} + +function getDraftSessionPath(projectDirectory: string): string { + return path.join(getDraftDir(projectDirectory), DRAFT_SESSION_FILENAME); +} + +function getDraftDefinitionPath(projectDirectory: string): string { + return path.join(getDraftDir(projectDirectory), DRAFT_DEFINITION_FILENAME); +} + +export async function loadDraftSession( + projectDirectory: string, +): Promise { + const sessionPath = getDraftSessionPath(projectDirectory); + if (!(await fileExists(sessionPath))) { + return null; + } + const [sessionContents, definitionContents] = await Promise.all([ + readFile(sessionPath, 'utf-8'), + readFile(getDraftDefinitionPath(projectDirectory), 'utf-8'), + ]); + const metadata = draftSessionMetadataSchema.parse( + JSON.parse(sessionContents), + ); + const draftDefinition = JSON.parse(definitionContents) as Record< + string, + unknown + >; + return { ...metadata, draftDefinition }; +} + +export async function saveDraftSession( + projectDirectory: string, + session: DraftSession, +): Promise { + const draftDir = getDraftDir(projectDirectory); + await mkdir(draftDir, { recursive: true }); + const metadata: DraftSessionMetadata = { + sessionId: session.sessionId, + definitionHash: session.definitionHash, + }; + await Promise.all([ + writeFile( + getDraftSessionPath(projectDirectory), + stringifyPrettyStable(metadata), + ), + writeFile( + getDraftDefinitionPath(projectDirectory), + stringifyPrettyStable(session.draftDefinition), + ), + ]); +} + +export async function deleteDraftSession( + projectDirectory: string, +): Promise { + const sessionPath = getDraftSessionPath(projectDirectory); + const definitionPath = getDraftDefinitionPath(projectDirectory); + await Promise.all([ + rm(sessionPath).catch(handleFileNotFoundError), + rm(definitionPath).catch(handleFileNotFoundError), + ]); +} + +export interface DraftSessionContext { + session: DraftSession; + entityContext: EntityServiceContext; + parserContext: SchemaParserContext; + projectDirectory: string; +} + +/** + * Gets the existing draft session or creates a new one from the current project definition. + * + * If a draft exists but the session ID or definition hash doesn't match, an error is thrown + * requiring the caller to discard the stale draft first. + */ +export async function getOrCreateDraftSession( + projectNameOrId: string, + context: ServiceActionContext, +): Promise { + const project = getProjectByNameOrId(context.projects, projectNameOrId); + const { sessionId } = context; + + const parserContext = await createNodeSchemaParserContext( + project, + context.logger, + context.plugins, + context.cliVersion, + ); + + const { definition, hash } = await loadProjectDefinition( + project.baseplateDirectory, + parserContext, + ); + + const existingDraft = await loadDraftSession(project.directory); + + if (existingDraft) { + if (existingDraft.sessionId !== sessionId) { + throw new Error( + `A draft session exists from a different session (${existingDraft.sessionId}). ` + + 'Discard it with discard-draft before starting a new one.', + ); + } + if (existingDraft.definitionHash !== hash) { + throw new Error( + 'The project definition has changed since the draft was created. ' + + 'Discard the draft with discard-draft and start over.', + ); + } + + // Rebuild EntityServiceContext from the draft definition + const draftContainer = ProjectDefinitionContainer.fromSerializedConfig( + existingDraft.draftDefinition, + parserContext, + ); + const entityContext = draftContainer.toEntityServiceContext(); + + return { + session: existingDraft, + entityContext, + parserContext, + projectDirectory: project.directory, + }; + } + + // Create a new draft from the current definition + const container = ProjectDefinitionContainer.fromSerializedConfig( + definition, + parserContext, + ); + const entityContext = container.toEntityServiceContext(); + const session: DraftSession = { + sessionId, + definitionHash: hash, + draftDefinition: entityContext.serializedDefinition, + }; + + return { + session, + entityContext, + parserContext, + projectDirectory: project.directory, + }; +} + +/** + * Loads the definition hash for the given project directory. + */ +export async function loadDefinitionHash( + projectDirectory: string, +): Promise { + const baseplateDir = resolveBaseplateDir(projectDirectory); + const projectJsonPath = path.join(baseplateDir, 'project-definition.json'); + const contents = await readFile(projectJsonPath, 'utf-8'); + return hashWithSHA256(contents); +} diff --git a/packages/project-builder-server/src/actions/definition/get-entity-schema.action.ts b/packages/project-builder-server/src/actions/definition/get-entity-schema.action.ts new file mode 100644 index 000000000..bc8249f48 --- /dev/null +++ b/packages/project-builder-server/src/actions/definition/get-entity-schema.action.ts @@ -0,0 +1,63 @@ +import { z } from 'zod'; +import { createAuxiliaryTypeStore, printNode, zodToTs } from 'zod-to-ts'; + +import { createServiceAction } from '#src/actions/types.js'; + +import { loadEntityServiceContext } from './load-entity-service-context.js'; + +const getEntitySchemaInputSchema = z.object({ + project: z.string().describe('The name or ID of the project.'), + entityTypeName: z + .string() + .describe( + 'The entity type to get the schema for (e.g., "feature", "model", "model-scalar-field").', + ), +}); + +const getEntitySchemaOutputSchema = z.object({ + entityTypeName: z.string().describe('The entity type name.'), + parentEntityTypeName: z + .string() + .nullable() + .describe('The parent entity type name, or null for top-level entities.'), + schema: z + .string() + .describe('The TypeScript type representation of this entity type.'), +}); + +export const getEntitySchemaAction = createServiceAction({ + name: 'get-entity-schema', + title: 'Get Entity Schema', + description: + 'Get the TypeScript type for a given entity type. Useful for understanding valid field shapes before creating or updating entities.', + inputSchema: getEntitySchemaInputSchema, + outputSchema: getEntitySchemaOutputSchema, + writeCliOutput: (output) => { + console.info(output.schema); + }, + handler: async (input, context) => { + const { entityContext } = await loadEntityServiceContext( + input.project, + context, + ); + + const metadata = entityContext.entityTypeMap.get(input.entityTypeName); + if (!metadata) { + throw new Error( + `Unknown entity type: "${input.entityTypeName}". Use list-entities with entityTypeName "*" to discover available types.`, + ); + } + + const { node } = zodToTs(metadata.elementSchema, { + auxiliaryTypeStore: createAuxiliaryTypeStore(), + unrepresentable: 'any', + }); + const schemaText = printNode(node); + + return { + entityTypeName: input.entityTypeName, + parentEntityTypeName: metadata.parentEntityTypeName ?? null, + schema: schemaText, + }; + }, +}); diff --git a/packages/project-builder-server/src/actions/definition/get-entity.action.ts b/packages/project-builder-server/src/actions/definition/get-entity.action.ts new file mode 100644 index 000000000..4d3e047a3 --- /dev/null +++ b/packages/project-builder-server/src/actions/definition/get-entity.action.ts @@ -0,0 +1,49 @@ +import { getEntity } from '@baseplate-dev/project-builder-lib'; +import { stringifyPrettyStable } from '@baseplate-dev/utils'; +import { z } from 'zod'; + +import { createServiceAction } from '#src/actions/types.js'; + +import { loadEntityServiceContext } from './load-entity-service-context.js'; + +const getEntityInputSchema = z.object({ + project: z.string().describe('The name or ID of the project.'), + entityId: z + .string() + .describe('The ID of the entity to retrieve (e.g., "model:abc123").'), +}); + +const getEntityOutputSchema = z.object({ + entity: z + .record(z.string(), z.unknown()) + .nullable() + .describe( + 'The serialized entity data with references resolved to names, or null if not found.', + ), +}); + +export const getEntityAction = createServiceAction({ + name: 'get-entity', + title: 'Get Entity', + description: + 'Get the full serialized data for a specific entity by ID. Returns name-resolved JSON.', + inputSchema: getEntityInputSchema, + outputSchema: getEntityOutputSchema, + writeCliOutput: (output) => { + if (output.entity === null) { + console.info('Entity not found.'); + return; + } + console.info(stringifyPrettyStable(output.entity)); + }, + handler: async (input, context) => { + const { entityContext } = await loadEntityServiceContext( + input.project, + context, + ); + + const entity = getEntity(input.entityId, entityContext); + + return { entity: entity ?? null }; + }, +}); diff --git a/packages/project-builder-server/src/actions/definition/index.ts b/packages/project-builder-server/src/actions/definition/index.ts new file mode 100644 index 000000000..4af43f23a --- /dev/null +++ b/packages/project-builder-server/src/actions/definition/index.ts @@ -0,0 +1,10 @@ +export { commitDraftAction } from './commit-draft.action.js'; +export { discardDraftAction } from './discard-draft.action.js'; +export { getEntitySchemaAction } from './get-entity-schema.action.js'; +export { getEntityAction } from './get-entity.action.js'; +export { listEntitiesAction } from './list-entities.action.js'; +export { listEntityTypesAction } from './list-entity-types.action.js'; +export { showDraftAction } from './show-draft.action.js'; +export { stageCreateEntityAction } from './stage-create-entity.action.js'; +export { stageDeleteEntityAction } from './stage-delete-entity.action.js'; +export { stageUpdateEntityAction } from './stage-update-entity.action.js'; diff --git a/packages/project-builder-server/src/actions/definition/list-entities.action.ts b/packages/project-builder-server/src/actions/definition/list-entities.action.ts new file mode 100644 index 000000000..2a5423c16 --- /dev/null +++ b/packages/project-builder-server/src/actions/definition/list-entities.action.ts @@ -0,0 +1,61 @@ +import { listEntities } from '@baseplate-dev/project-builder-lib'; +import { z } from 'zod'; + +import { createServiceAction } from '#src/actions/types.js'; + +import { loadEntityServiceContext } from './load-entity-service-context.js'; + +const listEntitiesInputSchema = z.object({ + project: z.string().describe('The name or ID of the project.'), + entityTypeName: z + .string() + .describe( + 'The entity type to list (e.g., "feature", "model", "model-scalar-field").', + ), + parentEntityId: z + .string() + .optional() + .describe( + 'Required for nested entity types. The ID of the parent entity to scope the listing.', + ), +}); + +const entityStubSchema = z.object({ + id: z.string().describe('The entity ID.'), + name: z.string().describe('The entity name.'), + type: z.string().describe('The entity type name.'), +}); + +const listEntitiesOutputSchema = z.object({ + entities: z.array(entityStubSchema).describe('The list of entities.'), +}); + +export const listEntitiesAction = createServiceAction({ + name: 'list-entities', + title: 'List Entities', + description: + 'List entities of a given type in the project definition. Use list-entity-types to discover available entity type names.', + inputSchema: listEntitiesInputSchema, + outputSchema: listEntitiesOutputSchema, + writeCliOutput: (output) => { + for (const entity of output.entities) { + console.info(` ${entity.name} (${entity.id})`); + } + }, + handler: async (input, context) => { + const { entityContext } = await loadEntityServiceContext( + input.project, + context, + ); + + const entities = listEntities( + { + entityTypeName: input.entityTypeName, + parentEntityId: input.parentEntityId, + }, + entityContext, + ); + + return { entities }; + }, +}); diff --git a/packages/project-builder-server/src/actions/definition/list-entity-types.action.ts b/packages/project-builder-server/src/actions/definition/list-entity-types.action.ts new file mode 100644 index 000000000..fc22db642 --- /dev/null +++ b/packages/project-builder-server/src/actions/definition/list-entity-types.action.ts @@ -0,0 +1,55 @@ +import { z } from 'zod'; + +import { createServiceAction } from '#src/actions/types.js'; + +import { loadEntityServiceContext } from './load-entity-service-context.js'; + +const listEntityTypesInputSchema = z.object({ + project: z.string().describe('The name or ID of the project.'), +}); + +const entityTypeInfoSchema = z.object({ + name: z.string().describe('The entity type name.'), + parentEntityTypeName: z + .string() + .nullable() + .describe('The parent entity type name, or null for top-level entities.'), +}); + +const listEntityTypesOutputSchema = z.object({ + entityTypes: z + .array(entityTypeInfoSchema) + .describe('The list of available entity types.'), +}); + +export const listEntityTypesAction = createServiceAction({ + name: 'list-entity-types', + title: 'List Entity Types', + description: + 'List all available entity types in the project definition schema. Returns type names and parent relationships.', + inputSchema: listEntityTypesInputSchema, + outputSchema: listEntityTypesOutputSchema, + writeCliOutput: (output) => { + for (const entityType of output.entityTypes) { + const parent = entityType.parentEntityTypeName + ? ` (parent: ${entityType.parentEntityTypeName})` + : ''; + console.info(` ${entityType.name}${parent}`); + } + }, + handler: async (input, context) => { + const { entityContext } = await loadEntityServiceContext( + input.project, + context, + ); + + const entityTypes = [...entityContext.entityTypeMap.entries()].map( + ([name, metadata]) => ({ + name, + parentEntityTypeName: metadata.parentEntityTypeName ?? null, + }), + ); + + return { entityTypes }; + }, +}); diff --git a/packages/project-builder-server/src/actions/definition/load-entity-service-context.ts b/packages/project-builder-server/src/actions/definition/load-entity-service-context.ts new file mode 100644 index 000000000..9abbd4b53 --- /dev/null +++ b/packages/project-builder-server/src/actions/definition/load-entity-service-context.ts @@ -0,0 +1,48 @@ +import type { EntityServiceContext } from '@baseplate-dev/project-builder-lib'; + +import { ProjectDefinitionContainer } from '@baseplate-dev/project-builder-lib'; + +import type { ServiceActionContext } from '#src/actions/types.js'; + +import { createNodeSchemaParserContext } from '#src/plugins/node-plugin-store.js'; +import { loadProjectDefinition } from '#src/project-definition/load-project-definition.js'; + +import { getProjectByNameOrId } from '../utils/projects.js'; + +export interface EntityServiceContextResult { + entityContext: EntityServiceContext; + container: ProjectDefinitionContainer; +} + +/** + * Loads a project definition and builds an EntityServiceContext for entity operations. + * + * Shared helper used by all definition read actions. + */ +export async function loadEntityServiceContext( + projectNameOrId: string, + context: ServiceActionContext, +): Promise { + const project = getProjectByNameOrId(context.projects, projectNameOrId); + + const parserContext = await createNodeSchemaParserContext( + project, + context.logger, + context.plugins, + context.cliVersion, + ); + + const { definition } = await loadProjectDefinition( + project.baseplateDirectory, + parserContext, + ); + + const container = ProjectDefinitionContainer.fromSerializedConfig( + definition, + parserContext, + ); + + const entityContext = container.toEntityServiceContext(); + + return { entityContext, container }; +} diff --git a/packages/project-builder-server/src/actions/definition/show-draft.action.ts b/packages/project-builder-server/src/actions/definition/show-draft.action.ts new file mode 100644 index 000000000..c7cbd55b5 --- /dev/null +++ b/packages/project-builder-server/src/actions/definition/show-draft.action.ts @@ -0,0 +1,149 @@ +import type { DefinitionDiffEntry } from '@baseplate-dev/project-builder-lib'; + +import { diffSerializedDefinitions } from '@baseplate-dev/project-builder-lib'; +import { stringifyPrettyStable } from '@baseplate-dev/utils'; +import jsonPatch from 'fast-json-patch'; +import { z } from 'zod'; + +import { createServiceAction } from '#src/actions/types.js'; + +import { getProjectByNameOrId } from '../utils/projects.js'; +import { loadDraftSession } from './draft-session.js'; +import { loadEntityServiceContext } from './load-entity-service-context.js'; + +const showDraftInputSchema = z.object({ + project: z.string().describe('The name or ID of the project.'), +}); + +const MAX_DETAILS_LENGTH = 2000; + +const draftChangeSchema = z.object({ + label: z + .string() + .describe('Human-readable label (e.g., "Feature: payments").'), + type: z.enum(['added', 'updated', 'removed']).describe('The type of change.'), + details: z + .string() + .nullable() + .describe( + 'For added: the entity JSON. For updated: a JSON Patch (RFC 6902) array. Null for removed.', + ), +}); + +const showDraftOutputSchema = z.object({ + hasDraft: z.boolean().describe('Whether a draft session exists.'), + sessionId: z + .string() + .nullable() + .describe('The session ID of the draft, or null if no draft.'), + definitionHash: z + .string() + .nullable() + .describe( + 'The hash of the project definition when the draft was created, or null if no draft.', + ), + changes: z + .array(draftChangeSchema) + .nullable() + .describe('Entity-level changes in the draft, or null if no draft.'), +}); + +function truncateDetails(text: string): string { + if (text.length <= MAX_DETAILS_LENGTH) { + return text; + } + return `${text.slice(0, MAX_DETAILS_LENGTH)}\n... (truncated)`; +} + +function formatChangeDetails(entry: DefinitionDiffEntry): string | null { + switch (entry.type) { + case 'added': { + const json = stringifyPrettyStable(entry.merged as object); + return truncateDetails(json); + } + case 'updated': { + const operations = jsonPatch.compare( + entry.current as object, + entry.merged as object, + ); + const json = stringifyPrettyStable(operations); + return truncateDetails(json); + } + case 'removed': { + return null; + } + } +} + +export const showDraftAction = createServiceAction({ + name: 'show-draft', + title: 'Show Draft', + description: + 'Show the current draft session status and staged changes for a project.', + inputSchema: showDraftInputSchema, + outputSchema: showDraftOutputSchema, + handler: async (input, context) => { + const project = getProjectByNameOrId(context.projects, input.project); + + const session = await loadDraftSession(project.directory); + if (!session) { + return { + hasDraft: false, + sessionId: null, + definitionHash: null, + changes: null, + }; + } + + // Load the current definition to diff against + const { container } = await loadEntityServiceContext( + input.project, + context, + ); + const currentEntityContext = container.toEntityServiceContext(); + + const diff = diffSerializedDefinitions( + container.schema, + currentEntityContext.serializedDefinition, + session.draftDefinition, + ); + + return { + hasDraft: true, + sessionId: session.sessionId, + definitionHash: session.definitionHash, + changes: diff.entries.map((entry) => ({ + label: entry.label, + type: entry.type, + details: formatChangeDetails(entry), + })), + }; + }, + writeCliOutput: (output) => { + if (!output.hasDraft) { + console.info('No draft session.'); + return; + } + console.info(`Draft session: ${output.sessionId}`); + console.info(`Definition hash: ${output.definitionHash}`); + + if (!output.changes || output.changes.length === 0) { + console.info('No changes.'); + return; + } + + console.info('Changes:'); + for (const change of output.changes) { + const prefix = + change.type === 'added' ? '+' : change.type === 'removed' ? '-' : '~'; + console.info(` ${prefix} ${change.label} (${change.type})`); + if (change.details) { + const indented = change.details + .split('\n') + .map((line) => ` ${line}`) + .join('\n'); + console.info(indented); + } + } + }, +}); diff --git a/packages/project-builder-server/src/actions/definition/stage-create-entity.action.ts b/packages/project-builder-server/src/actions/definition/stage-create-entity.action.ts new file mode 100644 index 000000000..7990a4060 --- /dev/null +++ b/packages/project-builder-server/src/actions/definition/stage-create-entity.action.ts @@ -0,0 +1,83 @@ +import { createEntity } from '@baseplate-dev/project-builder-lib'; +import { z } from 'zod'; + +import { createServiceAction } from '#src/actions/types.js'; + +import { getOrCreateDraftSession, saveDraftSession } from './draft-session.js'; +import { + definitionIssueSchema, + validateDraftDefinition, +} from './validate-draft.js'; + +const stageCreateEntityInputSchema = z.object({ + project: z.string().describe('The name or ID of the project.'), + entityTypeName: z + .string() + .describe('The entity type to create (e.g., "feature", "model").'), + entityData: z + .record(z.string(), z.unknown()) + .describe('The entity data to create.'), + parentEntityId: z + .string() + .optional() + .describe( + 'Required for nested entity types. The ID of the parent entity to add to.', + ), +}); + +const stageCreateEntityOutputSchema = z.object({ + message: z.string().describe('A summary of the staged change.'), + issues: z + .array(definitionIssueSchema) + .optional() + .describe('Definition issues found after staging.'), +}); + +export const stageCreateEntityAction = createServiceAction({ + name: 'stage-create-entity', + title: 'Stage Create Entity', + description: + 'Stage a new entity creation in the draft session. Changes are not persisted until commit-draft is called.', + inputSchema: stageCreateEntityInputSchema, + outputSchema: stageCreateEntityOutputSchema, + handler: async (input, context) => { + const { session, entityContext, parserContext, projectDirectory } = + await getOrCreateDraftSession(input.project, context); + + const newDefinition = createEntity( + { + entityTypeName: input.entityTypeName, + entityData: input.entityData, + parentEntityId: input.parentEntityId, + }, + entityContext, + ); + + session.draftDefinition = newDefinition; + + const { errors, warnings } = validateDraftDefinition( + newDefinition, + parserContext, + ); + + if (errors.length > 0) { + const messages = errors.map((e) => e.message).join('; '); + throw new Error(`Staging blocked by definition errors: ${messages}`); + } + + await saveDraftSession(projectDirectory, session); + + return { + message: `Staged creation of ${input.entityTypeName} entity. Use commit-draft to persist.`, + issues: warnings.length > 0 ? warnings : undefined, + }; + }, + writeCliOutput: (output) => { + console.info(`✓ ${output.message}`); + if (output.issues) { + for (const issue of output.issues) { + console.warn(` ⚠ ${issue.message}`); + } + } + }, +}); diff --git a/packages/project-builder-server/src/actions/definition/stage-delete-entity.action.ts b/packages/project-builder-server/src/actions/definition/stage-delete-entity.action.ts new file mode 100644 index 000000000..4fe176aab --- /dev/null +++ b/packages/project-builder-server/src/actions/definition/stage-delete-entity.action.ts @@ -0,0 +1,76 @@ +import { deleteEntity } from '@baseplate-dev/project-builder-lib'; +import { z } from 'zod'; + +import { createServiceAction } from '#src/actions/types.js'; + +import { getOrCreateDraftSession, saveDraftSession } from './draft-session.js'; +import { + definitionIssueSchema, + validateDraftDefinition, +} from './validate-draft.js'; + +const stageDeleteEntityInputSchema = z.object({ + project: z.string().describe('The name or ID of the project.'), + entityTypeName: z + .string() + .describe('The entity type being deleted (e.g., "feature", "model").'), + entityId: z + .string() + .describe('The ID of the entity to delete (e.g., "model:abc123").'), +}); + +const stageDeleteEntityOutputSchema = z.object({ + message: z.string().describe('A summary of the staged change.'), + issues: z + .array(definitionIssueSchema) + .optional() + .describe('Definition issues found after staging.'), +}); + +export const stageDeleteEntityAction = createServiceAction({ + name: 'stage-delete-entity', + title: 'Stage Delete Entity', + description: + 'Stage an entity deletion in the draft session. Changes are not persisted until commit-draft is called.', + inputSchema: stageDeleteEntityInputSchema, + outputSchema: stageDeleteEntityOutputSchema, + handler: async (input, context) => { + const { session, entityContext, parserContext, projectDirectory } = + await getOrCreateDraftSession(input.project, context); + + const newDefinition = deleteEntity( + { + entityTypeName: input.entityTypeName, + entityId: input.entityId, + }, + entityContext, + ); + + session.draftDefinition = newDefinition; + + const { errors, warnings } = validateDraftDefinition( + newDefinition, + parserContext, + ); + + if (errors.length > 0) { + const messages = errors.map((e) => e.message).join('; '); + throw new Error(`Staging blocked by definition errors: ${messages}`); + } + + await saveDraftSession(projectDirectory, session); + + return { + message: `Staged deletion of ${input.entityTypeName} entity "${input.entityId}". Use commit-draft to persist.`, + issues: warnings.length > 0 ? warnings : undefined, + }; + }, + writeCliOutput: (output) => { + console.info(`✓ ${output.message}`); + if (output.issues) { + for (const issue of output.issues) { + console.warn(` ⚠ ${issue.message}`); + } + } + }, +}); diff --git a/packages/project-builder-server/src/actions/definition/stage-update-entity.action.ts b/packages/project-builder-server/src/actions/definition/stage-update-entity.action.ts new file mode 100644 index 000000000..67cfc79be --- /dev/null +++ b/packages/project-builder-server/src/actions/definition/stage-update-entity.action.ts @@ -0,0 +1,80 @@ +import { updateEntity } from '@baseplate-dev/project-builder-lib'; +import { z } from 'zod'; + +import { createServiceAction } from '#src/actions/types.js'; + +import { getOrCreateDraftSession, saveDraftSession } from './draft-session.js'; +import { + definitionIssueSchema, + validateDraftDefinition, +} from './validate-draft.js'; + +const stageUpdateEntityInputSchema = z.object({ + project: z.string().describe('The name or ID of the project.'), + entityTypeName: z + .string() + .describe('The entity type being updated (e.g., "feature", "model").'), + entityId: z + .string() + .describe('The ID of the entity to update (e.g., "model:abc123").'), + entityData: z + .record(z.string(), z.unknown()) + .describe('The full updated entity data.'), +}); + +const stageUpdateEntityOutputSchema = z.object({ + message: z.string().describe('A summary of the staged change.'), + issues: z + .array(definitionIssueSchema) + .optional() + .describe('Definition issues found after staging.'), +}); + +export const stageUpdateEntityAction = createServiceAction({ + name: 'stage-update-entity', + title: 'Stage Update Entity', + description: + 'Stage an entity update in the draft session. Changes are not persisted until commit-draft is called.', + inputSchema: stageUpdateEntityInputSchema, + outputSchema: stageUpdateEntityOutputSchema, + handler: async (input, context) => { + const { session, entityContext, parserContext, projectDirectory } = + await getOrCreateDraftSession(input.project, context); + + const newDefinition = updateEntity( + { + entityTypeName: input.entityTypeName, + entityId: input.entityId, + entityData: input.entityData, + }, + entityContext, + ); + + session.draftDefinition = newDefinition; + + const { errors, warnings } = validateDraftDefinition( + newDefinition, + parserContext, + ); + + if (errors.length > 0) { + const messages = errors.map((e) => e.message).join('; '); + throw new Error(`Staging blocked by definition errors: ${messages}`); + } + + await saveDraftSession(projectDirectory, session); + + return { + message: `Staged update of ${input.entityTypeName} entity "${input.entityId}". Use commit-draft to persist.`, + issues: warnings.length > 0 ? warnings : undefined, + }; + }, + writeCliOutput: (output) => { + console.info(`✓ ${output.message}`); + if (output.issues) { + for (const issue of output.issues) { + console.warn(` ⚠ ${issue.message}`); + } + } + }, +}); diff --git a/packages/project-builder-server/src/actions/definition/validate-draft.ts b/packages/project-builder-server/src/actions/definition/validate-draft.ts new file mode 100644 index 000000000..e7a38ad22 --- /dev/null +++ b/packages/project-builder-server/src/actions/definition/validate-draft.ts @@ -0,0 +1,45 @@ +import type { + PartitionedIssues, + SchemaParserContext, +} from '@baseplate-dev/project-builder-lib'; + +import { + collectDefinitionIssues, + partitionIssuesBySeverity, + ProjectDefinitionContainer, +} from '@baseplate-dev/project-builder-lib'; +import { z } from 'zod'; + +export const definitionIssueSchema = z.object({ + message: z.string().describe('Human-readable description of the issue.'), + path: z + .array(z.union([z.string(), z.number()])) + .describe('Path in the definition where the issue originated.'), + severity: z + .enum(['error', 'warning']) + .describe( + "Issue severity: 'error' blocks the operation, 'warning' does not.", + ), +}); + +/** + * Validates a draft definition by collecting all definition issues + * (both field-level and definition-level) and partitioning them by severity. + */ +export function validateDraftDefinition( + draftDefinition: Record, + parserContext: SchemaParserContext, +): PartitionedIssues { + const container = ProjectDefinitionContainer.fromSerializedConfig( + draftDefinition, + parserContext, + ); + + const issues = collectDefinitionIssues( + container.schema, + container.definition, + container.pluginStore, + ); + + return partitionIssuesBySeverity(issues); +} diff --git a/packages/project-builder-server/src/actions/index.ts b/packages/project-builder-server/src/actions/index.ts index 1ff136f82..4c8b230fa 100644 --- a/packages/project-builder-server/src/actions/index.ts +++ b/packages/project-builder-server/src/actions/index.ts @@ -1,3 +1,4 @@ +export * from './definition/index.js'; export * from './diff/index.js'; export * from './generators/index.js'; export * from './registry.js'; diff --git a/packages/project-builder-server/src/actions/registry.ts b/packages/project-builder-server/src/actions/registry.ts index f38302e50..704c5a043 100644 --- a/packages/project-builder-server/src/actions/registry.ts +++ b/packages/project-builder-server/src/actions/registry.ts @@ -1,3 +1,15 @@ +import { + commitDraftAction, + discardDraftAction, + getEntityAction, + getEntitySchemaAction, + listEntitiesAction, + listEntityTypesAction, + showDraftAction, + stageCreateEntityAction, + stageDeleteEntityAction, + stageUpdateEntityAction, +} from './definition/index.js'; import { diffProjectAction } from './diff/diff-project.action.js'; import { createGeneratorAction } from './generators/index.js'; import { @@ -31,6 +43,16 @@ export const USER_SERVICE_ACTIONS = [ syncProjectAction, syncAllProjectsAction, syncFileAction, + listEntitiesAction, + listEntityTypesAction, + getEntityAction, + getEntitySchemaAction, + stageCreateEntityAction, + stageUpdateEntityAction, + stageDeleteEntityAction, + commitDraftAction, + discardDraftAction, + showDraftAction, ]; export const ALL_SERVICE_ACTIONS = [ diff --git a/packages/project-builder-server/src/actions/types.ts b/packages/project-builder-server/src/actions/types.ts index 4c443bcb2..d62c6d7c3 100644 --- a/packages/project-builder-server/src/actions/types.ts +++ b/packages/project-builder-server/src/actions/types.ts @@ -23,6 +23,8 @@ export interface ServiceActionContext { logger: Logger; /** The version of @baseplate-dev/project-builder-cli. */ cliVersion: string; + /** Session ID for draft management. */ + sessionId: string; } /** diff --git a/packages/project-builder-server/src/actions/utils/project-discovery.unit.test.ts b/packages/project-builder-server/src/actions/utils/project-discovery.unit.test.ts index c74ab9a7a..0953e85a6 100644 --- a/packages/project-builder-server/src/actions/utils/project-discovery.unit.test.ts +++ b/packages/project-builder-server/src/actions/utils/project-discovery.unit.test.ts @@ -1,4 +1,4 @@ -import { createTestProjectDefinition } from '@baseplate-dev/project-builder-lib'; +import { createTestProjectDefinition } from '@baseplate-dev/project-builder-lib/testing'; import { createTestLogger } from '@baseplate-dev/sync'; import { vol } from 'memfs'; import path from 'node:path'; diff --git a/packages/project-builder-server/src/dev-server/mcp/mcp-server.int.test.ts b/packages/project-builder-server/src/dev-server/mcp/mcp-server.int.test.ts index 00f706529..a46bce445 100644 --- a/packages/project-builder-server/src/dev-server/mcp/mcp-server.int.test.ts +++ b/packages/project-builder-server/src/dev-server/mcp/mcp-server.int.test.ts @@ -28,6 +28,7 @@ describe('MCP Server', () => { userConfig: {}, logger: createConsoleLogger('warn'), cliVersion: '1.0.0', + sessionId: 'default', }; // 2. Create server diff --git a/packages/project-builder-web/package.json b/packages/project-builder-web/package.json index f46b78440..f1519719b 100644 --- a/packages/project-builder-web/package.json +++ b/packages/project-builder-web/package.json @@ -78,7 +78,7 @@ "clsx": "2.1.1", "culori": "^4.0.1", "es-toolkit": "1.44.0", - "immer": "10.1.1", + "immer": "~11.1.4", "inflection": "3.0.0", "jsdom": "26.0.0", "loglevel": "1.9.1", diff --git a/packages/utils/src/objects/immutable-set.ts b/packages/utils/src/objects/immutable-set.ts new file mode 100644 index 000000000..7756ba80b --- /dev/null +++ b/packages/utils/src/objects/immutable-set.ts @@ -0,0 +1,55 @@ +import { isPlainObject } from 'es-toolkit'; + +/** + * Sets a value at a given path in an object or array immutably. + * + * Mimics lodash set but returns a new object/array and throws on invalid paths. + * Only operates on JSON-stringifyable data (objects and arrays). + * + * It navigates the path and recursively copies objects/arrays along the path. + * + * @param obj - The object or array to modify + * @param path - The path to set the value at + * @param value - The value to set + * @returns A new object or array with the value set + * @throws Error if the path is invalid (e.g., trying to set a property on a non-object) + */ +export function immutableSet( + obj: T, + path: (string | number)[], + value: unknown, +): T { + if (path.length === 0) { + return value as T; + } + + const [head, ...tail] = path; + + if (Array.isArray(obj)) { + if (typeof head !== 'number') { + throw new TypeError( + `Invalid path: expected number index for array, got "${head}"`, + ); + } + const newArr = [...obj] as unknown[]; + newArr[head] = + tail.length === 0 + ? value + : immutableSet(obj[head] as unknown, tail, value); + return newArr as T; + } + + if (isPlainObject(obj)) { + const newObj = { ...obj }; + const key = String(head); + (newObj as Record)[key] = + tail.length === 0 + ? value + : immutableSet((obj as Record)[key], tail, value); + return newObj as unknown as T; + } + + throw new Error( + `Invalid path: cannot set property "${head}" on non-object value`, + ); +} diff --git a/packages/utils/src/objects/immutable-set.unit.test.ts b/packages/utils/src/objects/immutable-set.unit.test.ts new file mode 100644 index 000000000..379f9ccc3 --- /dev/null +++ b/packages/utils/src/objects/immutable-set.unit.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; + +import { immutableSet } from './immutable-set.js'; + +describe('immutableSet', () => { + it('should set a value at a top-level property of an object', () => { + const obj = { a: 1, b: 2 }; + const result = immutableSet(obj, ['a'], 3); + expect(result).toEqual({ a: 3, b: 2 }); + expect(result).not.toBe(obj); + }); + + it('should set a value at a nested property of an object', () => { + const obj = { a: { b: 1 }, c: 2 }; + const result = immutableSet<{ a: { b: number }; c: number }>( + obj, + ['a', 'b'], + 3, + ); + expect(result).toEqual({ a: { b: 3 }, c: 2 }); + expect(result).not.toBe(obj); + expect(result.a).not.toBe(obj.a); + expect(result.c).toBe(obj.c); // Structural sharing + }); + + it('should set a value at an array index', () => { + const arr = [1, 2, 3]; + const result = immutableSet(arr, [1], 4); + expect(result).toEqual([1, 4, 3]); + expect(result).not.toBe(arr); + }); + + it('should set a value at a nested array index', () => { + const obj = { a: [1, { b: 2 }, 3] }; + const result = immutableSet<{ a: unknown[] }>(obj, ['a', 1, 'b'], 4); + expect(result).toEqual({ a: [1, { b: 4 }, 3] }); + expect(result.a[1]).not.toBe(obj.a[1]); + expect(result.a[0]).toBe(obj.a[0]); + }); + + it('should return the value if the path is empty', () => { + const obj = { a: 1 }; + const result = immutableSet(obj, [], { b: 2 }); + expect(result).toEqual({ b: 2 }); + }); + + it('should throw if the path is invalid (traversing a primitive)', () => { + const obj = { a: 1 }; + expect(() => immutableSet(obj, ['a', 'b'], 2)).toThrow( + 'Invalid path: cannot set property "b" on non-object value', + ); + }); + + it('should throw if the path is invalid (traversing null)', () => { + const obj = { a: null }; + expect(() => immutableSet(obj, ['a', 'b'], 2)).toThrow( + 'Invalid path: cannot set property "b" on non-object value', + ); + }); + + it('should throw if using a string key for an array', () => { + const arr = [1, 2, 3]; + expect(() => immutableSet(arr, ['a'], 4)).toThrow( + 'Invalid path: expected number index for array, got "a"', + ); + }); + + it('should support adding new properties to an object', () => { + const obj = { a: 1 }; + const result = immutableSet(obj, ['b'], 2); + expect(result).toEqual({ a: 1, b: 2 }); + }); + + it('should support adding new elements to an array by index', () => { + const arr = [1]; + const result = immutableSet(arr, [2], 3); + // [1, undefined, 3] + expect(result).toEqual([1, undefined, 3]); + }); +}); diff --git a/packages/utils/src/objects/index.ts b/packages/utils/src/objects/index.ts index 8363102b3..fd8bdc547 100644 --- a/packages/utils/src/objects/index.ts +++ b/packages/utils/src/objects/index.ts @@ -1,3 +1,4 @@ +export * from './immutable-set.js'; export * from './safe-merge.js'; export * from './sort-keys-recursive.js'; export * from './sort-object-keys.js'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c3e3dfaf7..4ca56aded 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -542,8 +542,8 @@ importers: specifier: ^14.0.2 version: 14.0.2 immer: - specifier: 10.1.1 - version: 10.1.1 + specifier: ~11.1.4 + version: 11.1.4 inflection: specifier: 3.0.0 version: 3.0.0 @@ -558,7 +558,7 @@ importers: version: 4.3.6 zustand: specifier: 5.0.3 - version: 5.0.3(@types/react@19.1.3)(immer@10.1.1)(react@19.1.0)(use-sync-external-store@1.6.0(react@19.1.0)) + version: 5.0.3(@types/react@19.1.3)(immer@11.1.4)(react@19.1.0)(use-sync-external-store@1.6.0(react@19.1.0)) devDependencies: '@baseplate-dev/tools': specifier: workspace:* @@ -653,6 +653,9 @@ importers: execa: specifier: 9.3.0 version: 9.3.0 + fast-json-patch: + specifier: ^3.1.1 + version: 3.1.1 fastify: specifier: 5.7.4 version: 5.7.4 @@ -669,8 +672,8 @@ importers: specifier: ^7.0.5 version: 7.0.5 immer: - specifier: 10.1.1 - version: 10.1.1 + specifier: ~11.1.4 + version: 11.1.4 inflection: specifier: 3.0.0 version: 3.0.0 @@ -698,6 +701,9 @@ importers: zod: specifier: 'catalog:' version: 4.3.6 + zod-to-ts: + specifier: ^2.0.0 + version: 2.0.0(typescript@5.9.3)(zod@4.3.6) devDependencies: '@baseplate-dev/tools': specifier: workspace:* @@ -830,8 +836,8 @@ importers: specifier: 1.44.0 version: 1.44.0 immer: - specifier: 10.1.1 - version: 10.1.1 + specifier: ~11.1.4 + version: 11.1.4 inflection: specifier: 3.0.0 version: 3.0.0 @@ -867,7 +873,7 @@ importers: version: 4.3.6 zustand: specifier: 5.0.3 - version: 5.0.3(@types/react@19.1.3)(immer@10.1.1)(react@19.1.0)(use-sync-external-store@1.6.0(react@19.1.0)) + version: 5.0.3(@types/react@19.1.3)(immer@11.1.4)(react@19.1.0)(use-sync-external-store@1.6.0(react@19.1.0)) devDependencies: '@baseplate-dev/project-builder-server': specifier: workspace:* @@ -1204,7 +1210,7 @@ importers: version: 4.3.6 zustand: specifier: 5.0.3 - version: 5.0.3(@types/react@19.1.3)(immer@10.1.1)(react@19.1.0)(use-sync-external-store@1.6.0(react@19.1.0)) + version: 5.0.3(@types/react@19.1.3)(immer@11.1.4)(react@19.1.0)(use-sync-external-store@1.6.0(react@19.1.0)) devDependencies: '@baseplate-dev/tools': specifier: workspace:* @@ -6252,8 +6258,8 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} - immer@10.1.1: - resolution: {integrity: sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==} + immer@11.1.4: + resolution: {integrity: sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==} import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} @@ -8624,6 +8630,12 @@ packages: peerDependencies: zod: ^3.25 || ^4 + zod-to-ts@2.0.0: + resolution: {integrity: sha512-aHsUgIl+CQutKAxtRNeZslLCLXoeuSq+j5HU7q3kvi/c2KIAo6q4YjT7/lwFfACxLB923ELHYMkHmlxiqFy4lw==} + peerDependencies: + typescript: ^5.0.0 + zod: ^3.25.0 || ^4.0.0 + zod-validation-error@4.0.2: resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} engines: {node: '>=18.0.0'} @@ -13467,7 +13479,7 @@ snapshots: ignore@7.0.5: {} - immer@10.1.1: {} + immer@11.1.4: {} import-fresh@3.3.1: dependencies: @@ -16115,6 +16127,11 @@ snapshots: dependencies: zod: 4.3.6 + zod-to-ts@2.0.0(typescript@5.9.3)(zod@4.3.6): + dependencies: + typescript: 5.9.3 + zod: 4.3.6 + zod-validation-error@4.0.2(zod@4.3.6): dependencies: zod: 4.3.6 @@ -16123,9 +16140,9 @@ snapshots: zod@4.3.6: {} - zustand@5.0.3(@types/react@19.1.3)(immer@10.1.1)(react@19.1.0)(use-sync-external-store@1.6.0(react@19.1.0)): + zustand@5.0.3(@types/react@19.1.3)(immer@11.1.4)(react@19.1.0)(use-sync-external-store@1.6.0(react@19.1.0)): optionalDependencies: '@types/react': 19.1.3 - immer: 10.1.1 + immer: 11.1.4 react: 19.1.0 use-sync-external-store: 1.6.0(react@19.1.0)