oh-my-agent-commandcode 0.0.0-stage → 20.38.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -2
- package/filter-test-output.sh +52 -0
- package/mods/oma.ts +347 -0
- package/package.json +30 -4
- package/skills/_shared/conditional/experiment-ledger.md +28 -0
- package/skills/_shared/conditional/exploration-loop.md +22 -0
- package/skills/_shared/conditional/quality-score.md +33 -0
- package/skills/_shared/core/anti-ai-prose.md +191 -0
- package/skills/_shared/core/api-contracts/README.md +28 -0
- package/skills/_shared/core/api-contracts/template.md +90 -0
- package/skills/_shared/core/clarification-protocol.md +23 -0
- package/skills/_shared/core/code-intelligence.md +46 -0
- package/skills/_shared/core/collaboration-policy.md +28 -0
- package/skills/_shared/core/common-checklist.md +28 -0
- package/skills/_shared/core/context-budget.md +22 -0
- package/skills/_shared/core/context-loading.md +34 -0
- package/skills/_shared/core/difficulty-guide.md +26 -0
- package/skills/_shared/core/execution-policy.md +31 -0
- package/skills/_shared/core/lessons-learned.md +33 -0
- package/skills/_shared/core/prompt-structure.md +18 -0
- package/skills/_shared/core/quality-principles.md +11 -0
- package/skills/_shared/core/session-metrics.md +35 -0
- package/skills/_shared/core/skill-routing.md +39 -0
- package/skills/_shared/core/test-approach.md +54 -0
- package/skills/_shared/core/vendor-detection.md +52 -0
- package/skills/_shared/runtime/memory-protocol.md +118 -0
- package/skills/_shared/runtime/result-contract.md +64 -0
- package/skills/_shared/ui-interaction-quality.md +133 -0
- package/skills/oma-academic-writing/SKILL.md +181 -0
- package/skills/oma-academic-writing/resources/abstract-guide.md +51 -0
- package/skills/oma-academic-writing/resources/academic-verb-tiers.md +42 -0
- package/skills/oma-academic-writing/resources/anti-ai-checklist.md +52 -0
- package/skills/oma-academic-writing/resources/hedging-guide.md +132 -0
- package/skills/oma-academic-writing/resources/sentence-structure-reference.md +151 -0
- package/skills/oma-architecture/SKILL.md +203 -0
- package/skills/oma-architecture/resources/api-evolution.md +41 -0
- package/skills/oma-architecture/resources/checklist.md +42 -0
- package/skills/oma-architecture/resources/execution-protocol.md +115 -0
- package/skills/oma-architecture/resources/methodology-selection.md +92 -0
- package/skills/oma-architecture/resources/migration-patterns.md +28 -0
- package/skills/oma-architecture/resources/output-templates.md +111 -0
- package/skills/oma-architecture/resources/stakeholder-synthesis.md +80 -0
- package/skills/oma-backend/SKILL.md +202 -0
- package/skills/oma-backend/resources/checklist.md +45 -0
- package/skills/oma-backend/resources/error-playbook.md +97 -0
- package/skills/oma-backend/resources/execution-protocol.md +39 -0
- package/skills/oma-backend/resources/orm-reference.md +61 -0
- package/skills/oma-backend/variants/node/api-template.ts +315 -0
- package/skills/oma-backend/variants/node/snippets.md +475 -0
- package/skills/oma-backend/variants/node/stack.yaml +21 -0
- package/skills/oma-backend/variants/node/tech-stack.md +33 -0
- package/skills/oma-backend/variants/python/api-template.py +378 -0
- package/skills/oma-backend/variants/python/snippets.md +199 -0
- package/skills/oma-backend/variants/python/stack.yaml +18 -0
- package/skills/oma-backend/variants/python/tech-stack.md +45 -0
- package/skills/oma-backend/variants/rust/api-template.rs +635 -0
- package/skills/oma-backend/variants/rust/snippets.md +471 -0
- package/skills/oma-backend/variants/rust/stack.yaml +20 -0
- package/skills/oma-backend/variants/rust/tech-stack.md +36 -0
- package/skills/oma-backend/variants/stack.schema.json +73 -0
- package/skills/oma-brainstorm/SKILL.md +166 -0
- package/skills/oma-brainstorm/resources/triz-lite.md +117 -0
- package/skills/oma-coordination/SKILL.md +171 -0
- package/skills/oma-db/SKILL.md +201 -0
- package/skills/oma-db/resources/anti-patterns.md +177 -0
- package/skills/oma-db/resources/checklist.md +67 -0
- package/skills/oma-db/resources/document-templates.md +107 -0
- package/skills/oma-db/resources/error-playbook.md +50 -0
- package/skills/oma-db/resources/examples.md +170 -0
- package/skills/oma-db/resources/execution-protocol.md +127 -0
- package/skills/oma-db/resources/iso-controls.md +98 -0
- package/skills/oma-db/resources/migration-playbook.md +93 -0
- package/skills/oma-db/resources/query-tuning.md +57 -0
- package/skills/oma-db/resources/vector-db.md +159 -0
- package/skills/oma-debug/SKILL.md +146 -0
- package/skills/oma-debug/resources/bug-report-template.md +327 -0
- package/skills/oma-debug/resources/checklist.md +31 -0
- package/skills/oma-debug/resources/common-patterns.md +734 -0
- package/skills/oma-debug/resources/debugging-checklist.md +364 -0
- package/skills/oma-debug/resources/error-playbook.md +94 -0
- package/skills/oma-debug/resources/execution-protocol.md +40 -0
- package/skills/oma-design/SKILL.md +135 -0
- package/skills/oma-design/resources/asset-strategy.md +34 -0
- package/skills/oma-design/resources/checklist.md +63 -0
- package/skills/oma-design/resources/data-grid.md +48 -0
- package/skills/oma-design/resources/design-md-spec.md +107 -0
- package/skills/oma-design/resources/design-tokens.md +53 -0
- package/skills/oma-design/resources/error-playbook.md +18 -0
- package/skills/oma-design/resources/execution-protocol.md +95 -0
- package/skills/oma-design/resources/external-design-skills.md +130 -0
- package/skills/oma-design/resources/getdesign-fetcher.md +104 -0
- package/skills/oma-design/resources/product-interface-review.md +59 -0
- package/skills/oma-design/resources/redesign-protocol.md +52 -0
- package/skills/oma-design/resources/stitch-integration.md +244 -0
- package/skills/oma-dev-workflow/SKILL.md +375 -0
- package/skills/oma-dev-workflow/resources/affected-checks.py +58 -0
- package/skills/oma-dev-workflow/resources/api-workflows.md +58 -0
- package/skills/oma-dev-workflow/resources/database-patterns.md +107 -0
- package/skills/oma-dev-workflow/resources/i18n-patterns.md +63 -0
- package/skills/oma-dev-workflow/resources/release-coordination.md +240 -0
- package/skills/oma-dev-workflow/resources/tooling-profiles.md +217 -0
- package/skills/oma-dev-workflow/resources/troubleshooting.md +78 -0
- package/skills/oma-dev-workflow/resources/validation-pipeline.md +246 -0
- package/skills/oma-docs/SKILL.md +77 -0
- package/skills/oma-docs/resources/commands.md +76 -0
- package/skills/oma-explanation/SKILL.md +212 -0
- package/skills/oma-explanation/resources/document-structure.md +115 -0
- package/skills/oma-explanation/resources/draft-format.md +199 -0
- package/skills/oma-explanation/resources/html-contract.md +102 -0
- package/skills/oma-frontend/SKILL.md +227 -0
- package/skills/oma-frontend/resources/angular-rules.md +110 -0
- package/skills/oma-frontend/resources/checklist.md +76 -0
- package/skills/oma-frontend/resources/error-playbook.md +125 -0
- package/skills/oma-frontend/resources/execution-protocol.md +46 -0
- package/skills/oma-frontend/resources/snippets.md +354 -0
- package/skills/oma-frontend/resources/specialized-visual-implementation.md +193 -0
- package/skills/oma-frontend/resources/tailwind-rules.md +83 -0
- package/skills/oma-frontend/resources/tech-stack.md +156 -0
- package/skills/oma-hwp/SKILL.md +166 -0
- package/skills/oma-hwp/config/hwp-config.yaml +35 -0
- package/skills/oma-hwp/resources/balanced-tables.ts +39 -0
- package/skills/oma-hwp/resources/execution-protocol.md +145 -0
- package/skills/oma-hwp/resources/flatten-tables.ts +101 -0
- package/skills/oma-hwp/resources/package.json +13 -0
- package/skills/oma-hwp/resources/troubleshooting.md +70 -0
- package/skills/oma-hwp/resources/tsconfig.json +10 -0
- package/skills/oma-hwp/resources/turndown-plugin-gfm.d.ts +9 -0
- package/skills/oma-image/SKILL.md +142 -0
- package/skills/oma-image/config/image-config.yaml +70 -0
- package/skills/oma-image/resources/checklist.md +18 -0
- package/skills/oma-image/resources/execution-protocol.md +117 -0
- package/skills/oma-image/resources/invocation.md +105 -0
- package/skills/oma-image/resources/prompt-tips.md +71 -0
- package/skills/oma-image/resources/vendor-matrix.md +90 -0
- package/skills/oma-market/SKILL.md +142 -0
- package/skills/oma-market/resources/checklist.md +12 -0
- package/skills/oma-market/resources/error-playbook.md +12 -0
- package/skills/oma-market/resources/execution-protocol.md +60 -0
- package/skills/oma-market/resources/frameworks/pestel.md +101 -0
- package/skills/oma-market/resources/frameworks/porters-5f.md +90 -0
- package/skills/oma-market/resources/frameworks/swot.md +61 -0
- package/skills/oma-market/resources/intent-rules.md +35 -0
- package/skills/oma-market/resources/output-laws.md +53 -0
- package/skills/oma-mobile/SKILL.md +168 -0
- package/skills/oma-mobile/resources/checklist.md +77 -0
- package/skills/oma-mobile/resources/error-playbook.md +198 -0
- package/skills/oma-mobile/resources/execution-protocol.md +47 -0
- package/skills/oma-mobile/resources/screen-template.dart +331 -0
- package/skills/oma-mobile/resources/screen-template.swift +239 -0
- package/skills/oma-mobile/resources/screen-template.tsx +179 -0
- package/skills/oma-mobile/resources/tech-stack.md +69 -0
- package/skills/oma-mobile/variants/README.md +45 -0
- package/skills/oma-mobile/variants/flutter/api-template.dart +461 -0
- package/skills/oma-mobile/variants/flutter/snippets.md +1353 -0
- package/skills/oma-mobile/variants/flutter/stack.yaml +21 -0
- package/skills/oma-mobile/variants/flutter/tech-stack.md +201 -0
- package/skills/oma-mobile/variants/kotlin-android/api-template.kt +182 -0
- package/skills/oma-mobile/variants/kotlin-android/snippets.md +871 -0
- package/skills/oma-mobile/variants/kotlin-android/stack.yaml +32 -0
- package/skills/oma-mobile/variants/kotlin-android/tech-stack.md +159 -0
- package/skills/oma-mobile/variants/react-native/api-template.ts +531 -0
- package/skills/oma-mobile/variants/react-native/snippets.md +1014 -0
- package/skills/oma-mobile/variants/react-native/stack.yaml +22 -0
- package/skills/oma-mobile/variants/react-native/tech-stack.md +192 -0
- package/skills/oma-mobile/variants/stack.schema.json +68 -0
- package/skills/oma-mobile/variants/swift-ios/api-template.swift +165 -0
- package/skills/oma-mobile/variants/swift-ios/snippets.md +1028 -0
- package/skills/oma-mobile/variants/swift-ios/stack.yaml +25 -0
- package/skills/oma-mobile/variants/swift-ios/tech-stack.md +174 -0
- package/skills/oma-observability/SKILL.md +225 -0
- package/skills/oma-observability/resources/anti-patterns.md +555 -0
- package/skills/oma-observability/resources/boundaries/cross-application.md +348 -0
- package/skills/oma-observability/resources/boundaries/multi-tenant.md +290 -0
- package/skills/oma-observability/resources/boundaries/release.md +372 -0
- package/skills/oma-observability/resources/boundaries/slo.md +225 -0
- package/skills/oma-observability/resources/checklist.md +123 -0
- package/skills/oma-observability/resources/examples.md +448 -0
- package/skills/oma-observability/resources/execution-protocol.md +166 -0
- package/skills/oma-observability/resources/incident-forensics.md +348 -0
- package/skills/oma-observability/resources/intent-rules.md +307 -0
- package/skills/oma-observability/resources/layers/L3-network.md +281 -0
- package/skills/oma-observability/resources/layers/L4-transport.md +299 -0
- package/skills/oma-observability/resources/layers/L7-application/crash-analytics.md +359 -0
- package/skills/oma-observability/resources/layers/L7-application/mobile-rum.md +278 -0
- package/skills/oma-observability/resources/layers/L7-application/waf.md +246 -0
- package/skills/oma-observability/resources/layers/L7-application/web-rum.md +350 -0
- package/skills/oma-observability/resources/layers/mesh.md +368 -0
- package/skills/oma-observability/resources/matrix.md +177 -0
- package/skills/oma-observability/resources/meta-observability.md +605 -0
- package/skills/oma-observability/resources/observability-as-code.md +666 -0
- package/skills/oma-observability/resources/signals/audit.md +310 -0
- package/skills/oma-observability/resources/signals/cost.md +303 -0
- package/skills/oma-observability/resources/signals/logs.md +263 -0
- package/skills/oma-observability/resources/signals/metrics.md +478 -0
- package/skills/oma-observability/resources/signals/privacy.md +351 -0
- package/skills/oma-observability/resources/signals/profiles.md +245 -0
- package/skills/oma-observability/resources/signals/traces.md +360 -0
- package/skills/oma-observability/resources/standards.md +214 -0
- package/skills/oma-observability/resources/transport/collector-topology.md +308 -0
- package/skills/oma-observability/resources/transport/otlp-grpc-vs-http.md +256 -0
- package/skills/oma-observability/resources/transport/sampling-recipes.md +297 -0
- package/skills/oma-observability/resources/transport/udp-statsd-mtu.md +167 -0
- package/skills/oma-observability/resources/vendor-categories.md +352 -0
- package/skills/oma-orchestration/SKILL.md +302 -0
- package/skills/oma-orchestration/config/cli-config.yaml +92 -0
- package/skills/oma-orchestration/resources/memory-schema.md +232 -0
- package/skills/oma-orchestration/resources/subagent-prompt-template.md +30 -0
- package/skills/oma-orchestration/scripts/parallel-run.sh +6 -0
- package/skills/oma-orchestration/scripts/spawn-agent.sh +5 -0
- package/skills/oma-orchestration/scripts/verify.sh +20 -0
- package/skills/oma-orchestration/templates/backend-task.md +19 -0
- package/skills/oma-orchestration/templates/debug-task.md +16 -0
- package/skills/oma-orchestration/templates/frontend-task.md +18 -0
- package/skills/oma-orchestration/templates/mobile-task.md +18 -0
- package/skills/oma-orchestration/templates/qa-task.md +16 -0
- package/skills/oma-orchestration/templates/tasks-example.yaml +15 -0
- package/skills/oma-pdf/SKILL.md +162 -0
- package/skills/oma-pdf/config/pdf-config.yaml +27 -0
- package/skills/oma-pdf/resources/execution-protocol.md +127 -0
- package/skills/oma-pm/SKILL.md +157 -0
- package/skills/oma-pm/resources/error-playbook.md +75 -0
- package/skills/oma-pm/resources/examples.md +439 -0
- package/skills/oma-pm/resources/execution-protocol.md +62 -0
- package/skills/oma-pm/resources/iso-planning.md +110 -0
- package/skills/oma-pm/resources/plan-phase-protocol.md +66 -0
- package/skills/oma-pm/resources/task-template.json +86 -0
- package/skills/oma-qa/SKILL.md +141 -0
- package/skills/oma-qa/resources/checklist.md +339 -0
- package/skills/oma-qa/resources/error-playbook.md +98 -0
- package/skills/oma-qa/resources/execution-protocol.md +169 -0
- package/skills/oma-qa/resources/iso-quality.md +82 -0
- package/skills/oma-qa/resources/verify-ship-protocol.md +116 -0
- package/skills/oma-recap/SKILL.md +245 -0
- package/skills/oma-recap/resources/output-formats.md +83 -0
- package/skills/oma-refactor/SKILL.md +184 -0
- package/skills/oma-refactor/resources/definition.md +108 -0
- package/skills/oma-refactor/resources/governance.md +33 -0
- package/skills/oma-refactor/resources/hotspots.py +44 -0
- package/skills/oma-refactor/resources/measurement.md +93 -0
- package/skills/oma-scholar/SKILL.md +276 -0
- package/skills/oma-scholar/config/scholar-config.yaml +79 -0
- package/skills/oma-scholar/resources/api-endpoints.md +174 -0
- package/skills/oma-scholar/resources/checklist.md +92 -0
- package/skills/oma-scholar/resources/execution-protocol.md +224 -0
- package/skills/oma-scholar/resources/fallback-providers.md +143 -0
- package/skills/oma-scholar/resources/setup-openalex.md +85 -0
- package/skills/oma-scholar/resources/sidecar-spec.md +341 -0
- package/skills/oma-scholar/resources/upstream-spec-cache.md +972 -0
- package/skills/oma-scm/SKILL.md +103 -0
- package/skills/oma-scm/resources/cm-operations.md +56 -0
- package/skills/oma-scm/resources/codeowners-playbook.md +45 -0
- package/skills/oma-scm/resources/conventional-commits.md +108 -0
- package/skills/oma-scm/resources/merge-risk.md +76 -0
- package/skills/oma-scm/resources/onboarding-risk-signals.md +71 -0
- package/skills/oma-search/SKILL.md +125 -0
- package/skills/oma-search/resources/checklist.md +36 -0
- package/skills/oma-search/resources/error-playbook.md +66 -0
- package/skills/oma-search/resources/examples.md +80 -0
- package/skills/oma-search/resources/execution-protocol.md +86 -0
- package/skills/oma-search/resources/intent-rules.md +47 -0
- package/skills/oma-search/resources/trust-registry.md +53 -0
- package/skills/oma-security/SKILL.md +172 -0
- package/skills/oma-security/resources/artex.md +108 -0
- package/skills/oma-security/resources/ci.md +134 -0
- package/skills/oma-security/resources/cisco-source.md +108 -0
- package/skills/oma-security/resources/deepsec-config.md +45 -0
- package/skills/oma-security/resources/deepsec-matchers.md +35 -0
- package/skills/oma-security/resources/deepsec-scanning.md +45 -0
- package/skills/oma-security/resources/deepsec-setup.md +41 -0
- package/skills/oma-security/resources/findings-contract.md +408 -0
- package/skills/oma-security/resources/mcp-scanning.md +122 -0
- package/skills/oma-security/resources/skill-scanning.md +106 -0
- package/skills/oma-security/resources/validation.md +71 -0
- package/skills/oma-skill-creation/SKILL.md +84 -0
- package/skills/oma-skill-creation/resources/prompt-evaluation.md +36 -0
- package/skills/oma-skill-creation/resources/ssl-lite-template.md +108 -0
- package/skills/oma-skill-creation/resources/validation-checklist.md +101 -0
- package/skills/oma-slide/SKILL.md +277 -0
- package/skills/oma-slide/resources/animation-patterns.md +245 -0
- package/skills/oma-slide/resources/assets/deck-stage.js +514 -0
- package/skills/oma-slide/resources/assets/viewport-base.css +257 -0
- package/skills/oma-slide/resources/canva-integration.md +203 -0
- package/skills/oma-slide/resources/checklist.md +46 -0
- package/skills/oma-slide/resources/design-doctrine.md +260 -0
- package/skills/oma-slide/resources/fixed-stage.md +232 -0
- package/skills/oma-slide/resources/generation-protocol.md +393 -0
- package/skills/oma-slide/resources/native-pptx.md +84 -0
- package/skills/oma-slide/resources/selection-index.json +461 -0
- package/skills/oma-slide/resources/style-presets.LICENSE +10 -0
- package/skills/oma-slide/resources/style-presets.md +520 -0
- package/skills/oma-tf-infra/SKILL.md +201 -0
- package/skills/oma-tf-infra/resources/checklist.md +53 -0
- package/skills/oma-tf-infra/resources/cost-optimization.md +574 -0
- package/skills/oma-tf-infra/resources/error-playbook.md +68 -0
- package/skills/oma-tf-infra/resources/examples.md +38 -0
- package/skills/oma-tf-infra/resources/execution-protocol.md +129 -0
- package/skills/oma-tf-infra/resources/iso-42001-infra.md +229 -0
- package/skills/oma-tf-infra/resources/multi-cloud-examples.md +241 -0
- package/skills/oma-tf-infra/resources/policy-testing-examples.md +332 -0
- package/skills/oma-translation/SKILL.md +229 -0
- package/skills/oma-translation/resources/anti-ai-patterns.md +104 -0
- package/skills/oma-translation/resources/lang/_template.md +73 -0
- package/skills/oma-translation/resources/lang/en.md +123 -0
- package/skills/oma-translation/resources/lang/ja.md +274 -0
- package/skills/oma-translation/resources/lang/ko.md +228 -0
- package/skills/oma-translation/resources/lang/zh.md +285 -0
- package/skills/oma-translation/resources/style-skills.md +104 -0
- package/skills/oma-translation/resources/translation-rubric.md +126 -0
- package/skills/oma-video/SKILL.md +229 -0
- package/skills/oma-video/config/video-config.yaml +60 -0
- package/skills/oma-video/resources/checklist.md +26 -0
- package/skills/oma-video/resources/execution-protocol.md +122 -0
- package/skills/oma-video/resources/existing-media.md +185 -0
- package/skills/oma-video/resources/hyperframes-authoring/README.md +44 -0
- package/skills/oma-video/resources/hyperframes-authoring/demo.md +31 -0
- package/skills/oma-video/resources/hyperframes-authoring/explainer.md +10 -0
- package/skills/oma-video/resources/hyperframes-authoring/shorts.md +10 -0
- package/skills/oma-video/resources/motion-principles.md +81 -0
- package/skills/oma-video/resources/mpt/driver.py +301 -0
- package/skills/oma-video/resources/prompt-tips.md +75 -0
- package/skills/oma-video/resources/script-schema.md +81 -0
- package/skills/oma-video/resources/sound-design.md +127 -0
- package/skills/oma-video/resources/strudel/package.json +11 -0
- package/skills/oma-video/resources/strudel/page.html +87 -0
- package/skills/oma-video/resources/strudel/render.mjs +295 -0
- package/skills/oma-video/resources/vendor-matrix.md +124 -0
- package/skills/oma-voice/SKILL.md +249 -0
- package/skills/oma-voice/config/voice-config.yaml +30 -0
- package/skills/oma-voice/resources/audio-processing.md +119 -0
- package/skills/oma-voice/resources/checklist.md +47 -0
- package/skills/oma-voice/resources/execution-protocol.md +215 -0
- package/skills/oma-voice/resources/prompt-tips.md +43 -0
- package/skills/oma-voice/resources/voice-matrix.md +51 -0
- package/skills/oma-voice/resources/voicestudio.md +87 -0
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API Data Layer Template for Mobile Agent (React Native)
|
|
3
|
+
*
|
|
4
|
+
* This file is the complete todos data layer — the ONLY place that touches
|
|
5
|
+
* the axios transport for the /todos resource. Screens and components never
|
|
6
|
+
* import from this file directly; they consume the TanStack Query hooks in
|
|
7
|
+
* src/features/todos/queries.ts and mutations.ts, which call these functions.
|
|
8
|
+
*
|
|
9
|
+
* Layout (split into separate files in production):
|
|
10
|
+
* src/shared/utils/
|
|
11
|
+
* storage.ts ← MMKV singletons (non-secret KV + query cache)
|
|
12
|
+
* src/store/
|
|
13
|
+
* authStore.ts ← Zustand auth store (in-memory token, Keychain-backed)
|
|
14
|
+
* src/api/
|
|
15
|
+
* client.ts ← singleton axios instance + interceptors (auth, refresh, retry)
|
|
16
|
+
* queryClient.ts ← QueryClient + MMKV persister + onlineManager (offline-first)
|
|
17
|
+
* todos.ts ← THIS FILE: typed axios functions for /todos
|
|
18
|
+
* src/features/todos/
|
|
19
|
+
* queries.ts ← useQuery hooks (useTodosQuery, useTodoDetailQuery)
|
|
20
|
+
* mutations.ts ← useMutation hooks (useCreateTodo, useToggleTodo, useDeleteTodo)
|
|
21
|
+
*
|
|
22
|
+
* Caching contract (TanStack Query owns the repository-layer cache):
|
|
23
|
+
* - Reads: useQuery caches DECODED JS objects (not AxiosResponse bytes).
|
|
24
|
+
* staleTime / gcTime are explicit — no implicit infinite TTL. gcTime must be
|
|
25
|
+
* >= the persister's maxAge or the persisted cache is GC'd before it can be
|
|
26
|
+
* restored, silently defeating offline-first. Query keys = [account/tenant, operation, ...params],
|
|
27
|
+
* never URLs. Stale-while-revalidate: the cache entry renders immediately; a
|
|
28
|
+
* background fetch updates it once the entry is older than staleTime.
|
|
29
|
+
* - Writes: useMutation calls queryClient.invalidateQueries() for all affected
|
|
30
|
+
* keys so the next read repopulates from the server.
|
|
31
|
+
* - Offline persistence: @tanstack/query-sync-storage-persister +
|
|
32
|
+
* @tanstack/react-query-persist-client + an MMKV persister (react-native-mmkv)
|
|
33
|
+
* serialise the cache to disk so it survives app restarts.
|
|
34
|
+
* - Secrets: the access token lives in an in-memory Zustand store hydrated from
|
|
35
|
+
* react-native-keychain — NEVER in plain-text MMKV. Durable non-secret user
|
|
36
|
+
* data belongs in MMKV. TanStack Query is never a system of record.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
// ============================================================================
|
|
40
|
+
// src/shared/utils/storage.ts
|
|
41
|
+
// ============================================================================
|
|
42
|
+
// Canonical MMKV singletons. Every module imports from here — never call
|
|
43
|
+
// `createMMKV(...)` anywhere else. MMKV is plain text unless an encryptionKey is
|
|
44
|
+
// passed, so it holds NON-SECRET data only (prefs, offline flags, query cache).
|
|
45
|
+
|
|
46
|
+
import { createMMKV } from 'react-native-mmkv';
|
|
47
|
+
|
|
48
|
+
/** General-purpose non-secret KV store. */
|
|
49
|
+
export const storage = createMMKV({ id: 'app-storage' });
|
|
50
|
+
|
|
51
|
+
/** Separate instance namespaced for the query cache to avoid key collisions. */
|
|
52
|
+
export const queryStorage = createMMKV({ id: 'query-cache' });
|
|
53
|
+
|
|
54
|
+
// ============================================================================
|
|
55
|
+
// src/store/authStore.ts
|
|
56
|
+
// ============================================================================
|
|
57
|
+
// Auth session store. The access token lives ONLY in memory here and in the
|
|
58
|
+
// platform secure enclave via react-native-keychain — never in plain MMKV.
|
|
59
|
+
|
|
60
|
+
import { create } from 'zustand';
|
|
61
|
+
import * as Keychain from 'react-native-keychain';
|
|
62
|
+
import { resetAccountQueryCache } from '@api/queryClient';
|
|
63
|
+
|
|
64
|
+
const KEYCHAIN_SERVICE = 'com.example.app.auth';
|
|
65
|
+
|
|
66
|
+
interface AuthState {
|
|
67
|
+
accessToken: string | null;
|
|
68
|
+
accountId: string | null; // validated non-secret account/tenant ID from the server
|
|
69
|
+
sessionVersion: number;
|
|
70
|
+
isAuthenticated: boolean;
|
|
71
|
+
setToken: (token: string, accountId: string) => Promise<void>;
|
|
72
|
+
updateToken: (token: string, version: number) => Promise<boolean>;
|
|
73
|
+
clearToken: (expectedVersion?: number) => Promise<void>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Serialize credential writes: a late refresh cannot undo logout.
|
|
77
|
+
let sessionTransition: Promise<unknown> = Promise.resolve();
|
|
78
|
+
function transition<T>(operation: () => Promise<T>): Promise<T> {
|
|
79
|
+
const next = sessionTransition.then(operation);
|
|
80
|
+
sessionTransition = next.catch(() => undefined);
|
|
81
|
+
return next;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const useAuthStore = create<AuthState>()((set, get) => ({
|
|
85
|
+
accessToken: null,
|
|
86
|
+
accountId: null,
|
|
87
|
+
sessionVersion: 0,
|
|
88
|
+
isAuthenticated: false,
|
|
89
|
+
setToken: (token, accountId) => transition(async () => {
|
|
90
|
+
if (!accountId) throw new Error('Validated account ID is required');
|
|
91
|
+
const previousAccount = get().accountId;
|
|
92
|
+
set({ accessToken: null, accountId: null, isAuthenticated: false,
|
|
93
|
+
sessionVersion: get().sessionVersion + 1 }); // unmount old account before awaiting
|
|
94
|
+
await resetAccountQueryCache(previousAccount);
|
|
95
|
+
await Keychain.setGenericPassword(accountId, token, { service: KEYCHAIN_SERVICE });
|
|
96
|
+
set({ accessToken: token, accountId, isAuthenticated: true });
|
|
97
|
+
}),
|
|
98
|
+
updateToken: (token, version) => transition(async () => {
|
|
99
|
+
const accountId = get().accountId;
|
|
100
|
+
if (version !== get().sessionVersion || !accountId) return false;
|
|
101
|
+
await Keychain.setGenericPassword(accountId, token, { service: KEYCHAIN_SERVICE });
|
|
102
|
+
set({ accessToken: token });
|
|
103
|
+
return true;
|
|
104
|
+
}),
|
|
105
|
+
clearToken: (expectedVersion) => transition(async () => {
|
|
106
|
+
// Check inside the queue: a queued new login may have finished meanwhile.
|
|
107
|
+
if (expectedVersion !== undefined && expectedVersion !== get().sessionVersion) return;
|
|
108
|
+
const previousAccount = get().accountId;
|
|
109
|
+
set({ accessToken: null, accountId: null, isAuthenticated: false,
|
|
110
|
+
sessionVersion: get().sessionVersion + 1 });
|
|
111
|
+
await resetAccountQueryCache(previousAccount);
|
|
112
|
+
await Keychain.resetGenericPassword({ service: KEYCHAIN_SERVICE });
|
|
113
|
+
}),
|
|
114
|
+
}));
|
|
115
|
+
|
|
116
|
+
// Call before mounting account providers. The username is the validated account
|
|
117
|
+
// ID written by setToken; discard legacy token-only entries during migration.
|
|
118
|
+
export async function hydrateAuth(): Promise<void> {
|
|
119
|
+
const creds = await Keychain.getGenericPassword({ service: KEYCHAIN_SERVICE });
|
|
120
|
+
if (creds && creds.username !== 'accessToken') {
|
|
121
|
+
await useAuthStore.getState().setToken(creds.password, creds.username);
|
|
122
|
+
} else if (creds) {
|
|
123
|
+
await useAuthStore.getState().clearToken();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ============================================================================
|
|
128
|
+
// src/api/client.ts
|
|
129
|
+
// ============================================================================
|
|
130
|
+
|
|
131
|
+
import axios, {
|
|
132
|
+
type AxiosInstance,
|
|
133
|
+
type AxiosRequestConfig,
|
|
134
|
+
type InternalAxiosRequestConfig,
|
|
135
|
+
type AxiosResponse,
|
|
136
|
+
type AxiosError,
|
|
137
|
+
} from 'axios';
|
|
138
|
+
import axiosRetry from 'axios-retry';
|
|
139
|
+
|
|
140
|
+
const BASE_URL =
|
|
141
|
+
process.env.EXPO_PUBLIC_API_BASE_URL ?? 'https://api.example.com';
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Singleton axios instance — the ONLY axios instance in the codebase.
|
|
145
|
+
* All src/api/*.ts data functions import from this module.
|
|
146
|
+
* React components and screens NEVER import this directly.
|
|
147
|
+
*/
|
|
148
|
+
export const apiClient: AxiosInstance = axios.create({
|
|
149
|
+
baseURL: BASE_URL,
|
|
150
|
+
timeout: 15_000,
|
|
151
|
+
headers: { 'Content-Type': 'application/json' },
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// --- Request interceptor: inject bearer token ---
|
|
155
|
+
// Read synchronously from the in-memory auth store (hydrated from the Keychain
|
|
156
|
+
// at app start). Secrets NEVER live in plain-text MMKV.
|
|
157
|
+
export type SessionRequestConfig = AxiosRequestConfig & { _sessionVersion: number; _accountId: string | null };
|
|
158
|
+
type SessionRequest = InternalAxiosRequestConfig & { _sessionVersion?: number; _accountId?: string | null; _retried?: boolean };
|
|
159
|
+
apiClient.interceptors.request.use((config: SessionRequest) => {
|
|
160
|
+
config._sessionVersion ??= useAuthStore.getState().sessionVersion;
|
|
161
|
+
if (!('_accountId' in config)) config._accountId = useAuthStore.getState().accountId;
|
|
162
|
+
if (config._sessionVersion !== useAuthStore.getState().sessionVersion ||
|
|
163
|
+
config._accountId !== useAuthStore.getState().accountId) {
|
|
164
|
+
throw new axios.CanceledError('Request belongs to a retired session');
|
|
165
|
+
}
|
|
166
|
+
const token = useAuthStore.getState().accessToken;
|
|
167
|
+
if (token) {
|
|
168
|
+
config.headers.set('Authorization', `Bearer ${token}`);
|
|
169
|
+
}
|
|
170
|
+
return config;
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// --- Single-flight token refresh ---
|
|
174
|
+
// Only one refresh is ever in flight; concurrent 401s await the same promise
|
|
175
|
+
// and then replay their original request exactly once.
|
|
176
|
+
let refreshPromise: { version: number; promise: Promise<string | null> } | null = null;
|
|
177
|
+
|
|
178
|
+
async function refreshAccessToken(): Promise<string | null> {
|
|
179
|
+
const version = useAuthStore.getState().sessionVersion;
|
|
180
|
+
try {
|
|
181
|
+
// Bare axios (no interceptors/retry) so the refresh can't recurse on itself.
|
|
182
|
+
const { data } = await axios.post<{ accessToken: string }>(
|
|
183
|
+
`${BASE_URL}/auth/refresh`,
|
|
184
|
+
{},
|
|
185
|
+
{ withCredentials: true }, // refresh token rides as an httpOnly cookie
|
|
186
|
+
);
|
|
187
|
+
if (!await useAuthStore.getState().updateToken(data.accessToken, version)) return null;
|
|
188
|
+
return data.accessToken;
|
|
189
|
+
} catch {
|
|
190
|
+
await useAuthStore.getState().clearToken(version);
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// --- Response interceptor: refresh once on 401, else logout ---
|
|
196
|
+
apiClient.interceptors.response.use(
|
|
197
|
+
(response: AxiosResponse) => response,
|
|
198
|
+
async (error: AxiosError) => {
|
|
199
|
+
const original = error.config as
|
|
200
|
+
| SessionRequest
|
|
201
|
+
| undefined;
|
|
202
|
+
|
|
203
|
+
if (error.response?.status === 401 && original && !original._retried &&
|
|
204
|
+
!original.signal?.aborted && useAuthStore.getState().isAuthenticated &&
|
|
205
|
+
original._sessionVersion === useAuthStore.getState().sessionVersion) {
|
|
206
|
+
original._retried = true;
|
|
207
|
+
const version = useAuthStore.getState().sessionVersion;
|
|
208
|
+
if (!refreshPromise || refreshPromise.version !== version) {
|
|
209
|
+
const promise = refreshAccessToken().finally(() => {
|
|
210
|
+
if (refreshPromise?.promise === promise) refreshPromise = null;
|
|
211
|
+
});
|
|
212
|
+
refreshPromise = { version, promise };
|
|
213
|
+
}
|
|
214
|
+
const newToken = await refreshPromise.promise;
|
|
215
|
+
if (newToken && version === useAuthStore.getState().sessionVersion && !original.signal?.aborted) {
|
|
216
|
+
original.headers.set('Authorization', `Bearer ${newToken}`);
|
|
217
|
+
return apiClient(original); // replay the original request once
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return Promise.reject(error);
|
|
221
|
+
},
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
// --- Retry: 3 attempts, exponential back-off, idempotent methods only ---
|
|
225
|
+
axiosRetry(apiClient, {
|
|
226
|
+
retries: 3,
|
|
227
|
+
retryCondition: (error) =>
|
|
228
|
+
['get', 'head', 'options', 'put', 'delete'].includes(error.config?.method?.toLowerCase() ?? '') &&
|
|
229
|
+
axiosRetry.isNetworkOrIdempotentRequestError(error) &&
|
|
230
|
+
error.response?.status !== 401,
|
|
231
|
+
retryDelay: axiosRetry.exponentialDelay,
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// ============================================================================
|
|
235
|
+
// src/api/queryClient.ts
|
|
236
|
+
// ============================================================================
|
|
237
|
+
|
|
238
|
+
import { QueryClient, onlineManager } from '@tanstack/react-query';
|
|
239
|
+
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';
|
|
240
|
+
import NetInfo from '@react-native-community/netinfo';
|
|
241
|
+
|
|
242
|
+
// Drive TanStack Query's online state from the real network status so paused
|
|
243
|
+
// mutations resume and stale queries refetch the moment connectivity returns.
|
|
244
|
+
onlineManager.setEventListener((setOnline) =>
|
|
245
|
+
NetInfo.addEventListener((state) => setOnline(!!state.isConnected)),
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* MMKV-backed persister for TanStack Query.
|
|
250
|
+
* Serialises the entire query cache to disk under a single key so the cache
|
|
251
|
+
* survives app restarts — this is the offline-first persistence tier.
|
|
252
|
+
*/
|
|
253
|
+
let cacheSessionVersion = 0;
|
|
254
|
+
export const createAccountPersister = (accountId: string) => {
|
|
255
|
+
const version = cacheSessionVersion;
|
|
256
|
+
return createSyncStoragePersister({
|
|
257
|
+
key: `query-cache:${accountId}`,
|
|
258
|
+
storage: {
|
|
259
|
+
getItem: (key) => queryStorage.getString(key) ?? null,
|
|
260
|
+
setItem: (key, value) => {
|
|
261
|
+
if (version === cacheSessionVersion) queryStorage.set(key, value);
|
|
262
|
+
},
|
|
263
|
+
removeItem: (key) => queryStorage.remove(key),
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* One QueryClient per authenticated session; provided via
|
|
270
|
+
* <PersistQueryClientProvider> in App.tsx. Never instantiate per-component.
|
|
271
|
+
*
|
|
272
|
+
* gcTime MUST stay >= the persister's maxAge (24h, set in App.tsx) or an
|
|
273
|
+
* unused entry is garbage-collected from memory before the persisted copy can
|
|
274
|
+
* be restored — which silently breaks "survives app restart". Override
|
|
275
|
+
* staleTime per-query for resources with different freshness requirements.
|
|
276
|
+
*/
|
|
277
|
+
const createQueryClient = () => new QueryClient({
|
|
278
|
+
defaultOptions: {
|
|
279
|
+
queries: {
|
|
280
|
+
staleTime: 60_000, // 1 minute — triggers background revalidation
|
|
281
|
+
gcTime: 1000 * 60 * 60 * 24, // 24h — must stay >= the persister maxAge
|
|
282
|
+
retry: 3,
|
|
283
|
+
refetchOnWindowFocus: false, // Irrelevant on mobile; disabling avoids surprises
|
|
284
|
+
},
|
|
285
|
+
mutations: {
|
|
286
|
+
retry: 0,
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
export let queryClient = createQueryClient();
|
|
291
|
+
export async function resetAccountQueryCache(accountId: string | null): Promise<void> {
|
|
292
|
+
cacheSessionVersion += 1; // block delayed disk writes from the retired session
|
|
293
|
+
await queryClient.cancelQueries();
|
|
294
|
+
queryClient.clear();
|
|
295
|
+
if (accountId) await createAccountPersister(accountId).removeClient();
|
|
296
|
+
queryClient = createQueryClient(); // old callbacks retain an unmounted client
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
// ============================================================================
|
|
301
|
+
// src/api/todos.ts
|
|
302
|
+
// ============================================================================
|
|
303
|
+
// Typed axios functions for the /todos REST resource.
|
|
304
|
+
// Pure data functions: accept inputs, call apiClient, return decoded objects.
|
|
305
|
+
// They are the transport seam — no React, no hooks, no cache knowledge.
|
|
306
|
+
|
|
307
|
+
import { apiClient, type SessionRequestConfig } from './client';
|
|
308
|
+
|
|
309
|
+
// --- Domain types ---
|
|
310
|
+
|
|
311
|
+
/** A single todo item returned by the API. */
|
|
312
|
+
export interface Todo {
|
|
313
|
+
id: string;
|
|
314
|
+
title: string;
|
|
315
|
+
completed: boolean;
|
|
316
|
+
createdAt: string;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Request body for creating a new todo. */
|
|
320
|
+
export interface CreateTodoInput {
|
|
321
|
+
title: string;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// --- Data functions ---
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Fetch all todos for the authenticated user.
|
|
328
|
+
* Called by: useTodosQuery (src/features/todos/queries.ts)
|
|
329
|
+
*/
|
|
330
|
+
export async function fetchTodos(accountId: string | null, sessionVersion: number, signal?: AbortSignal): Promise<Todo[]> {
|
|
331
|
+
const config: SessionRequestConfig = { _accountId: accountId, _sessionVersion: sessionVersion, signal };
|
|
332
|
+
const { data } = await apiClient.get<Todo[]>('/todos', config);
|
|
333
|
+
return data;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Fetch a single todo by ID.
|
|
338
|
+
* Called by: useTodoDetailQuery (src/features/todos/queries.ts)
|
|
339
|
+
*/
|
|
340
|
+
export async function fetchTodo(id: string, accountId: string | null, sessionVersion: number, signal?: AbortSignal): Promise<Todo> {
|
|
341
|
+
const config: SessionRequestConfig = { _accountId: accountId, _sessionVersion: sessionVersion, signal };
|
|
342
|
+
const { data } = await apiClient.get<Todo>(`/todos/${id}`, config);
|
|
343
|
+
return data;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Create a new todo with the given title.
|
|
348
|
+
* Called by: useCreateTodo (src/features/todos/mutations.ts)
|
|
349
|
+
* Cache effect: mutations.ts invalidates todoKeys.lists(accountId) on success.
|
|
350
|
+
*/
|
|
351
|
+
export async function createTodo(input: CreateTodoInput, accountId: string | null, sessionVersion: number): Promise<Todo> {
|
|
352
|
+
const config: SessionRequestConfig = { _accountId: accountId, _sessionVersion: sessionVersion };
|
|
353
|
+
const { data } = await apiClient.post<Todo>('/todos', input, config);
|
|
354
|
+
return data;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Toggle the completed flag on a todo.
|
|
359
|
+
* Called by: useToggleTodo (src/features/todos/mutations.ts)
|
|
360
|
+
* Cache effect: mutations.ts optimistically patches todoKeys.lists(accountId) + todoKeys.detail(accountId, id).
|
|
361
|
+
*/
|
|
362
|
+
export async function toggleTodo(id: string, accountId: string | null, sessionVersion: number): Promise<Todo> {
|
|
363
|
+
const config: SessionRequestConfig = { _accountId: accountId, _sessionVersion: sessionVersion };
|
|
364
|
+
const { data } = await apiClient.patch<Todo>(`/todos/${id}/toggle`, undefined, config);
|
|
365
|
+
return data;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Permanently delete a todo.
|
|
370
|
+
* Called by: useDeleteTodo (src/features/todos/mutations.ts)
|
|
371
|
+
* Cache effect: mutations.ts invalidates todoKeys.lists(accountId) and removes todoKeys.detail(accountId, id).
|
|
372
|
+
*/
|
|
373
|
+
export async function deleteTodo(id: string, accountId: string | null, sessionVersion: number): Promise<void> {
|
|
374
|
+
const config: SessionRequestConfig = { _accountId: accountId, _sessionVersion: sessionVersion };
|
|
375
|
+
await apiClient.delete(`/todos/${id}`, config);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ============================================================================
|
|
379
|
+
// src/features/todos/queries.ts
|
|
380
|
+
// ============================================================================
|
|
381
|
+
// Read hooks — server-state cache layer built on TanStack Query.
|
|
382
|
+
// These hooks are what screens import. They never expose axios internals.
|
|
383
|
+
|
|
384
|
+
import { useQuery } from '@tanstack/react-query';
|
|
385
|
+
import { useAuthStore } from '@store/authStore';
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Centralised query key factory.
|
|
389
|
+
* Shape: [account/tenant, operation, ...params] — never a URL.
|
|
390
|
+
* Shared with mutations.ts so invalidation references the exact same keys.
|
|
391
|
+
*/
|
|
392
|
+
export const todoKeys = {
|
|
393
|
+
all: (accountId: string | null) => ['account', accountId, 'todos'] as const,
|
|
394
|
+
lists: (accountId: string | null) => [...todoKeys.all(accountId), 'list'] as const,
|
|
395
|
+
detail: (accountId: string | null, id: string) => [...todoKeys.all(accountId), 'detail', id] as const,
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Fetch and cache the full todo list.
|
|
400
|
+
*
|
|
401
|
+
* Behaviour:
|
|
402
|
+
* - On mount: returns cached data immediately (zero-latency render), then
|
|
403
|
+
* triggers a background fetch when data is older than staleTime (SWR).
|
|
404
|
+
* - On cache miss: fetches and caches, then returns.
|
|
405
|
+
* - After app restart: MMKV persister restores the cache; stale check runs.
|
|
406
|
+
*/
|
|
407
|
+
export function useTodosQuery() {
|
|
408
|
+
const accountId = useAuthStore((state) => state.accountId);
|
|
409
|
+
const sessionVersion = useAuthStore((state) => state.sessionVersion);
|
|
410
|
+
return useQuery({
|
|
411
|
+
queryKey: todoKeys.lists(accountId),
|
|
412
|
+
queryFn: ({ signal }) => fetchTodos(accountId, sessionVersion, signal),
|
|
413
|
+
enabled: Boolean(accountId),
|
|
414
|
+
staleTime: 60_000, // 1 minute
|
|
415
|
+
gcTime: 1000 * 60 * 60 * 24, // 24h — must stay >= the persister maxAge
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Fetch and cache a single todo by ID.
|
|
421
|
+
* Disabled when `id` is falsy to avoid spurious requests during navigation setup.
|
|
422
|
+
*/
|
|
423
|
+
export function useTodoDetailQuery(id: string) {
|
|
424
|
+
const accountId = useAuthStore((state) => state.accountId);
|
|
425
|
+
const sessionVersion = useAuthStore((state) => state.sessionVersion);
|
|
426
|
+
return useQuery({
|
|
427
|
+
queryKey: todoKeys.detail(accountId, id),
|
|
428
|
+
queryFn: ({ signal }) => fetchTodo(id, accountId, sessionVersion, signal),
|
|
429
|
+
staleTime: 60_000,
|
|
430
|
+
gcTime: 1000 * 60 * 60 * 24, // 24h — see useTodosQuery
|
|
431
|
+
enabled: Boolean(accountId && id),
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// ============================================================================
|
|
436
|
+
// src/features/todos/mutations.ts
|
|
437
|
+
// ============================================================================
|
|
438
|
+
// Write hooks — useMutation wrappers that call api functions and invalidate cache.
|
|
439
|
+
// Rule: EVERY mutation invalidates all affected query keys once it settles.
|
|
440
|
+
|
|
441
|
+
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
442
|
+
import { useAuthStore } from '@store/authStore';
|
|
443
|
+
|
|
444
|
+
// A paused/optimistic mutation from an old provider must not use a new login.
|
|
445
|
+
function runAccountMutation<T>(accountId: string | null, version: number, operation: (version: number) => Promise<T>): Promise<T> {
|
|
446
|
+
const current = useAuthStore.getState();
|
|
447
|
+
if (!accountId || current.accountId !== accountId || current.sessionVersion !== version) {
|
|
448
|
+
return Promise.reject(new Error('Mutation belongs to a retired session'));
|
|
449
|
+
}
|
|
450
|
+
return operation(version);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** Create a new todo, then invalidate the list cache. */
|
|
454
|
+
export function useCreateTodo() {
|
|
455
|
+
const accountId = useAuthStore((state) => state.accountId);
|
|
456
|
+
const sessionVersion = useAuthStore((state) => state.sessionVersion);
|
|
457
|
+
const qc = useQueryClient();
|
|
458
|
+
|
|
459
|
+
return useMutation({
|
|
460
|
+
mutationFn: (input: CreateTodoInput) => runAccountMutation(accountId, sessionVersion, (version) => createTodo(input, accountId, version)),
|
|
461
|
+
onSuccess: () => {
|
|
462
|
+
// Force the list to refetch on next mount — the new todo must appear.
|
|
463
|
+
qc.invalidateQueries({ queryKey: todoKeys.lists(accountId) });
|
|
464
|
+
},
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Toggle a todo's completed state.
|
|
470
|
+
* Uses an optimistic update for instant visual feedback; rolls back on error.
|
|
471
|
+
*/
|
|
472
|
+
export function useToggleTodo() {
|
|
473
|
+
const accountId = useAuthStore((state) => state.accountId);
|
|
474
|
+
const sessionVersion = useAuthStore((state) => state.sessionVersion);
|
|
475
|
+
const qc = useQueryClient();
|
|
476
|
+
|
|
477
|
+
return useMutation({
|
|
478
|
+
mutationFn: (id: string) => runAccountMutation(accountId, sessionVersion, (version) => toggleTodo(id, accountId, version)),
|
|
479
|
+
// Optimistic: flip the completed flag in-cache before the network call.
|
|
480
|
+
onMutate: async (id) => {
|
|
481
|
+
// Cancel in-flight queries for both keys so they can't clobber our patch.
|
|
482
|
+
await qc.cancelQueries({ queryKey: todoKeys.lists(accountId) });
|
|
483
|
+
await qc.cancelQueries({ queryKey: todoKeys.detail(accountId, id) });
|
|
484
|
+
|
|
485
|
+
const previousList = qc.getQueryData<Todo[]>(todoKeys.lists(accountId));
|
|
486
|
+
const previousDetail = qc.getQueryData<Todo>(todoKeys.detail(accountId, id));
|
|
487
|
+
|
|
488
|
+
// Patch each cache entry only when it exists — returning `old` untouched
|
|
489
|
+
// when undefined avoids materialising a fake empty list.
|
|
490
|
+
qc.setQueryData<Todo[] | undefined>(todoKeys.lists(accountId), (old) =>
|
|
491
|
+
old ? old.map((t) => (t.id === id ? { ...t, completed: !t.completed } : t)) : old,
|
|
492
|
+
);
|
|
493
|
+
qc.setQueryData<Todo | undefined>(todoKeys.detail(accountId, id), (old) =>
|
|
494
|
+
old ? { ...old, completed: !old.completed } : old,
|
|
495
|
+
);
|
|
496
|
+
|
|
497
|
+
// Return snapshots for rollback.
|
|
498
|
+
return { previousList, previousDetail };
|
|
499
|
+
},
|
|
500
|
+
onError: (_err, id, context) => {
|
|
501
|
+
// Roll back both keys on failure.
|
|
502
|
+
if (context?.previousList !== undefined) {
|
|
503
|
+
qc.setQueryData(todoKeys.lists(accountId), context.previousList);
|
|
504
|
+
}
|
|
505
|
+
if (context?.previousDetail !== undefined) {
|
|
506
|
+
qc.setQueryData(todoKeys.detail(accountId, id), context.previousDetail);
|
|
507
|
+
}
|
|
508
|
+
},
|
|
509
|
+
onSettled: (_data, _err, id) => {
|
|
510
|
+
// Reconcile with server truth for both keys once the mutation settles.
|
|
511
|
+
qc.invalidateQueries({ queryKey: todoKeys.lists(accountId) });
|
|
512
|
+
qc.invalidateQueries({ queryKey: todoKeys.detail(accountId, id) });
|
|
513
|
+
},
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** Delete a todo, then invalidate the list and remove the detail entry. */
|
|
518
|
+
export function useDeleteTodo() {
|
|
519
|
+
const accountId = useAuthStore((state) => state.accountId);
|
|
520
|
+
const sessionVersion = useAuthStore((state) => state.sessionVersion);
|
|
521
|
+
const qc = useQueryClient();
|
|
522
|
+
|
|
523
|
+
return useMutation({
|
|
524
|
+
mutationFn: (id: string) => runAccountMutation(accountId, sessionVersion, (version) => deleteTodo(id, accountId, version)),
|
|
525
|
+
onSuccess: (_data, id) => {
|
|
526
|
+
qc.invalidateQueries({ queryKey: todoKeys.lists(accountId) });
|
|
527
|
+
// The detail entry is permanently invalid — remove it rather than refetch.
|
|
528
|
+
qc.removeQueries({ queryKey: todoKeys.detail(accountId, id) });
|
|
529
|
+
},
|
|
530
|
+
});
|
|
531
|
+
}
|