diff --git a/docs/api-integration.md b/docs/api-integration.md index 0fcebe1..030411b 100644 --- a/docs/api-integration.md +++ b/docs/api-integration.md @@ -92,6 +92,28 @@ type TopAgents = { serviceId: string; items: { agent: string; total: number }[] > (e.g. `"1e2"`), and whitespace-padded input. The accepted range is shown as a field > hint on both the create (`services/new`) and edit (`services/[serviceId]/edit`) forms. +### Optimistic service price mutations + +Price edits use the service-scoped optimistic ledger in +[`src/lib/serviceOptimisticStore.ts`](../src/lib/serviceOptimisticStore.ts). +The edited price is published to subscribed dashboard/detail consumers before the +PATCH resolves. Each service has an independent monotonic mutation revision and +network queue: same-service PATCH + reconciliation pairs execute in submit order, +so an older write cannot land after a newer one and leave server state behind the +newer UI. Queues are independent across services, so unrelated updates still run +concurrently. + +A successful PATCH is followed by a canonical +`GET /api/v1/services/{serviceId}`; that response reconciles the optimistic +entry. If the write fails, only that mutation revision is removed, revealing the +exact previous canonical value (or the next-newest pending edit). Write failures +surface the stable `SERVICE_WRITE_FAILED` code with a user-safe message, and +the edit form announces the rollback through an `aria-live="polite"` status +region. A write that succeeds but cannot be reconciled is distinguished as +`SERVICE_RECONCILE_FAILED`; the optimistic value is retained because the +server write already succeeded. + + ## Usage | Method & path | Type | Request body | Response shape | Source | diff --git a/package-lock.json b/package-lock.json index 17617f3..b523aa7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -796,107 +796,105 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.23.5", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", - "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^3.0.5", + "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", - "minimatch": "^10.2.4" + "minimatch": "^3.1.5" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@eslint/config-array/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, "engines": { - "node": "18 || 20 || >=22" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "balanced-match": "^4.0.2" + "@types/json-schema": "^7.0.15" }, "engines": { - "node": "20 || >=22" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "10.2.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", - "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "node_modules/@eslint/eslintrc": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz", + "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==", "dev": true, - "license": "BlueOak-1.0.0", + "license": "MIT", "dependencies": { - "brace-expansion": "^5.0.8" + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.3.0", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" }, "engines": { - "node": "18 || 20 || >=22" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://opencollective.com/eslint" } }, - "node_modules/@eslint/config-helpers": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", - "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "node_modules/@eslint/js": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^1.2.1" - }, + "license": "MIT", "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/core": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", - "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "funding": { + "url": "https://eslint.org/donate" } }, "node_modules/@eslint/object-schema": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", - "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/plugin-kit": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", - "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^1.2.1", + "@eslint/core": "^0.17.0", "levn": "^0.4.1" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@humanfs/core": { @@ -2257,25 +2255,22 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.3.tgz", - "integrity": "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", "dev": true, "license": "MIT", "optional": true, "dependencies": { "@tybys/wasm-util": "^0.10.3" }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=23.5.0" - }, "funding": { "type": "github", "url": "https://github.com/sponsors/Brooooooklyn" }, "peerDependencies": { - "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", - "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" } }, "node_modules/@next/env": { @@ -3177,9 +3172,9 @@ } }, "node_modules/@testing-library/jest-dom": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz", - "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==", + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", "dev": true, "license": "MIT", "dependencies": { @@ -3191,18 +3186,9 @@ "redent": "^3.0.0" }, "engines": { - "node": ">=22", + "node": ">=14", "npm": ">=6", "yarn": ">=1" - }, - "peerDependencies": { - "@testing-library/dom": ">=10 <11", - "vitest": ">= 0.32" - }, - "peerDependenciesMeta": { - "vitest": { - "optional": true - } } }, "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { @@ -3345,13 +3331,6 @@ "@babel/types": "^7.28.2" } }, - "node_modules/@types/esrecurse": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", - "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -3579,6 +3558,19 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@unrs/resolver-binding-android-arm-eabi": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz", @@ -3685,9 +3677,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3702,9 +3691,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3719,9 +3705,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3736,9 +3719,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3753,9 +3733,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3770,9 +3747,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3787,9 +3761,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3804,9 +3775,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3821,9 +3789,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3838,9 +3803,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4059,6 +4021,13 @@ "dev": true, "license": "MIT" }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, "node_modules/aria-query": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", @@ -4263,9 +4232,9 @@ } }, "node_modules/axe-core": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz", - "integrity": "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==", + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.1.tgz", + "integrity": "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==", "dev": true, "license": "MPL-2.0", "engines": { @@ -5328,33 +5297,33 @@ } }, "node_modules/eslint": { - "version": "10.8.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.1.tgz", - "integrity": "sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==", + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", "dev": true, "license": "MIT", - "workspaces": [ - "packages/*" - ], "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.2", - "@eslint/config-array": "^0.23.5", - "@eslint/config-helpers": "^0.7.0", - "@eslint/core": "^1.2.1", - "@eslint/plugin-kit": "^0.7.2", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", + "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", + "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^9.1.2", - "eslint-visitor-keys": "^5.0.1", - "espree": "^11.2.0", - "esquery": "^1.7.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", @@ -5364,7 +5333,8 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "minimatch": "^10.2.5", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -5372,7 +5342,7 @@ "eslint": "bin/eslint.js" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://eslint.org/donate" @@ -5413,16 +5383,6 @@ } } }, - "node_modules/eslint-config-next/node_modules/aria-query": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", - "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/eslint-config-next/node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -5446,317 +5406,62 @@ "node": "20 || >=22" } }, - "node_modules/eslint-config-next/node_modules/eslint-import-resolver-typescript": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz", - "integrity": "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==", + "node_modules/eslint-config-next/node_modules/globals": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz", + "integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==", "dev": true, - "license": "ISC", - "dependencies": { - "@nolyfill/is-core-module": "1.0.39", - "debug": "^4.4.0", - "get-tsconfig": "^4.10.0", - "is-bun-module": "^2.0.0", - "stable-hash": "^0.0.5", - "tinyglobby": "^0.2.13", - "unrs-resolver": "^1.6.2" - }, + "license": "MIT", "engines": { - "node": "^14.18.0 || >=16.0.0" + "node": ">=18" }, "funding": { - "url": "https://opencollective.com/eslint-import-resolver-typescript" - }, - "peerDependencies": { - "eslint": "*", - "eslint-plugin-import": "*", - "eslint-plugin-import-x": "*" - }, - "peerDependenciesMeta": { - "eslint-plugin-import": { - "optional": true - }, - "eslint-plugin-import-x": { - "optional": true - } + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/eslint-config-next/node_modules/eslint-plugin-import": { - "version": "2.32.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", - "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "node_modules/eslint-config-next/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", "dev": true, "license": "MIT", - "dependencies": { - "@rtsao/scc": "^1.1.0", - "array-includes": "^3.1.9", - "array.prototype.findlastindex": "^1.2.6", - "array.prototype.flat": "^1.3.3", - "array.prototype.flatmap": "^1.3.3", - "debug": "^3.2.7", - "doctrine": "^2.1.0", - "eslint-import-resolver-node": "^0.3.9", - "eslint-module-utils": "^2.12.1", - "hasown": "^2.0.2", - "is-core-module": "^2.16.1", - "is-glob": "^4.0.3", - "minimatch": "^3.1.2", - "object.fromentries": "^2.0.8", - "object.groupby": "^1.0.3", - "object.values": "^1.2.1", - "semver": "^6.3.1", - "string.prototype.trimend": "^1.0.9", - "tsconfig-paths": "^3.15.0" - }, "engines": { - "node": ">=4" - }, - "peerDependencies": { - "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" - } - }, - "node_modules/eslint-config-next/node_modules/eslint-plugin-import/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/eslint-config-next/node_modules/eslint-plugin-import/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/eslint-config-next/node_modules/eslint-plugin-import/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" + "node": ">= 4" } }, - "node_modules/eslint-config-next/node_modules/eslint-plugin-import/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "node_modules/eslint-config-next/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^1.1.7" + "brace-expansion": "^5.0.5" }, "engines": { - "node": "*" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/eslint-config-next/node_modules/eslint-plugin-import/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "node_modules/eslint-config-next/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, - "node_modules/eslint-config-next/node_modules/eslint-plugin-jsx-a11y": { - "version": "6.10.2", - "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", - "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "aria-query": "^5.3.2", - "array-includes": "^3.1.8", - "array.prototype.flatmap": "^1.3.2", - "ast-types-flow": "^0.0.8", - "axe-core": "^4.10.0", - "axobject-query": "^4.1.0", - "damerau-levenshtein": "^1.0.8", - "emoji-regex": "^9.2.2", - "hasown": "^2.0.2", - "jsx-ast-utils": "^3.3.5", - "language-tags": "^1.0.9", - "minimatch": "^3.1.2", - "object.fromentries": "^2.0.8", - "safe-regex-test": "^1.0.3", - "string.prototype.includes": "^2.0.1" - }, - "engines": { - "node": ">=4.0" - }, - "peerDependencies": { - "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" - } - }, - "node_modules/eslint-config-next/node_modules/eslint-plugin-jsx-a11y/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/eslint-config-next/node_modules/eslint-plugin-jsx-a11y/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/eslint-config-next/node_modules/eslint-plugin-jsx-a11y/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/eslint-config-next/node_modules/eslint-plugin-react": { - "version": "7.37.5", - "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", - "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-includes": "^3.1.8", - "array.prototype.findlast": "^1.2.5", - "array.prototype.flatmap": "^1.3.3", - "array.prototype.tosorted": "^1.1.4", - "doctrine": "^2.1.0", - "es-iterator-helpers": "^1.2.1", - "estraverse": "^5.3.0", - "hasown": "^2.0.2", - "jsx-ast-utils": "^2.4.1 || ^3.0.0", - "minimatch": "^3.1.2", - "object.entries": "^1.1.9", - "object.fromentries": "^2.0.8", - "object.values": "^1.2.1", - "prop-types": "^15.8.1", - "resolve": "^2.0.0-next.5", - "semver": "^6.3.1", - "string.prototype.matchall": "^4.0.12", - "string.prototype.repeat": "^1.0.0" - }, - "engines": { - "node": ">=4" - }, - "peerDependencies": { - "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" - } - }, - "node_modules/eslint-config-next/node_modules/eslint-plugin-react/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/eslint-config-next/node_modules/eslint-plugin-react/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/eslint-config-next/node_modules/eslint-plugin-react/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/eslint-config-next/node_modules/eslint-plugin-react/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/eslint-config-next/node_modules/globals": { - "version": "16.4.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz", - "integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint-config-next/node_modules/ignore": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", - "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/eslint-config-next/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/eslint-config-next/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/eslint-config-next/node_modules/typescript-eslint": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.65.0.tgz", - "integrity": "sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==", + "node_modules/eslint-config-next/node_modules/typescript-eslint": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.65.0.tgz", + "integrity": "sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==", "dev": true, "license": "MIT", "dependencies": { @@ -5969,6 +5674,41 @@ "ms": "^2.1.1" } }, + "node_modules/eslint-import-resolver-typescript": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz", + "integrity": "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@nolyfill/is-core-module": "1.0.39", + "debug": "^4.4.0", + "get-tsconfig": "^4.10.0", + "is-bun-module": "^2.0.0", + "stable-hash": "^0.0.5", + "tinyglobby": "^0.2.13", + "unrs-resolver": "^1.6.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, "node_modules/eslint-module-utils": { "version": "2.14.0", "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.14.0.tgz", @@ -5997,6 +5737,123 @@ "ms": "^2.1.1" } }, + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-jsx-a11y": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", + "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "aria-query": "^5.3.2", + "array-includes": "^3.1.8", + "array.prototype.flatmap": "^1.3.2", + "ast-types-flow": "^0.0.8", + "axe-core": "^4.10.0", + "axobject-query": "^4.1.0", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "hasown": "^2.0.2", + "jsx-ast-utils": "^3.3.5", + "language-tags": "^1.0.9", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "safe-regex-test": "^1.0.3", + "string.prototype.includes": "^2.0.1" + }, + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-jsx-a11y/node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, "node_modules/eslint-plugin-react-hooks": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", @@ -6018,89 +5875,48 @@ } }, "node_modules/eslint-scope": { - "version": "9.1.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", - "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@types/esrecurse": "^4.3.1", - "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/eslint/node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/eslint/node_modules/minimatch": { - "version": "10.2.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", - "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.8" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/espree": { - "version": "11.2.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", - "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.16.0", + "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^5.0.1" + "eslint-visitor-keys": "^4.2.1" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" @@ -6543,9 +6359,9 @@ } }, "node_modules/get-tsconfig": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.3.tgz", - "integrity": "sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==", + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", "dev": true, "license": "MIT", "dependencies": { @@ -6590,6 +6406,19 @@ "node": ">=10.13.0" } }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/globalthis": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", @@ -6841,6 +6670,23 @@ "node": ">= 4" } }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/import-local": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", @@ -8678,6 +8524,29 @@ "dev": true, "license": "MIT" }, + "node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/jsdom": { "version": "26.1.0", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", @@ -9131,6 +9000,13 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -9706,6 +9582,19 @@ "node": ">=6" } }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -10189,6 +10078,16 @@ "node": ">=8" } }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", diff --git a/src/app/services/[serviceId]/edit/page.test.tsx b/src/app/services/[serviceId]/edit/page.test.tsx index eacc6e6..04c7410 100644 --- a/src/app/services/[serviceId]/edit/page.test.tsx +++ b/src/app/services/[serviceId]/edit/page.test.tsx @@ -1,7 +1,11 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import EditServicePage from "./page"; import { apiGet, apiPatch } from "@/lib/apiClient"; import { ToastProvider } from "@/components/ToastProvider"; +import { + __resetServiceOptimisticStoreForTests, + getOptimisticService, +} from "@/lib/serviceOptimisticStore"; jest.mock("@/lib/apiClient", () => ({ apiGet: jest.fn(), @@ -32,6 +36,16 @@ jest.mock("next/navigation", () => ({ const mockApiGet = apiGet as jest.MockedFunction; const mockApiPatch = apiPatch as jest.MockedFunction; +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + function renderPage(serviceId: string) { const params = Promise.resolve({ serviceId }) as Promise<{ serviceId: string; @@ -48,6 +62,7 @@ describe("EditServicePage", () => { mockApiGet.mockReset(); mockApiPatch.mockReset(); mockPush.mockReset(); + __resetServiceOptimisticStoreForTests(); }); afterEach(() => { @@ -215,10 +230,15 @@ describe("EditServicePage", () => { // ── Submit: success ─────────────────────────────────────────── it("calls PATCH, shows success toast, clears dirty, and redirects on valid submit", async () => { - mockApiGet.mockResolvedValueOnce({ - serviceId: "svc-1", - priceStroops: 1000, - } as never); + mockApiGet + .mockResolvedValueOnce({ + serviceId: "svc-1", + priceStroops: 1000, + } as never) + .mockResolvedValueOnce({ + serviceId: "svc-1", + priceStroops: 2100, + } as never); mockApiPatch.mockResolvedValueOnce({} as never); renderPage("svc-1"); const input = await screen.findByLabelText("Price (stroops / request)"); @@ -247,19 +267,82 @@ describe("EditServicePage", () => { // ── Submit: failure ─────────────────────────────────────────── - it("surfaces PATCH failures as a role=alert", async () => { + it("serializes rapid edits and rolls a failed newer edit back to the first canonical result", async () => { + const firstWrite = deferred(); + + mockApiGet + .mockResolvedValueOnce({ + serviceId: "svc-1", + priceStroops: 1000, + } as never) + .mockResolvedValueOnce({ + serviceId: "svc-1", + priceStroops: 2250, + } as never); + mockApiPatch + .mockReturnValueOnce(firstWrite.promise as never) + .mockRejectedValueOnce(new Error("newer edit failed")); + + renderPage("svc-1"); + const input = await screen.findByLabelText("Price (stroops / request)"); + + fireEvent.change(input, { target: { value: "2000" } }); + fireEvent.submit(screen.getByRole("button")); + + await waitFor(() => { + expect(mockApiPatch).toHaveBeenCalledTimes(1); + expect(getOptimisticService("svc-1")?.priceStroops).toBe(2000); + }); + + fireEvent.change(input, { target: { value: "3000" } }); + fireEvent.submit(screen.getByRole("button")); + + await waitFor(() => { + expect(getOptimisticService("svc-1")?.priceStroops).toBe(3000); + }); + expect(mockApiPatch).toHaveBeenCalledTimes(1); + expect(input).toHaveValue("3000"); + + await act(async () => { + firstWrite.resolve(); + await firstWrite.promise; + }); + + await waitFor(() => { + expect(mockApiPatch).toHaveBeenCalledTimes(2); + expect(getOptimisticService("svc-1")?.priceStroops).toBe(2250); + expect(input).toHaveValue("2250"); + }); + + expect(mockPush).not.toHaveBeenCalled(); + }); + + it("rolls back failed PATCH state and announces a safe message politely", async () => { mockApiGet.mockResolvedValueOnce({ serviceId: "svc-1", priceStroops: 1000, } as never); - mockApiPatch.mockRejectedValueOnce(new Error("Price too high")); + mockApiPatch.mockRejectedValueOnce( + new Error("database table internals must stay private"), + ); renderPage("svc-1"); const input = await screen.findByLabelText("Price (stroops / request)"); fireEvent.change(input, { target: { value: "2000" } }); fireEvent.submit(screen.getByRole("button", { name: "Save" })); - const alert = await screen.findByRole("alert"); - expect(alert).toHaveTextContent("Price too high"); + await waitFor(() => { + expect(getOptimisticService("svc-1")?.priceStroops).toBe(1000); + }); + + const liveRegion = await screen.findByRole("status"); + expect(liveRegion).toHaveAttribute("aria-live", "polite"); + expect(liveRegion).toHaveTextContent( + "Could not update the service. Your previous price was restored.", + ); + expect(input).toHaveValue("1000"); + expect( + screen.queryByText("database table internals must stay private"), + ).not.toBeInTheDocument(); }); }); diff --git a/src/app/services/[serviceId]/edit/page.tsx b/src/app/services/[serviceId]/edit/page.tsx index 5765018..971fe6f 100644 --- a/src/app/services/[serviceId]/edit/page.tsx +++ b/src/app/services/[serviceId]/edit/page.tsx @@ -1,17 +1,27 @@ "use client"; -import { useEffect, useState, use } from "react"; +import { useEffect, useRef, useState, use } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; import { apiGet, apiPatch } from "@/lib/apiClient"; -import { PageShell } from "@/components/PageShell"; import { TextField } from "@/components/TextField"; import { Spinner } from "@/components/Spinner"; import { useToast } from "@/components/ToastProvider"; import { parseNonNegativeInt } from "@/lib/validateNumber"; +import { + getOptimisticService, + hydrateServiceSnapshot, + runOptimisticServiceMutation, + ServiceMutationError, +} from "@/lib/serviceOptimisticStore"; type Service = { serviceId: string; priceStroops: number }; +const UPDATE_FAILURE_MESSAGE = + "Could not update the service. Your previous price was restored."; +const RECONCILE_FAILURE_MESSAGE = + "The price was saved, but the latest server value could not be confirmed. Refresh before editing again."; + export default function EditServicePage({ params, }: { @@ -22,31 +32,38 @@ export default function EditServicePage({ const toast = useToast(); const [price, setPrice] = useState(""); const [error, setError] = useState(null); - const [saving, setSaving] = useState(false); + const [announcement, setAnnouncement] = useState(""); + const [pendingSaves, setPendingSaves] = useState(0); const [prefillLoading, setPrefillLoading] = useState(true); const [prefillError, setPrefillError] = useState(null); const [originalPrice, setOriginalPrice] = useState(null); + const [serverService, setServerService] = useState(null); + const submitGenerationRef = useRef(0); + const dirtyRef = useRef(false); + const pendingSavesRef = useRef(0); - /** - * Whether the price field differs from the originally-fetched value. - * Derived during render (not stored in state) so it stays in sync without a - * setState-in-effect. Used by the unsaved-changes guard. - */ const dirty = originalPrice !== null && price !== originalPrice; + const saving = pendingSaves > 0; useEffect(() => { const load = async () => { setPrefillLoading(true); setPrefillError(null); try { - const s = await apiGet( - `/api/v1/services/${encodeURIComponent(serviceId)}`, + const service = await apiGet( + "/api/v1/services/" + encodeURIComponent(serviceId), ); - const prefilled = String(s.priceStroops); + const prefilled = String(service.priceStroops); + setServerService(service); + hydrateServiceSnapshot(service); setPrice(prefilled); setOriginalPrice(prefilled); - } catch (e) { - setPrefillError((e as Error).message); + } catch (loadError) { + setPrefillError( + loadError instanceof Error + ? loadError.message + : "Failed to load service details.", + ); } finally { setPrefillLoading(false); } @@ -54,49 +71,118 @@ export default function EditServicePage({ void load(); }, [serviceId]); - /* - * beforeunload guard: registers a `beforeunload` event on the window - * whenever the form is dirty so the browser prompts the operator before - * closing or navigating away. - */ useEffect(() => { + dirtyRef.current = dirty; if (!dirty) return; - const handler = (e: BeforeUnloadEvent) => { - e.preventDefault(); + const handler = (event: BeforeUnloadEvent) => { + if (dirtyRef.current) { + event.preventDefault(); + } }; window.addEventListener("beforeunload", handler); return () => window.removeEventListener("beforeunload", handler); }, [dirty]); - const handleBack = (e: React.MouseEvent) => { - if (dirty && !window.confirm("You have unsaved changes. Are you sure you want to leave?")) { - e.preventDefault(); + const handleBack = (event: React.MouseEvent) => { + if ( + dirty && + !window.confirm("You have unsaved changes. Are you sure you want to leave?") + ) { + event.preventDefault(); } }; - const onSubmit = async (e: React.FormEvent) => { - e.preventDefault(); + const onSubmit = async (event: React.FormEvent) => { + event.preventDefault(); setError(null); + setAnnouncement(""); + const parsed = parseNonNegativeInt(price); if (!parsed.ok) { setError(parsed.message); return; } - setSaving(true); + const current = + getOptimisticService(serviceId) ?? + serverService ?? { + serviceId, + priceStroops: + originalPrice === null ? parsed.value : Number(originalPrice), + }; + const optimistic: Service = { + ...current, + serviceId, + priceStroops: parsed.value, + }; + const submission = ++submitGenerationRef.current; + + pendingSavesRef.current += 1; + setPendingSaves(pendingSavesRef.current); + try { - await apiPatch( - `/api/v1/services/${encodeURIComponent(serviceId)}/price`, - { priceStroops: parsed.value } - ); - setOriginalPrice(price); + const canonical = await runOptimisticServiceMutation({ + serviceId, + current, + optimistic, + mutate: () => + apiPatch( + "/api/v1/services/" + encodeURIComponent(serviceId) + "/price", + { priceStroops: parsed.value }, + ), + reconcile: () => + apiGet("/api/v1/services/" + encodeURIComponent(serviceId)), + failureMessage: UPDATE_FAILURE_MESSAGE, + reconcileMessage: RECONCILE_FAILURE_MESSAGE, + }); + + if (submission !== submitGenerationRef.current) { + return; + } + + dirtyRef.current = false; + const canonicalPrice = String(canonical.priceStroops); + setServerService(canonical); + setPrice(canonicalPrice); + setOriginalPrice(canonicalPrice); toast.push("Price updated.", "info"); - router.push(`/services/${encodeURIComponent(serviceId)}`); - } catch (err) { - setError((err as Error).message); + router.push("/services/" + encodeURIComponent(serviceId)); + } catch (mutationError) { + if (submission !== submitGenerationRef.current) { + return; + } + + const message = + mutationError instanceof ServiceMutationError + ? mutationError.message + : UPDATE_FAILURE_MESSAGE; + const rolledBack = getOptimisticService(serviceId); + + if (rolledBack) { + dirtyRef.current = false; + const rolledBackPrice = String(rolledBack.priceStroops); + setServerService(rolledBack); + setPrice(rolledBackPrice); + setOriginalPrice(rolledBackPrice); + } + + setError(message); + setAnnouncement(message); } finally { - setSaving(false); + pendingSavesRef.current = Math.max(0, pendingSavesRef.current - 1); + setPendingSaves(pendingSavesRef.current); + + if (pendingSavesRef.current === 0) { + const settled = getOptimisticService(serviceId); + if (settled) { + dirtyRef.current = false; + const settledPrice = String(settled.priceStroops); + setServerService(settled); + setPrice(settledPrice); + setOriginalPrice(settledPrice); + } + } } }; @@ -107,7 +193,7 @@ export default function EditServicePage({ className="mx-auto flex min-h-[60vh] max-w-xl flex-col gap-6 p-8 focus:outline-none" > @@ -126,23 +212,36 @@ export default function EditServicePage({ )} {!prefillLoading && !prefillError && ( -
+ setPrice(e.target.value)} + onChange={(event) => setPrice(event.target.value)} error={error} description="Accepted range: 0 – 9,007,199,254,740,991 stroops" /> + {announcement && ( +

+ {announcement} +

+ )} )} diff --git a/src/app/services/[serviceId]/page.test.tsx b/src/app/services/[serviceId]/page.test.tsx index 3adf30e..9aa8f82 100644 --- a/src/app/services/[serviceId]/page.test.tsx +++ b/src/app/services/[serviceId]/page.test.tsx @@ -1,6 +1,7 @@ import { render, screen, fireEvent } from "@testing-library/react"; import ServiceDetailPage from "./page"; import { apiGet } from "@/lib/apiClient"; +import { __resetServiceOptimisticStoreForTests } from "@/lib/serviceOptimisticStore"; jest.mock("@/lib/apiClient", () => ({ apiGet: jest.fn(), @@ -40,6 +41,7 @@ describe("ServiceDetailPage", () => { beforeEach(() => { mockApiGet.mockReset(); + __resetServiceOptimisticStoreForTests(); // Mock navigator.clipboard Object.defineProperty(global.navigator, "clipboard", { value: { diff --git a/src/app/services/[serviceId]/page.tsx b/src/app/services/[serviceId]/page.tsx index 0c0de1f..5bbc299 100644 --- a/src/app/services/[serviceId]/page.tsx +++ b/src/app/services/[serviceId]/page.tsx @@ -10,6 +10,7 @@ import { CopyButton } from "@/components/CopyButton"; import { KeyValueGrid } from "@/components/KeyValueGrid"; import { PageShell } from "@/components/PageShell"; import { formatStroops } from "@/lib/format"; +import { useOptimisticService } from "@/lib/serviceOptimisticStore"; type Service = { serviceId: string; priceStroops: number }; type Rollup = { serviceId: string; total: number; agents: number }; @@ -24,6 +25,8 @@ export default function ServiceDetailPage({ const [rollup, setRollup] = useState(null); const [error, setError] = useState(null); + const visibleService = useOptimisticService(serviceId, service); + useEffect(() => { let cancelled = false; apiGet(`/api/v1/services/${encodeURIComponent(serviceId)}`) @@ -46,7 +49,7 @@ export default function ServiceDetailPage({ {serviceId} {error && } - {service && ( + {visibleService && (
({ apiGet: jest.fn(), @@ -251,6 +255,7 @@ describe("sortServices", () => { describe("ServicesPage", () => { beforeEach(() => { apiGetMock.mockReset(); + __resetServiceOptimisticStoreForTests(); window.history.replaceState( null, "", @@ -258,6 +263,55 @@ describe("ServicesPage", () => { ); }); + it("projects an optimistic price into the dashboard and rolls back only that service on failure", async () => { + apiGetMock.mockResolvedValueOnce({ + services: [ + service("svc-a", 100), + service("svc-b", 500), + ], + page: 1, + pageCount: 1, + } as never); + + renderServicesPage(); + + expect(await screen.findByText("100 stroops / request")).toBeInTheDocument(); + expect(screen.getByText("500 stroops / request")).toBeInTheDocument(); + + let rejectWrite!: (reason?: unknown) => void; + const write = new Promise((_resolve, reject) => { + rejectWrite = reject; + }); + let operation!: Promise; + + act(() => { + operation = runOptimisticServiceMutation({ + serviceId: "svc-a", + current: service("svc-a", 100), + optimistic: service("svc-a", 200), + mutate: () => write, + reconcile: async () => service("svc-a", 200), + }); + }); + + await waitFor(() => { + expect(screen.getByText("200 stroops / request")).toBeInTheDocument(); + }); + expect(screen.getByText("500 stroops / request")).toBeInTheDocument(); + + await act(async () => { + rejectWrite(new Error("private backend details")); + await expect(operation).rejects.toMatchObject({ + code: "SERVICE_WRITE_FAILED", + }); + }); + + await waitFor(() => { + expect(screen.getByText("100 stroops / request")).toBeInTheDocument(); + }); + expect(screen.getByText("500 stroops / request")).toBeInTheDocument(); + }); + it("renders a spinner while the first page is loading", () => { apiGetMock.mockReturnValueOnce(new Promise(() => undefined) as never); diff --git a/src/app/services/page.tsx b/src/app/services/page.tsx index 75dd6ed..3921db2 100644 --- a/src/app/services/page.tsx +++ b/src/app/services/page.tsx @@ -11,6 +11,7 @@ import { Spinner } from "@/components/Spinner"; import { truncateMiddle } from "@/lib/format"; import { useToast } from "@/components/ToastProvider"; import { useClipboard } from "@/lib/useClipboard"; +import { useOptimisticServices } from "@/lib/serviceOptimisticStore"; type Service = { serviceId: string; priceStroops: number; createdAt?: number | string | null }; type ServicesResponse = { @@ -188,7 +189,8 @@ export default function ServicesPage() { getInitialSort ); - const sortedServices = useSorted(services, sortKey, sortDir); + const visibleServices = useOptimisticServices(services); + const sortedServices = useSorted(visibleServices, sortKey, sortDir); const handleSort = (key: SortKey) => { const next: { key: SortKey; dir: SortDir } = diff --git a/src/lib/__tests__/serviceOptimisticStore.test.ts b/src/lib/__tests__/serviceOptimisticStore.test.ts new file mode 100644 index 0000000..2884bd1 --- /dev/null +++ b/src/lib/__tests__/serviceOptimisticStore.test.ts @@ -0,0 +1,159 @@ +import { + __resetServiceOptimisticStoreForTests, + getOptimisticService, + hydrateServiceSnapshot, + runOptimisticServiceMutation, + ServiceMutationError, +} from "../serviceOptimisticStore"; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +function service(serviceId: string, priceStroops: number) { + return { serviceId, priceStroops }; +} + +describe("serviceOptimisticStore", () => { + beforeEach(() => { + __resetServiceOptimisticStoreForTests(); + }); + + it("shows the optimistic value immediately and replaces it with canonical server state", async () => { + hydrateServiceSnapshot(service("svc-a", 100)); + const write = deferred(); + const reconcile = deferred>(); + + const operation = runOptimisticServiceMutation({ + serviceId: "svc-a", + current: service("svc-a", 100), + optimistic: service("svc-a", 200), + mutate: () => write.promise, + reconcile: () => reconcile.promise, + }); + + expect(getOptimisticService("svc-a")).toEqual(service("svc-a", 200)); + + write.resolve(); + reconcile.resolve(service("svc-a", 225)); + + await expect(operation).resolves.toEqual(service("svc-a", 225)); + expect(getOptimisticService("svc-a")).toEqual(service("svc-a", 225)); + }); + + it("rolls back exactly and returns a typed stable error when the write fails", async () => { + hydrateServiceSnapshot(service("svc-a", 100)); + const write = deferred(); + + const operation = runOptimisticServiceMutation({ + serviceId: "svc-a", + current: service("svc-a", 100), + optimistic: service("svc-a", 200), + mutate: () => write.promise, + reconcile: async () => service("svc-a", 200), + failureMessage: + "Could not update the service. Your previous price was restored.", + }); + + expect(getOptimisticService("svc-a")).toEqual(service("svc-a", 200)); + + write.reject(new Error("database internals that must not escape")); + + await expect(operation).rejects.toMatchObject({ + name: "ServiceMutationError", + code: "SERVICE_WRITE_FAILED", + serviceId: "svc-a", + message: "Could not update the service. Your previous price was restored.", + }); + expect(getOptimisticService("svc-a")).toEqual(service("svc-a", 100)); + }); + + it("keeps the newest optimistic value visible while serializing same-service writes", async () => { + hydrateServiceSnapshot(service("svc-a", 100)); + + const firstWrite = deferred(); + const firstReconcile = deferred>(); + const secondWrite = deferred(); + const secondReconcile = deferred>(); + const firstMutate = jest.fn(() => firstWrite.promise); + const secondMutate = jest.fn(() => secondWrite.promise); + + const first = runOptimisticServiceMutation({ + serviceId: "svc-a", + current: service("svc-a", 100), + optimistic: service("svc-a", 200), + mutate: firstMutate, + reconcile: () => firstReconcile.promise, + }); + const second = runOptimisticServiceMutation({ + serviceId: "svc-a", + current: service("svc-a", 200), + optimistic: service("svc-a", 300), + mutate: secondMutate, + reconcile: () => secondReconcile.promise, + }); + + expect(getOptimisticService("svc-a")).toEqual(service("svc-a", 300)); + await Promise.resolve(); + expect(firstMutate).toHaveBeenCalledTimes(1); + expect(secondMutate).not.toHaveBeenCalled(); + + firstWrite.resolve(); + firstReconcile.resolve(service("svc-a", 225)); + await first; + + expect(getOptimisticService("svc-a")).toEqual(service("svc-a", 300)); + + secondWrite.resolve(); + secondReconcile.resolve(service("svc-a", 325)); + await second; + + expect(secondMutate).toHaveBeenCalledTimes(1); + expect(getOptimisticService("svc-a")).toEqual(service("svc-a", 325)); + }); + + it("keeps unrelated service updates independent while one mutation rolls back", async () => { + hydrateServiceSnapshot(service("svc-a", 100)); + hydrateServiceSnapshot(service("svc-b", 500)); + + const firstWrite = deferred(); + const secondWrite = deferred(); + const secondReconcile = deferred>(); + + const first = runOptimisticServiceMutation({ + serviceId: "svc-a", + current: service("svc-a", 100), + optimistic: service("svc-a", 200), + mutate: () => firstWrite.promise, + reconcile: async () => service("svc-a", 200), + }); + const second = runOptimisticServiceMutation({ + serviceId: "svc-b", + current: service("svc-b", 500), + optimistic: service("svc-b", 600), + mutate: () => secondWrite.promise, + reconcile: () => secondReconcile.promise, + }); + + expect(getOptimisticService("svc-a")).toEqual(service("svc-a", 200)); + expect(getOptimisticService("svc-b")).toEqual(service("svc-b", 600)); + + firstWrite.reject(new Error("svc-a failed")); + await expect(first).rejects.toBeInstanceOf(ServiceMutationError); + + expect(getOptimisticService("svc-a")).toEqual(service("svc-a", 100)); + expect(getOptimisticService("svc-b")).toEqual(service("svc-b", 600)); + + secondWrite.resolve(); + secondReconcile.resolve(service("svc-b", 650)); + await second; + + expect(getOptimisticService("svc-b")).toEqual(service("svc-b", 650)); + }); +}); diff --git a/src/lib/serviceOptimisticStore.ts b/src/lib/serviceOptimisticStore.ts new file mode 100644 index 0000000..4ac68c8 --- /dev/null +++ b/src/lib/serviceOptimisticStore.ts @@ -0,0 +1,318 @@ +"use client"; + +import { useEffect, useSyncExternalStore } from "react"; + +export type ServiceRecord = { + serviceId: string; + priceStroops: number; + createdAt?: number | string | null; +}; + +export type ServiceMutationErrorCode = + | "SERVICE_WRITE_FAILED" + | "SERVICE_RECONCILE_FAILED"; + +export class ServiceMutationError extends Error { + readonly code: ServiceMutationErrorCode; + readonly serviceId: string; + + constructor( + code: ServiceMutationErrorCode, + serviceId: string, + message: string, + ) { + super(message); + this.name = "ServiceMutationError"; + this.code = code; + this.serviceId = serviceId; + } +} + +type ServiceEntry = { + base: ServiceRecord | null; + baseRevision: number; + pending: Map; +}; + +type OptimisticServiceMutationOptions = { + serviceId: string; + current?: ServiceRecord | null; + optimistic: ServiceRecord; + mutate: () => Promise; + reconcile: () => Promise; + failureMessage?: string; + reconcileMessage?: string; +}; + +const entries = new Map(); +const listeners = new Set<() => void>(); +const mutationTails = new Map>(); +let storeVersion = 0; +let nextMutationRevision = 0; + +const DEFAULT_FAILURE_MESSAGE = + "Could not save the service change. The previous value was restored."; +const DEFAULT_RECONCILE_MESSAGE = + "The service was saved, but its latest server value could not be confirmed. Refresh before editing again."; + +function cloneService(service: ServiceRecord): ServiceRecord { + return { ...service }; +} + +function sameService( + first: ServiceRecord | null, + second: ServiceRecord, +): boolean { + return ( + first?.serviceId === second.serviceId && + first.priceStroops === second.priceStroops && + first.createdAt === second.createdAt + ); +} + +function getOrCreateEntry(serviceId: string): ServiceEntry { + let entry = entries.get(serviceId); + if (!entry) { + entry = { + base: null, + baseRevision: 0, + pending: new Map(), + }; + entries.set(serviceId, entry); + } + return entry; +} + +function emitChange() { + storeVersion += 1; + for (const listener of listeners) { + listener(); + } +} + +function visibleValue(entry: ServiceEntry): ServiceRecord | null { + let newestRevision = -1; + let newest: ServiceRecord | null = null; + + for (const [revision, service] of entry.pending) { + // A newer successful mutation may commit while an older request is still + // in flight. Once the base has advanced past that request, the older + // optimistic value is stale and must never become visible again. + if (revision <= entry.baseRevision) { + continue; + } + + if (revision > newestRevision) { + newestRevision = revision; + newest = service; + } + } + + return newest ?? entry.base; +} + +function removePending( + serviceId: string, + entry: ServiceEntry, + revision: number, +) { + entry.pending.delete(revision); + if (entry.base === null && entry.pending.size === 0) { + entries.delete(serviceId); + } +} + +function reserveMutationTurn(serviceId: string) { + const previous = mutationTails.get(serviceId) ?? Promise.resolve(); + const wait: Promise = previous.catch(() => {}); + + let releaseCurrent!: () => void; + const current = new Promise((resolve) => { + releaseCurrent = resolve; + }); + const tail = wait.then(() => current); + mutationTails.set(serviceId, tail); + + return { + wait, + release() { + releaseCurrent(); + if (mutationTails.get(serviceId) === tail) { + mutationTails.delete(serviceId); + } + }, + }; +} + +export function subscribeServiceOptimisticStore(listener: () => void) { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function getServiceOptimisticStoreVersion() { + return storeVersion; +} + +export function hydrateServiceSnapshot(service: ServiceRecord) { + const entry = getOrCreateEntry(service.serviceId); + if (sameService(entry.base, service)) { + return; + } + + entry.base = cloneService(service); + emitChange(); +} + +export function hydrateServiceSnapshots(services: ServiceRecord[]) { + let changed = false; + + for (const service of services) { + const entry = getOrCreateEntry(service.serviceId); + if (sameService(entry.base, service)) { + continue; + } + + entry.base = cloneService(service); + changed = true; + } + + if (changed) { + emitChange(); + } +} + +export function getOptimisticService( + serviceId: string, +): ServiceRecord | null { + const entry = entries.get(serviceId); + return entry ? visibleValue(entry) : null; +} + +export function useOptimisticService( + serviceId: string, + serverService: ServiceRecord | null, +): ServiceRecord | null { + const version = useSyncExternalStore( + subscribeServiceOptimisticStore, + getServiceOptimisticStoreVersion, + getServiceOptimisticStoreVersion, + ); + + useEffect(() => { + if (serverService) { + hydrateServiceSnapshot(serverService); + } + }, [serverService]); + + void version; + return getOptimisticService(serviceId) ?? serverService; +} + +export function useOptimisticServices( + serverServices: ServiceRecord[] | null, +): ServiceRecord[] | null { + const version = useSyncExternalStore( + subscribeServiceOptimisticStore, + getServiceOptimisticStoreVersion, + getServiceOptimisticStoreVersion, + ); + + useEffect(() => { + if (serverServices) { + hydrateServiceSnapshots(serverServices); + } + }, [serverServices]); + + void version; + + if (!serverServices) { + return null; + } + + return serverServices.map( + (service) => getOptimisticService(service.serviceId) ?? service, + ); +} + +/** + * Apply one service mutation immediately and reconcile it with canonical + * server state after the write succeeds. + * + * Optimistic values are published immediately. Network mutation/reconciliation + * for one service is serialized in submit order so an older write cannot land + * after a newer one and leave server state behind the UI. Different services + * keep independent queues and can still progress concurrently. A failed + * mutation removes only its own revision, revealing the newest valid pending + * value or the committed base. + */ +export async function runOptimisticServiceMutation( + options: OptimisticServiceMutationOptions, +): Promise { + if (options.optimistic.serviceId !== options.serviceId) { + throw new Error("optimistic service id does not match mutation key"); + } + + const entry = getOrCreateEntry(options.serviceId); + if (entry.base === null && options.current) { + entry.base = cloneService(options.current); + } + + const revision = ++nextMutationRevision; + entry.pending.set(revision, cloneService(options.optimistic)); + emitChange(); + + const turn = reserveMutationTurn(options.serviceId); + await turn.wait; + + try { + try { + await options.mutate(); + } catch { + removePending(options.serviceId, entry, revision); + emitChange(); + throw new ServiceMutationError( + "SERVICE_WRITE_FAILED", + options.serviceId, + options.failureMessage ?? DEFAULT_FAILURE_MESSAGE, + ); + } + + let canonical: ServiceRecord; + try { + canonical = await options.reconcile(); + if (canonical.serviceId !== options.serviceId) { + throw new Error("reconciled service id does not match mutation key"); + } + } catch { + if (revision >= entry.baseRevision) { + entry.base = cloneService(options.optimistic); + entry.baseRevision = revision; + } + removePending(options.serviceId, entry, revision); + emitChange(); + throw new ServiceMutationError( + "SERVICE_RECONCILE_FAILED", + options.serviceId, + options.reconcileMessage ?? DEFAULT_RECONCILE_MESSAGE, + ); + } + + if (revision >= entry.baseRevision) { + entry.base = cloneService(canonical); + entry.baseRevision = revision; + } + + removePending(options.serviceId, entry, revision); + emitChange(); + return canonical; + } finally { + turn.release(); + } +} + +export function __resetServiceOptimisticStoreForTests() { + entries.clear(); + mutationTails.clear(); + nextMutationRevision = 0; + emitChange(); +}