sanook-cli 0.4.0 → 0.5.1
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/.env.example +19 -0
- package/CHANGELOG.md +173 -0
- package/README.md +153 -20
- package/README.th.md +136 -0
- package/dist/agentContext.js +4 -0
- package/dist/approval.js +6 -0
- package/dist/bin.js +405 -57
- package/dist/brain.js +92 -59
- package/dist/brand.js +47 -0
- package/dist/checkpoint.js +37 -0
- package/dist/commands.js +86 -6
- package/dist/compaction.js +76 -5
- package/dist/config.js +100 -12
- package/dist/cost.js +60 -3
- package/dist/doctor.js +92 -0
- package/dist/gateway/auth.js +2 -2
- package/dist/gateway/ledger.js +2 -2
- package/dist/gateway/scheduler.js +1 -0
- package/dist/gateway/serve.js +6 -4
- package/dist/gateway/server.js +10 -2
- package/dist/git.js +11 -2
- package/dist/hooks.js +43 -17
- package/dist/knowledge.js +48 -49
- package/dist/loop.js +182 -66
- package/dist/lsp/client.js +173 -0
- package/dist/lsp/framing.js +56 -0
- package/dist/lsp/index.js +138 -0
- package/dist/lsp/servers.js +82 -0
- package/dist/mcp-server.js +244 -0
- package/dist/mcp.js +184 -29
- package/dist/memory-store.js +559 -0
- package/dist/memory.js +143 -29
- package/dist/orchestrate.js +150 -0
- package/dist/providers/codex.js +21 -7
- package/dist/providers/keys.js +3 -2
- package/dist/providers/models.js +22 -6
- package/dist/providers/registry.js +155 -1
- package/dist/repomap.js +93 -0
- package/dist/search/chunk.js +158 -0
- package/dist/search/embed-store.js +187 -0
- package/dist/search/engine.js +203 -0
- package/dist/search/fuse.js +35 -0
- package/dist/search/index-core.js +187 -0
- package/dist/search/indexer.js +241 -0
- package/dist/search/store.js +77 -0
- package/dist/session.js +42 -8
- package/dist/skill-install.js +10 -10
- package/dist/skills.js +12 -9
- package/dist/summarize.js +31 -0
- package/dist/tools/bash.js +21 -2
- package/dist/tools/diagnostics.js +41 -0
- package/dist/tools/edit.js +29 -7
- package/dist/tools/index.js +8 -1
- package/dist/tools/list.js +7 -2
- package/dist/tools/permission.js +90 -9
- package/dist/tools/read.js +23 -4
- package/dist/tools/remember.js +1 -1
- package/dist/tools/sandbox.js +61 -0
- package/dist/tools/search.js +105 -4
- package/dist/tools/task.js +195 -29
- package/dist/tools/timeout.js +35 -0
- package/dist/tools/util.js +10 -0
- package/dist/tools/write.js +6 -4
- package/dist/trust.js +89 -0
- package/dist/ui/app.js +228 -31
- package/dist/ui/banner.js +4 -9
- package/dist/ui/brain-wizard.js +2 -2
- package/dist/ui/history.js +30 -0
- package/dist/ui/mentions.js +44 -0
- package/dist/ui/render.js +55 -15
- package/dist/ui/setup.js +97 -12
- package/dist/ui/useEditor.js +83 -0
- package/dist/update.js +114 -0
- package/dist/worktree.js +173 -0
- package/package.json +11 -5
- package/scripts/postinstall.mjs +33 -0
- package/second-brain/.agents/_Index.md +30 -0
- package/second-brain/.agents/skills/_Index.md +30 -0
- package/second-brain/.agents/workflows/_Index.md +30 -0
- package/second-brain/AGENTS.md +4 -4
- package/second-brain/Acceptance/_Index.md +30 -0
- package/second-brain/Acceptance/golden-case-template.md +39 -0
- package/second-brain/Areas/_Index.md +30 -0
- package/second-brain/Bugs/System-OS/_Index.md +30 -0
- package/second-brain/Bugs/_Index.md +30 -0
- package/second-brain/CLAUDE.md +4 -1
- package/second-brain/Checklists/_Index.md +30 -0
- package/second-brain/Checklists/preflight-postflight-template.md +29 -0
- package/second-brain/Distillations/_Index.md +30 -0
- package/second-brain/Entities/_Index.md +30 -0
- package/second-brain/Entities/entity-template.md +33 -0
- package/second-brain/Evals/_Index.md +30 -0
- package/second-brain/Evals/correction-pairs.md +24 -0
- package/second-brain/Evals/failure-taxonomy.md +24 -0
- package/second-brain/Evals/golden-set.md +25 -0
- package/second-brain/Evals/quality-ledger.md +23 -0
- package/second-brain/Evals/self-eval-rubric.md +23 -0
- package/second-brain/GEMINI.md +4 -4
- package/second-brain/Goals/_Index.md +30 -0
- package/second-brain/Handoffs/_Index.md +30 -0
- package/second-brain/Home.md +7 -0
- package/second-brain/Intake/Raw Sources/_Index.md +30 -0
- package/second-brain/Intake/_Index.md +30 -0
- package/second-brain/Intake/_Quarantine/_Index.md +30 -0
- package/second-brain/Learning/_Index.md +30 -0
- package/second-brain/Playbooks/_Index.md +30 -0
- package/second-brain/Playbooks/playbook-template.md +23 -0
- package/second-brain/Projects/_Index.md +30 -0
- package/second-brain/Prompts/_Index.md +30 -0
- package/second-brain/README.md +2 -1
- package/second-brain/Research/_Index.md +30 -0
- package/second-brain/Retrospectives/_Index.md +30 -0
- package/second-brain/Reviews/_Index.md +30 -0
- package/second-brain/Runbooks/_Index.md +30 -0
- package/second-brain/Runbooks/eval-loop.md +24 -0
- package/second-brain/Sessions/_Index.md +30 -0
- package/second-brain/Shared/AI-Context-Index.md +20 -0
- package/second-brain/Shared/AI-Threads/_Index.md +30 -0
- package/second-brain/Shared/Archive/_Index.md +30 -0
- package/second-brain/Shared/Assets/_Index.md +30 -0
- package/second-brain/Shared/Context-Packs/_Index.md +30 -0
- package/second-brain/Shared/Context7-Docs/_Index.md +30 -0
- package/second-brain/Shared/Coordination/NOW.md +28 -0
- package/second-brain/Shared/Coordination/_Index.md +30 -0
- package/second-brain/Shared/Coordination/agent-registry.md +24 -0
- package/second-brain/Shared/Coordination/task-board/_Index.md +30 -0
- package/second-brain/Shared/Coordination/task-board/task-template.md +43 -0
- package/second-brain/Shared/Coordination/task-board.md +32 -0
- package/second-brain/Shared/Core-Facts/_Index.md +30 -0
- package/second-brain/Shared/Decision-Memory/_Index.md +30 -0
- package/second-brain/Shared/Glossary/_Index.md +30 -0
- package/second-brain/Shared/Memory-Inbox/_Index.md +30 -0
- package/second-brain/Shared/Operating-State/_Index.md +30 -0
- package/second-brain/Shared/Prompting/_Index.md +30 -0
- package/second-brain/Shared/Provenance/_Index.md +30 -0
- package/second-brain/Shared/Rules/_Index.md +30 -0
- package/second-brain/Shared/Rules/contextual-note-rule.md +30 -0
- package/second-brain/Shared/Rules/frontmatter-standard.md +10 -0
- package/second-brain/Shared/Rules/memory-write-protocol.md +28 -0
- package/second-brain/Shared/Rules/procedural-runbook-header.md +40 -0
- package/second-brain/Shared/Rules/review-and-staleness-policy.md +22 -0
- package/second-brain/Shared/Rules/rules-formatting.md +34 -0
- package/second-brain/Shared/Scripts/_Index.md +30 -0
- package/second-brain/Shared/Scripts-Archive/_Index.md +30 -0
- package/second-brain/Shared/Tech-Standards/_Index.md +30 -0
- package/second-brain/Shared/Tech-Standards/verification-standard.md +40 -0
- package/second-brain/Shared/User-Memory/_Index.md +30 -0
- package/second-brain/Shared/User-Persona/_Index.md +30 -0
- package/second-brain/Shared/User-Persona/owner-profile.md +25 -0
- package/second-brain/Shared/Working-Memory/_Index.md +30 -0
- package/second-brain/Shared/_Index.md +30 -0
- package/second-brain/Shared/mcp-servers/_Index.md +30 -0
- package/second-brain/Skills/_Index.md +30 -0
- package/second-brain/Templates/_Index.md +30 -0
- package/second-brain/Templates/bug.md +2 -0
- package/second-brain/Templates/handoff.md +2 -0
- package/second-brain/Templates/session.md +2 -0
- package/second-brain/Tools/_Index.md +30 -0
- package/second-brain/Traces/_Index.md +30 -0
- package/second-brain/Vault Structure Map.md +33 -1
- package/second-brain/copilot/_Index.md +30 -0
- package/skills/audit-license-compliance/SKILL.md +117 -0
- package/skills/author-codemod/SKILL.md +110 -0
- package/skills/build-audit-logging/SKILL.md +112 -0
- package/skills/build-cdc-streaming-pipeline/SKILL.md +123 -0
- package/skills/build-cli-tool/SKILL.md +108 -0
- package/skills/build-data-table/SKILL.md +141 -0
- package/skills/build-native-mobile-ui/SKILL.md +154 -0
- package/skills/build-offline-first-sync/SKILL.md +118 -0
- package/skills/build-realtime-channel/SKILL.md +122 -0
- package/skills/build-vector-search/SKILL.md +131 -0
- package/skills/compose-local-dev-stack/SKILL.md +149 -0
- package/skills/configure-bundler-build/SKILL.md +166 -0
- package/skills/configure-dns-tls/SKILL.md +142 -0
- package/skills/configure-reverse-proxy-lb/SKILL.md +129 -0
- package/skills/configure-security-headers-csp/SKILL.md +122 -0
- package/skills/contract-testing/SKILL.md +140 -0
- package/skills/datetime-timezone-correctness/SKILL.md +125 -0
- package/skills/debug-ci-pipeline-failure/SKILL.md +134 -0
- package/skills/debug-flaky-tests/SKILL.md +128 -0
- package/skills/defend-llm-prompt-injection/SKILL.md +110 -0
- package/skills/deliver-webhooks/SKILL.md +116 -0
- package/skills/design-api-pagination/SKILL.md +144 -0
- package/skills/design-authorization-model/SKILL.md +119 -0
- package/skills/design-backup-dr-recovery/SKILL.md +113 -0
- package/skills/design-event-sourcing-cqrs/SKILL.md +143 -0
- package/skills/design-multi-tenancy/SKILL.md +100 -0
- package/skills/design-protobuf-grpc-service/SKILL.md +146 -0
- package/skills/design-relational-schema/SKILL.md +129 -0
- package/skills/design-search-index-infra/SKILL.md +151 -0
- package/skills/design-state-machine/SKILL.md +108 -0
- package/skills/design-token-system/SKILL.md +109 -0
- package/skills/distributed-locks-leases/SKILL.md +120 -0
- package/skills/encrypt-sensitive-data/SKILL.md +148 -0
- package/skills/feature-flags-rollout/SKILL.md +130 -0
- package/skills/file-upload-object-storage/SKILL.md +107 -0
- package/skills/fuzz-dynamic-security-test/SKILL.md +111 -0
- package/skills/harden-llm-app-reliability/SKILL.md +126 -0
- package/skills/i18n-localization-setup/SKILL.md +113 -0
- package/skills/idempotency-keys/SKILL.md +107 -0
- package/skills/implement-push-notifications/SKILL.md +142 -0
- package/skills/ingest-webhook-secure/SKILL.md +120 -0
- package/skills/integrate-oauth-oidc/SKILL.md +126 -0
- package/skills/load-stress-test/SKILL.md +129 -0
- package/skills/map-privacy-data-gdpr/SKILL.md +146 -0
- package/skills/model-nosql-data/SKILL.md +118 -0
- package/skills/money-decimal-arithmetic/SKILL.md +123 -0
- package/skills/monitor-ml-drift/SKILL.md +109 -0
- package/skills/numeric-precision-units/SKILL.md +144 -0
- package/skills/optimize-llm-cost-latency/SKILL.md +103 -0
- package/skills/optimize-react-rerenders/SKILL.md +124 -0
- package/skills/orchestrate-agent-workflow/SKILL.md +100 -0
- package/skills/payments-billing-integration/SKILL.md +114 -0
- package/skills/pin-toolchain-versions/SKILL.md +116 -0
- package/skills/plan-strangler-migration/SKILL.md +95 -0
- package/skills/property-based-testing/SKILL.md +108 -0
- package/skills/publish-package-registry/SKILL.md +130 -0
- package/skills/recover-git-state/SKILL.md +119 -0
- package/skills/remediate-web-vulnerabilities/SKILL.md +125 -0
- package/skills/resilience-timeouts-retries/SKILL.md +104 -0
- package/skills/resolve-merge-rebase-conflict/SKILL.md +97 -0
- package/skills/rewrite-git-history/SKILL.md +109 -0
- package/skills/scaffold-cross-platform-app/SKILL.md +137 -0
- package/skills/schema-evolution-compatibility/SKILL.md +121 -0
- package/skills/send-transactional-email/SKILL.md +126 -0
- package/skills/serve-deploy-ml-model/SKILL.md +107 -0
- package/skills/setup-cdn-edge-waf/SKILL.md +107 -0
- package/skills/setup-devcontainer-env/SKILL.md +131 -0
- package/skills/setup-lint-format-precommit/SKILL.md +140 -0
- package/skills/setup-monorepo-tooling/SKILL.md +125 -0
- package/skills/ship-mobile-app-store-release/SKILL.md +137 -0
- package/skills/structured-output-llm/SKILL.md +86 -0
- package/skills/supply-chain-sbom-provenance/SKILL.md +120 -0
- package/skills/test-data-factories/SKILL.md +158 -0
- package/skills/threat-model-stride/SKILL.md +123 -0
- package/skills/train-evaluate-ml-model/SKILL.md +109 -0
- package/skills/unicode-text-correctness/SKILL.md +109 -0
- package/skills/visual-regression-testing/SKILL.md +120 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: build-cli-tool
|
|
3
|
+
description: Designs the UX and contract of a command-line program in any language — argument parsing via a real lib (commander/yargs, click/typer, cobra, clap), meaningful exit codes, the stdout=data / stderr=logs split so the tool pipes cleanly, TTY-aware color/spinners that auto-plain when redirected, a --json machine mode, layered config precedence, signal cleanup, and shell completion. Covers the whole interface contract that makes a CLI scriptable, composable, and safe — not the language-internal logic.
|
|
4
|
+
when_to_use: Building a new CLI/terminal program or fixing one that misbehaves in pipes, CI, or non-TTY contexts (logs on stdout, colors in files, wrong exit codes, secrets in flags). Distinct from shell-script-robust (writing a robust Bash script — set -euo pipefail, quoting, traps; this skill DESIGNS the CLI program/UX in any language) and publish-package-registry (PUBLISHING the finished tool to npm/PyPI/crates; this skill DESIGNS it).
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## When to Use
|
|
8
|
+
|
|
9
|
+
- "I'm writing a CLI — how should I structure subcommands, flags, and help?"
|
|
10
|
+
- "My tool breaks when I pipe it (`tool | jq`) or redirect to a file — output is garbled / has color codes."
|
|
11
|
+
- "CI can't tell why my command failed — every error exits 1."
|
|
12
|
+
- "I need a `--json` mode so scripts can parse my output."
|
|
13
|
+
- "Colors/spinners show up in log files but shouldn't" / "respect `NO_COLOR`."
|
|
14
|
+
- "How do I take a secret without it leaking in `ps` / shell history?"
|
|
15
|
+
- "Add shell completion / a `--dry-run` / proper Ctrl-C cleanup."
|
|
16
|
+
|
|
17
|
+
NOT this skill:
|
|
18
|
+
- Writing a robust **Bash** script (strict mode, quoting, trap cleanup) → **shell-script-robust** (that's a shell *implementation*; this is CLI *interface design* in any language).
|
|
19
|
+
- **Publishing** the built tool to npm/PyPI/crates (bin field, OIDC, semver) → **publish-package-registry**.
|
|
20
|
+
- The exact **wording** of a failure string (what/why/next) → **error-message** (use it for message copy; this skill decides the *channel* and *exit code*).
|
|
21
|
+
- Choosing **names** for commands/flags/config keys → **naming-helper**.
|
|
22
|
+
- Hardening the language-internal correctness (concurrency, types, money math) → the respective domain skills.
|
|
23
|
+
|
|
24
|
+
## Steps
|
|
25
|
+
|
|
26
|
+
The contract in one line: **stdout = data, stderr = everything else, exit code = the verdict.** Get those three right and the tool composes with Unix.
|
|
27
|
+
|
|
28
|
+
1. **Pick a parser library, never hand-roll.** Hand-rolled `process.argv` parsing misses `--`, `=`, bundled short flags, and negation. Use the idiomatic one:
|
|
29
|
+
|
|
30
|
+
| Lang | Library | Notes |
|
|
31
|
+
|---|---|---|
|
|
32
|
+
| Node | **commander** (simple) / **yargs** (rich) / **clipanion** (class-based, typed) | commander for most; yargs for middleware/completion |
|
|
33
|
+
| Python | **typer** (type-hint driven) / **click** / `argparse` (stdlib, zero-dep) | typer = click + types; argparse if no deps allowed |
|
|
34
|
+
| Go | **cobra** (+ pflag/viper) | kubectl/gh use it; gives completion + config for free |
|
|
35
|
+
| Rust | **clap** (derive) | derive macro → struct = the CLI |
|
|
36
|
+
|
|
37
|
+
Define subcommands (`tool sync`, `tool config get`), flags with **both short and long** (`-v/--verbose`), positionals, and let the lib handle `--` (everything after it is a positional, never a flag — so `rm -- -weird-file`). Support `--flag=value` and `--flag value`.
|
|
38
|
+
|
|
39
|
+
2. **Generate `--help` and include examples + a one-line summary.** Every command and subcommand needs `--help`; the lib auto-generates usage from the spec — your job is to add a one-line summary and **real examples** (most help is useless without them):
|
|
40
|
+
```
|
|
41
|
+
sync — mirror a local dir to remote storage
|
|
42
|
+
|
|
43
|
+
Usage: tool sync [options] <src> <dest>
|
|
44
|
+
Examples:
|
|
45
|
+
tool sync ./build s3://bucket/site # one-shot
|
|
46
|
+
tool sync --dry-run ./build s3://... # preview, no writes
|
|
47
|
+
```
|
|
48
|
+
Provide `--version` (print version + exit 0). Unknown flag → usage error on **stderr** + exit 2, not a stack trace.
|
|
49
|
+
|
|
50
|
+
3. **Define exit codes that mean something.** Scripts and CI branch on `$?`. Don't return 1 for everything:
|
|
51
|
+
|
|
52
|
+
| Code | Meaning |
|
|
53
|
+
|---|---|
|
|
54
|
+
| 0 | success |
|
|
55
|
+
| 1 | generic/expected failure (operation didn't succeed) |
|
|
56
|
+
| 2 | **usage error** (bad flag/arg) — convention; argparse/clap use it |
|
|
57
|
+
| 3+ | distinct codes per failure class (e.g. 3 = network, 4 = auth, 5 = not-found) — document them |
|
|
58
|
+
| 130 | interrupted by SIGINT (128 + 2); 143 for SIGTERM (128 + 15) |
|
|
59
|
+
|
|
60
|
+
Document the table in `--help` or the README so callers can `case $? in ...`.
|
|
61
|
+
|
|
62
|
+
4. **Enforce stdout=data / stderr=logs (the cardinal rule).** Primary results → **stdout**. Logs, progress, spinners, prompts, warnings, errors → **stderr**. This is what makes `tool | jq`, `tool > out.json`, and `tool 2>/dev/null` work. **Never** print a log line, banner, or "✓ done" to stdout — it corrupts the data stream. A `--quiet` run with a clean pipe should emit *only* the payload on stdout.
|
|
63
|
+
|
|
64
|
+
5. **Detect TTY; degrade gracefully when not interactive.** Color, spinners, progress bars, and interactive prompts are only valid on a terminal. Check before emitting them:
|
|
65
|
+
- Node: `process.stdout.isTTY` / `process.stderr.isTTY`
|
|
66
|
+
- Python: `sys.stdout.isatty()`
|
|
67
|
+
- Go: `term.IsTerminal(int(os.Stdout.Fd()))`
|
|
68
|
+
|
|
69
|
+
Piped/redirected (not a TTY) → auto-plain: no ANSI, no spinner, no prompt (instead error: "stdin is not a tty; pass --yes or --input"). Honor env + flag precedence for color: **`--color=never` > `NO_COLOR` (any value disables) > `--color=always`/`FORCE_COLOR` > `--color=auto` (default: color only if stdout isTTY).**
|
|
70
|
+
|
|
71
|
+
6. **Add a `--json` / machine-readable mode.** Human tables for the TTY, structured output for scripts. `--json` emits one JSON document (or NDJSON per record for streams) to stdout, *nothing else* — no log noise, no color. This is more robust than asking users to `grep`/`awk` your pretty output. Keep the schema stable; version it if it may change.
|
|
72
|
+
|
|
73
|
+
7. **Stream output; don't buffer huge results.** Write records as you produce them (NDJSON line-by-line, or flush rows incrementally) so `tool export | head` exits fast and memory stays flat on large datasets. Buffering everything then printing at the end breaks `head`/`less` and OOMs on big runs.
|
|
74
|
+
|
|
75
|
+
8. **Layer config with a documented precedence.** Highest wins, document the order:
|
|
76
|
+
```
|
|
77
|
+
CLI flags > env vars > project config (./.toolrc) > user config (~/.config/tool/config.toml) > built-in defaults
|
|
78
|
+
```
|
|
79
|
+
viper (Go), a small merge (Node/Python), or click's `auto_envvar_prefix` give this. Print the resolved source on `--verbose` so users can debug "why is this value set?".
|
|
80
|
+
|
|
81
|
+
9. **Never accept secrets as CLI flags.** `--password hunter2` leaks into `ps aux`, shell history, and CI logs. Accept secrets via **env var** (`TOOL_TOKEN`), a **file** (`--token-file`), or **stdin** (`--password-stdin`, like `docker login`). If a flag like `--token` must exist, mark it deprecated and warn on use.
|
|
82
|
+
|
|
83
|
+
10. **Handle signals and clean up.** On SIGINT/SIGTERM: remove temp files, restore terminal state (cursor, raw mode, `\e[?25h` to show cursor), flush partial output, then exit 130/143 — don't leave a half-written file or a hidden cursor. Node: `process.on('SIGINT', cleanup)`; Python: `signal.signal` / `try/finally` + `KeyboardInterrupt`; Go: `signal.NotifyContext`. Make operations idempotent so a re-run after interruption is safe.
|
|
84
|
+
|
|
85
|
+
11. **Verbosity, dry-run, and destructive guards.** Levels: `-q/--quiet` (errors only), default, `-v`, `-vv` (stackable → log level). Destructive actions (`delete`, `reset`, overwrite) require `--dry-run` (print exactly what *would* happen, change nothing) and either an interactive confirm (TTY only) or an explicit `--yes`/`--force` for non-interactive use. Prefer idempotent operations so partial failures are recoverable.
|
|
86
|
+
|
|
87
|
+
12. **Ship shell completion + handle cross-platform.** Generate completion for bash/zsh/fish (cobra/clap/yargs do this; expose `tool completion zsh`). Cross-platform care: use the lib's path join (not hard-coded `/`), write `\n` not `\r\n` to data streams, and on Windows enable ANSI (modern terminals support it; older need a `colorama`-style shim or `FORCE_COLOR`). Distribution is a separate step — `bin` in package.json + npx, `pipx`, a single static binary (Go/Rust), or a Homebrew formula — but PUBLISHING is **publish-package-registry**.
|
|
88
|
+
|
|
89
|
+
## Common Errors
|
|
90
|
+
|
|
91
|
+
- **Logs on stdout.** A `console.log("Done!")` or progress bar to stdout silently corrupts `tool | jq` and `tool > file`. The single most common CLI bug — route all non-data to stderr.
|
|
92
|
+
- **Everything exits 1.** CI can't distinguish "bad input" from "network down". Use distinct codes (Step 3) and 2 for usage errors.
|
|
93
|
+
- **Color codes in files.** Forgetting the isTTY check writes raw `\e[31m` into redirected output. Auto-plain when not a TTY; honor `NO_COLOR`.
|
|
94
|
+
- **Secret in a flag.** `--api-key sk-...` is visible to every user via `ps` and saved in `~/.zsh_history`. Use env/file/stdin (Step 9).
|
|
95
|
+
- **Buffering huge output** then printing at the end → `head` hangs, memory blows up. Stream (Step 7).
|
|
96
|
+
- **No `--` handling** → `tool rm -weird-name` treats the filename as a flag. The parser lib handles `--`; don't hand-roll past it.
|
|
97
|
+
- **Prompting in a non-TTY** → CI hangs forever waiting on stdin. Detect TTY; require `--yes`/`--input` otherwise.
|
|
98
|
+
- **Leaving temp files / a hidden cursor on Ctrl-C** — register signal cleanup (Step 10) before creating temps.
|
|
99
|
+
|
|
100
|
+
## Verify
|
|
101
|
+
|
|
102
|
+
- `tool sub --json | jq .` succeeds and `tool sub > out.txt` produces clean data — **zero** log lines or ANSI in stdout.
|
|
103
|
+
- `tool sub 2>/dev/null` still prints the full payload; `tool sub >/dev/null` still shows progress (proves the stream split).
|
|
104
|
+
- `tool --color=never | cat` has no escape codes; `NO_COLOR=1 tool` is plain; piped output auto-plains without any flag.
|
|
105
|
+
- Bad flag → exit 2 + usage on stderr; a real failure → documented non-zero code; success → 0. `echo $?` after each.
|
|
106
|
+
- Ctrl-C mid-run → exit 130, no temp file left, cursor visible, terminal usable.
|
|
107
|
+
- `ps aux | grep tool` during a run shows **no** secret; `--help` lists examples, exit codes, and config precedence.
|
|
108
|
+
- `tool completion zsh` emits a valid script; a non-TTY run with a destructive command refuses without `--yes`/`--dry-run`.
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: build-data-table
|
|
3
|
+
description: Builds production data grids that stay fast and accessible at 10k–1M+ rows — decide server-side vs client-side sort/filter/paginate by the dataset-fits-in-memory test (client only under ~10k rows, otherwise push to the API and treat the table as a controlled view of server state), ROW-VIRTUALIZE with TanStack Virtual or react-window so only the visible window mounts (fixed estimateSize, overscan 5–10, measureElement for dynamic rows, contain:strict, and a real scroll container — never table-layout:auto over thousands of rows), build the headless logic with TanStack Table v8 (or AG Grid when you need pinning/grouping/enterprise out of the box), add column resize/reorder/pin, inline edit with optimistic update + rollback on error, row selection with a stable rowId, full keyboard nav with roving tabindex over an ARIA grid (role=grid/row/gridcell, aria-sort, aria-rowcount/aria-rowindex so virtualization stays announced), position:sticky headers, streaming CSV export that doesn't block the main thread, and explicit empty/loading-skeleton/error/no-results states.
|
|
4
|
+
when_to_use: Building a sortable/filterable/paginated table, an editable grid, or any list that must render thousands+ of rows without jank — virtualization, column pin/resize/reorder, inline edit, keyboard grid nav, or CSV export. Distinct from build-react-component (scaffolds one component's props/server-vs-client boundary; this is the full grid subsystem) and design-api-pagination (defines the backend cursor/keyset paging contract; this consumes it for server-side mode) — and from optimize-react-rerenders (fixes wasted renders in general React; this owns the table-specific row-memoization + virtualization).
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## When to Use
|
|
8
|
+
|
|
9
|
+
Reach for this skill when you're building a real data grid, not a static `<table>`:
|
|
10
|
+
|
|
11
|
+
- "Make this table sortable/filterable and paginated" — and decide server vs client
|
|
12
|
+
- "The table janks / freezes scrolling 50k rows" → you need virtualization
|
|
13
|
+
- "Let users resize, reorder, and pin columns; persist the layout"
|
|
14
|
+
- "Inline-edit a cell and save it optimistically with rollback on failure"
|
|
15
|
+
- "Add row selection (checkboxes, select-all-across-pages) and bulk actions"
|
|
16
|
+
- "Make the grid keyboard-navigable and screen-reader accessible (ARIA grid)"
|
|
17
|
+
- "Export the current (filtered/sorted) view to CSV"
|
|
18
|
+
|
|
19
|
+
NOT this skill:
|
|
20
|
+
- Scaffolding a single component's props contract / Server-vs-Client boundary, not a grid subsystem → build-react-component
|
|
21
|
+
- The backend list endpoint's cursor/keyset contract, page_size caps, `{data,next_cursor,has_more}` envelope → design-api-pagination (this skill *consumes* that contract in server-side mode)
|
|
22
|
+
- Generic "why is React re-rendering" wasted-render diagnosis outside the table → optimize-react-rerenders (this owns only the row/cell memoization the grid needs)
|
|
23
|
+
- Wiring TanStack Query caching/mutations/optimistic infra in general → manage-client-server-state (this skill calls into it for the data layer)
|
|
24
|
+
- A spreadsheet with formulas/multi-sheet/cell-range math → build-spreadsheet (a grid is read-mostly tabular UI, not a calc engine)
|
|
25
|
+
- Field-level form rules across a `<form>` (not per-cell inline edit) → build-form-validation
|
|
26
|
+
- Deep WCAG audit of the finished UI → audit-accessibility-wcag (this skill builds the grid a11y baseline it then verifies)
|
|
27
|
+
- Charts/heatmaps from the data → write-data-viz; cleaning/reshaping the rows before display → wrangle-tabular-data
|
|
28
|
+
- Live-updating rows over a socket → build-realtime-channel feeds this grid; merge into rows keyed by stable id
|
|
29
|
+
|
|
30
|
+
## Steps
|
|
31
|
+
|
|
32
|
+
1. **Decide server-side vs client-side FIRST — it changes the whole architecture.** The test is "does the full dataset fit in memory and stay responsive to filter/sort in the browser?"
|
|
33
|
+
|
|
34
|
+
| | Client-side | Server-side |
|
|
35
|
+
|---|---|---|
|
|
36
|
+
| Row count | ≲ 10k (hard ceiling ~50k) | 10k → millions |
|
|
37
|
+
| Sort/filter/paginate | in JS, instant | the API does it; table is a *controlled view* |
|
|
38
|
+
| Source of truth | the loaded array | the server query (sort/filter/page in the request) |
|
|
39
|
+
| TanStack flag | `getSortedRowModel`, `getFilteredRowModel`, `getPaginationRowModel` | `manualSorting/manualFiltering/manualPagination: true` + `pageCount`/`rowCount` |
|
|
40
|
+
|
|
41
|
+
In server-side mode, debounce filter input (~300ms), send `sort`, `filter`, and the **cursor** (from design-api-pagination — keyset, not OFFSET) to the API, and keep table state controlled (`state={{ sorting, columnFilters, pagination }}` + `onSortingChange` etc.). Never load 200k rows to filter client-side "because it's simpler" — it OOMs the tab.
|
|
42
|
+
|
|
43
|
+
2. **Virtualize rows whenever you render more than ~100 at once — this is non-negotiable for big grids.** Mounting 10k `<tr>` nodes blows the DOM budget and kills scroll. Use **TanStack Virtual** (`@tanstack/react-virtual`, framework-agnostic, the default) or **react-window** (lighter, fixed/variable list). Only the visible window + overscan mounts.
|
|
44
|
+
|
|
45
|
+
```tsx
|
|
46
|
+
const rowVirtualizer = useVirtualizer({
|
|
47
|
+
count: rows.length,
|
|
48
|
+
getScrollElement: () => scrollRef.current,
|
|
49
|
+
estimateSize: () => 36, // measured row height in px
|
|
50
|
+
overscan: 8, // render 8 extra each side; smooths fast scroll
|
|
51
|
+
measureElement: el => el.getBoundingClientRect().height, // only if rows vary
|
|
52
|
+
});
|
|
53
|
+
// render: a tall spacer div (totalSize) + absolutely-positioned visible rows
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Rules: give the scroll container a **fixed height** and `overflow:auto`; set `contain: strict` (or `layout paint`) on it; use `transform: translateY()` for row offset, not `top`. Do **not** use a native `<table>` with `table-layout:auto` over thousands of rows — the browser re-measures every column on each row; switch to `display:grid`/explicit `<col>` widths or `table-layout:fixed`. For dynamic row heights, `measureElement` + `data-index`; expect a one-frame jump unless you pre-measure.
|
|
57
|
+
|
|
58
|
+
3. **Build the logic headless with TanStack Table v8; pick AG Grid only when you need its enterprise features turned-key.** TanStack Table is *headless* — it computes models, you render every DOM node (full control, ~14kb, pairs with Virtual). Define columns with `createColumnHelper<Row>()` for type-safe accessors:
|
|
59
|
+
|
|
60
|
+
```tsx
|
|
61
|
+
const col = createColumnHelper<Person>();
|
|
62
|
+
const columns = [
|
|
63
|
+
col.accessor('name', { header: 'Name', enableSorting: true }),
|
|
64
|
+
col.accessor('amount', { cell: c => fmtMoney(c.getValue()), enableColumnFilter: true }),
|
|
65
|
+
];
|
|
66
|
+
const table = useReactTable({ data, columns, getRowId: r => r.id,
|
|
67
|
+
getCoreRowModel: getCoreRowModel() });
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Choose **AG Grid** instead when you need row grouping/tree data, pivoting, built-in column pinning + Excel export, or a million-row server-row-model without hand-rolling it — it ships those, but it's heavier and styling is its own system. Don't reach for a styled mega-component (`<DataGrid>` Material/MUI X) if you need fine control; you'll fight it.
|
|
71
|
+
|
|
72
|
+
4. **Column resize / reorder / pin — wire the table's column features and persist the layout.** TanStack: `enableColumnResizing: true` + `columnResizeMode:'onChange'` (live) vs `'onEnd'` (commit on mouseup, cheaper); read width via `header.getSize()` and apply with CSS vars so resizing doesn't re-render every cell. Reorder = drag the header and reorder `columnOrder` state (use the table's `setColumnOrder`; a `dnd-kit` sortable context for the drag). **Pin** with `column.getIsPinned()` + `position: sticky; left: <accumulated width>; z-index` and a shadow on the last pinned column. Persist `{columnOrder, columnSizing, columnPinning, columnVisibility}` to localStorage (or the user profile) keyed by table id and rehydrate as initial state.
|
|
73
|
+
|
|
74
|
+
5. **Inline edit = optimistic update + rollback, never block on the network.** On commit (Enter / blur), write the new value into the cached rows immediately, fire the mutation, and roll back on error. With TanStack Query:
|
|
75
|
+
|
|
76
|
+
```tsx
|
|
77
|
+
useMutation({ mutationFn: patchCell,
|
|
78
|
+
onMutate: async (next) => {
|
|
79
|
+
await qc.cancelQueries({ queryKey });
|
|
80
|
+
const prev = qc.getQueryData(queryKey);
|
|
81
|
+
qc.setQueryData(queryKey, patch(prev, next)); // optimistic
|
|
82
|
+
return { prev };
|
|
83
|
+
},
|
|
84
|
+
onError: (_e, _v, ctx) => qc.setQueryData(queryKey, ctx.prev), // rollback
|
|
85
|
+
onSettled: () => qc.invalidateQueries({ queryKey }),
|
|
86
|
+
});
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Edit mode: single cell, `aria-readonly` off, focus the input, Esc cancels, Enter commits + moves down, Tab moves right. Validate per-cell before the optimistic write (type/range); show the error inline and keep the old value. This is the per-cell case — multi-field `<form>` rules belong to build-form-validation.
|
|
90
|
+
|
|
91
|
+
6. **Row selection: use a stable `getRowId` and decide select-all semantics.** TanStack `enableRowSelection`, state `rowSelection: Record<rowId, boolean>` — keyed by **your row id**, not the index, so selection survives sort/filter/page. The header checkbox has three states (none/some/all) via `table.getIsSomePageRowsSelected()` → `indeterminate`. Critical decision: "select all" = **current page** or **entire matching dataset**? In server-side mode you can't select rows you haven't loaded — implement "select all N matching" as a *predicate* (the active filter) sent to the bulk endpoint, not a list of ids, and show "All 4,213 selected" with a clear-selection affordance.
|
|
92
|
+
|
|
93
|
+
7. **Keyboard nav + ARIA grid — roving tabindex over a real grid role, virtualization-aware.** A data grid is **one tab stop**: the container/active cell has `tabindex=0`, every other cell `tabindex=-1`; arrow keys move the active cell and move the `0`. Roles: container `role="grid"`, rows `role="row"`, cells `role="gridcell"`, header cells `role="columnheader"` with `aria-sort="ascending|descending|none"`. Because virtualization removes off-screen rows from the DOM, **you must** set `aria-rowcount={totalRows}` on the grid and `aria-rowindex` (1-based, header = 1) on every rendered row, and `aria-colcount`/`aria-colindex` for horizontally virtualized columns — otherwise SRs announce "row 12 of 30" instead of "of 50000". Key map:
|
|
94
|
+
|
|
95
|
+
| Key | Action |
|
|
96
|
+
|---|---|
|
|
97
|
+
| ↑ ↓ ← → | move active cell |
|
|
98
|
+
| Home / End | first / last cell in row |
|
|
99
|
+
| Ctrl+Home / Ctrl+End | first / last cell in grid |
|
|
100
|
+
| PageUp / PageDown | scroll a viewport of rows (and move focus) |
|
|
101
|
+
| Enter / F2 | enter edit mode; Esc exits |
|
|
102
|
+
| Space | toggle row selection |
|
|
103
|
+
|
|
104
|
+
When focus moves to a virtualized row that's scrolled out, call `rowVirtualizer.scrollToIndex(i)` before focusing so the node exists. Sort headers must be operable with Enter/Space and update `aria-sort`. Deep WCAG conformance → audit-accessibility-wcag.
|
|
105
|
+
|
|
106
|
+
8. **Sticky headers (and sticky pinned columns) with `position: sticky`.** Header row: `position: sticky; top: 0; z-index: 2` inside the scroll container (sticky is scoped to the nearest scrolling ancestor — the header must live *inside* the same `overflow:auto` element as the rows, not above it). Pinned column cells: `sticky; left: 0; z-index: 1`; the top-left corner (sticky header + pinned col) needs the higher `z-index`. Give sticky cells an opaque `background` (transparent sticky cells show rows bleeding through) and a bottom/right `box-shadow` so the freeze line reads.
|
|
107
|
+
|
|
108
|
+
9. **CSV export of the *current view*, off the main thread for large sets.** Export reflects the active sort/filter/column-visibility, not the raw data. Build CSV correctly: quote fields containing `, " \n`, double internal quotes (`"a ""b"" c"`), prefix the file with `` (UTF-8 BOM) so Excel reads UTF-8, and **defend against CSV injection** — prefix any cell starting with `= + - @ \t \r` with a `'` (formula-injection in spreadsheets). For server-side / huge datasets, hit a streaming export endpoint (the server pages with the keyset cursor and streams rows) or generate in a Web Worker + `Blob` so a 100k-row export doesn't freeze the UI; trigger download via an object URL.
|
|
109
|
+
|
|
110
|
+
10. **Every grid has four states — design them, don't default to a blank box.** *Loading* → skeleton rows matching column widths (not a centered spinner; preserves layout, no shift). *Empty* (no data exists yet) → illustration + primary action ("Add your first record"). *No results* (filters exclude everything) → "No matches" + a **Clear filters** button (distinct from empty — the fix differs). *Error* → message + Retry that refires the query, keeping prior data visible if you have it. In server-side infinite scroll, show a row-level loading sentinel at the bottom and an error row with retry, not a full-table swap.
|
|
111
|
+
|
|
112
|
+
## Common Errors
|
|
113
|
+
|
|
114
|
+
- **Rendering all rows, then "optimizing" later.** 10k `<tr>` is already janky; virtualize from the start (step 2). Bolting it on after layout/CSS assumes a normal `<table>` is the biggest rewrite.
|
|
115
|
+
- **Client-side sort/filter on a server-scale dataset.** Loading 100k+ rows to filter in JS OOMs the tab and waterfalls. Use `manualSorting/Filtering/Pagination` + the paged API (step 1).
|
|
116
|
+
- **`<table>` with `table-layout:auto` over thousands of rows.** The browser re-measures every column per row → quadratic. Use `table-layout:fixed` / `display:grid` with explicit widths (step 2).
|
|
117
|
+
- **Selection/edit keyed by row index.** Sort or filter and the wrong rows are selected/edited. Key by a stable `getRowId` (steps 5–6).
|
|
118
|
+
- **No `aria-rowcount`/`aria-rowindex` with virtualization.** SR announces the rendered window ("12 of 30"), not the real total. Set them from the full count (step 7).
|
|
119
|
+
- **Every cell in the tab order.** Tabbing through 50 columns × visible rows is unusable. One tab stop + roving tabindex + arrow keys (step 7).
|
|
120
|
+
- **Sticky header outside the scroll container.** `position:sticky` only sticks within its scrolling ancestor — a header above the `overflow:auto` div won't stick. Put it inside (step 8).
|
|
121
|
+
- **Transparent sticky/pinned cells.** Rows show through the frozen header/column. Opaque background + shadow (step 8).
|
|
122
|
+
- **Inline edit that awaits the server before updating UI.** Feels broken on slow networks. Optimistic write + rollback on error (step 5).
|
|
123
|
+
- **CSV without quoting / injection guard.** Commas/newlines corrupt columns; a cell starting `=cmd|...` executes in Excel. Quote + escape + prefix dangerous leading chars + BOM (step 9).
|
|
124
|
+
- **Exporting raw data instead of the current view.** Users expect the filtered/sorted/visible columns they see. Export from the table's current row model (step 9).
|
|
125
|
+
- **One "no data" state for both empty and filtered-out.** Users can't tell "nothing exists" from "filters hide everything." Split them; give no-results a Clear-filters button (step 10).
|
|
126
|
+
- **Re-creating `columns`/`data` inline each render.** New array identity busts memoization and re-runs every row model. Define `columns` module-level or `useMemo`, keep `data` referentially stable (defer deep render perf to optimize-react-rerenders).
|
|
127
|
+
|
|
128
|
+
## Verify
|
|
129
|
+
|
|
130
|
+
1. **Scale:** load the target row count (10k / 100k) and scroll fast top-to-bottom — DOM node count stays bounded (only window + overscan in the inspector), no dropped frames.
|
|
131
|
+
2. **Server mode:** sort/filter/page issue new API requests with the right params (keyset cursor, not OFFSET); the table never holds the full dataset; filter input is debounced.
|
|
132
|
+
3. **Columns:** resize, reorder, pin, hide — layout holds, pinned columns freeze with a shadow, and the layout persists across reload.
|
|
133
|
+
4. **Inline edit:** commit shows the new value instantly; force the mutation to fail → it rolls back to the old value and surfaces an error; Esc cancels, Enter advances.
|
|
134
|
+
5. **Selection:** select rows, then sort/filter/page → the *same* rows stay selected (id-keyed); header checkbox shows indeterminate; "select all matching" sends a predicate, not loaded ids.
|
|
135
|
+
6. **Keyboard:** Tab reaches the grid once; arrows/Home/End/Ctrl+Home move the active cell; focusing a scrolled-out row scrolls it into view first; sort headers fire on Enter/Space.
|
|
136
|
+
7. **A11y:** screen reader announces `role=grid`, column headers with `aria-sort`, and the **real** total via `aria-rowcount` (not the virtualized window); run audit-accessibility-wcag for full conformance.
|
|
137
|
+
8. **Sticky:** header stays pinned on vertical scroll, pinned columns on horizontal scroll, corner z-index correct, no bleed-through.
|
|
138
|
+
9. **Export:** CSV of a filtered+sorted view opens in Excel with UTF-8 intact, fields with commas/quotes/newlines are correct, a `=`-leading cell is neutralized, and a 100k-row export doesn't freeze the tab.
|
|
139
|
+
10. **States:** empty, no-results (with Clear-filters), loading skeleton, and error (with Retry) each render distinctly and the error path recovers.
|
|
140
|
+
|
|
141
|
+
Done = the grid renders the target scale without jank (virtualized, bounded DOM), sort/filter/paginate run server-side for large datasets against the keyset API, columns resize/reorder/pin and persist, inline edit is optimistic with rollback, selection and edit are id-stable, the grid is one keyboard tab stop with a correct ARIA grid (rowcount/rowindex aware of virtualization), headers and pinned columns stick opaquely, CSV export of the current view is correctly quoted + injection-safe, and all four data states are designed — all proven by checks 1–10.
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: build-native-mobile-ui
|
|
3
|
+
description: Builds native mobile UI in SwiftUI (iOS) and Jetpack Compose (Android) — declarative layout (List/LazyVStack vs Scaffold/LazyColumn), unidirectional state with hoisting (@Observable vs ViewModel/StateFlow), typed navigation stacks with deep links, adaptive sizing (size classes/WindowSizeClass), light/dark theming via semantic tokens, lifecycle-correct side effects, recomposition control, and VoiceOver/TalkBack accessibility.
|
|
4
|
+
when_to_use: Implementing or reviewing a native iOS (SwiftUI) or Android (Jetpack Compose) screen/component — lists, forms, custom layouts, state hoisting, typed navigation, dark mode/Dynamic Type, adaptive phone/tablet/foldable, recomposition jank. Distinct from scaffold-cross-platform-app (React Native/Flutter, not native Swift/Kotlin), build-react-component (web/React), and audit-accessibility-wcag (web WCAG audit).
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## When to Use
|
|
8
|
+
|
|
9
|
+
Reach for this skill when building or reviewing a **native** iOS/Android screen in a declarative UI framework (SwiftUI or Jetpack Compose, i.e. Swift/Kotlin — not React Native or Flutter):
|
|
10
|
+
|
|
11
|
+
- "Build a SwiftUI/Compose list-detail screen with pull-to-refresh"
|
|
12
|
+
- "Hoist this view state — the toggle should be controlled by the parent"
|
|
13
|
+
- "Add a NavigationStack / Compose Navigation route with a deep link"
|
|
14
|
+
- "Make this adapt to iPad / foldable / landscape (two-pane on wide)"
|
|
15
|
+
- "Support dark mode + Dynamic Type without truncation"
|
|
16
|
+
- "VoiceOver reads this button wrong / TalkBack skips the row"
|
|
17
|
+
- "This list janks / recomposes the whole screen on every keystroke"
|
|
18
|
+
|
|
19
|
+
NOT this skill:
|
|
20
|
+
- Cross-platform UI in React Native or Flutter (JSX/Dart, Expo Router, Riverpod/Bloc) → scaffold-cross-platform-app (this skill is native Swift/Kotlin only)
|
|
21
|
+
- Web/React components (JSX, hooks, DOM) → build-react-component
|
|
22
|
+
- CSS/Tailwind breakpoints and responsive web layout → style-responsive-tailwind
|
|
23
|
+
- Auditing a web page against WCAG success criteria → audit-accessibility-wcag
|
|
24
|
+
- Server cache, fetching, optimistic mutation, query invalidation → manage-client-server-state (this skill owns *UI* state, not network state)
|
|
25
|
+
- Architecting the token tiers/themes/multi-platform export pipeline → design-token-system (this skill *consumes* tokens in a screen, doesn't design the system)
|
|
26
|
+
- Converting a Figma/design spec into pixel-faithful code → implement-from-design (use this skill for the framework idioms once you have the spec)
|
|
27
|
+
- The server send path, payload schema, or token registration for push → implement-push-notifications (this skill owns the in-app deep-link router push taps land in)
|
|
28
|
+
- Code signing, build lanes, store upload, phased rollout → ship-mobile-app-store-release
|
|
29
|
+
- Profiling/fixing web load metrics (LCP/CLS) → optimize-core-web-vitals
|
|
30
|
+
|
|
31
|
+
## Steps
|
|
32
|
+
|
|
33
|
+
1. **Pick the container primitive by data shape — never default to a plain stack for collections.** Lazy containers virtualize; eager ones build every child up front and jank past ~50 rows.
|
|
34
|
+
|
|
35
|
+
| Need | SwiftUI | Compose |
|
|
36
|
+
|---|---|---|
|
|
37
|
+
| Long/unbounded scrolling list | `List` (free separators, swipe, refresh) or `LazyVStack` in `ScrollView` | `LazyColumn` (with `key = { it.id }`) |
|
|
38
|
+
| Small fixed group (≤ ~20, all visible) | `VStack`/`Form`/`Section` | `Column` |
|
|
39
|
+
| Screen chrome (top bar, FAB, snackbar, insets) | `NavigationStack` + `.toolbar` | `Scaffold(topBar, floatingActionButton, snackbarHost)` |
|
|
40
|
+
| Grid | `LazyVGrid(columns:)` | `LazyVerticalGrid(columns = GridCells.Adaptive(160.dp))` |
|
|
41
|
+
| Overlap / z-stack | `ZStack` | `Box` |
|
|
42
|
+
|
|
43
|
+
**Always set stable item identity** (`List(items, id: \.id)` / `items(list, key = { it.id })`) — without it, scroll position and animations break on reorder.
|
|
44
|
+
|
|
45
|
+
2. **One source of truth, hoisted up; flow data down, events up.** A child that owns the state it renders is unreusable and untestable. Make leaf views *stateless* (value + callback); keep state at the lowest common owner.
|
|
46
|
+
|
|
47
|
+
SwiftUI — child takes `Binding`, owns nothing:
|
|
48
|
+
```swift
|
|
49
|
+
struct ToggleRow: View { // stateless leaf
|
|
50
|
+
let title: String
|
|
51
|
+
@Binding var isOn: Bool
|
|
52
|
+
var body: some View { Toggle(title, isOn: $isOn) }
|
|
53
|
+
}
|
|
54
|
+
// parent owns it:
|
|
55
|
+
@State private var pushEnabled = false
|
|
56
|
+
ToggleRow(title: "Push", isOn: $pushEnabled)
|
|
57
|
+
```
|
|
58
|
+
Compose — hoist with `value` + `onValueChange`, never an internal `remember` for controlled state:
|
|
59
|
+
```kotlin
|
|
60
|
+
@Composable fun ToggleRow(title: String, checked: Boolean, onChecked: (Boolean) -> Unit) {
|
|
61
|
+
Row { Text(title); Switch(checked = checked, onCheckedChange = onChecked) } // stateless
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
| Concern | SwiftUI | Compose |
|
|
66
|
+
|---|---|---|
|
|
67
|
+
| Local ephemeral UI state | `@State` (private) | `var x by remember { mutableStateOf(...) }` |
|
|
68
|
+
| Owned by parent | `@Binding` | `value` + `onValueChange` lambda |
|
|
69
|
+
| Screen/business state, survives config change | `@Observable` class (`@State` at owner) | `ViewModel` + `StateFlow` → `collectAsStateWithLifecycle()` |
|
|
70
|
+
| Survive process death | `@SceneStorage` / `@AppStorage` | `SavedStateHandle` / `rememberSaveable` |
|
|
71
|
+
| DI'd cross-cutting | `@Environment` | `CompositionLocal` / hilt-injected VM |
|
|
72
|
+
|
|
73
|
+
Default: screen state lives in `@Observable` (iOS 17+) / `ViewModel`; the view is a pure function of it. Expose **immutable** state out (`val uiState: StateFlow<UiState>`), accept intents in (`fun onIntent(...)`).
|
|
74
|
+
|
|
75
|
+
3. **Type your navigation — no stringly-typed routes for in-app pushes.** Drive the stack from a state-bound path so back/deep-link/restore are deterministic.
|
|
76
|
+
|
|
77
|
+
SwiftUI:
|
|
78
|
+
```swift
|
|
79
|
+
@State private var path = NavigationPath()
|
|
80
|
+
NavigationStack(path: $path) {
|
|
81
|
+
List(items) { NavigationLink("\($0.name)", value: $0) } // value, not destination view
|
|
82
|
+
.navigationDestination(for: Item.self) { ItemDetail(item: $0) }
|
|
83
|
+
}
|
|
84
|
+
// deep link: path.append(item) — or .onOpenURL { url in route(url, &path) }
|
|
85
|
+
```
|
|
86
|
+
Compose (type-safe routes, nav 2.8+ with `@Serializable` objects):
|
|
87
|
+
```kotlin
|
|
88
|
+
@Serializable data class ItemDetail(val id: String)
|
|
89
|
+
NavHost(nav, startDestination = ItemList) {
|
|
90
|
+
composable<ItemList> { ItemListScreen(onOpen = { nav.navigate(ItemDetail(it.id)) }) }
|
|
91
|
+
composable<ItemDetail>(deepLinks = listOf(navDeepLink<ItemDetail>(basePath = "app://item"))) {
|
|
92
|
+
ItemDetailScreen(it.toRoute<ItemDetail>().id)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
Rules: each tab gets its **own** back stack; restore a saved stack on tab reselect (don't reset to root); a deep link must rebuild the parent stack so Back has somewhere to go.
|
|
97
|
+
|
|
98
|
+
4. **Layout for variable size from the start — respect insets, scale with the user's type setting, branch on width class.** Hardcoded heights and a single phone layout break on Dynamic Type / iPad / foldable.
|
|
99
|
+
- **Safe area / insets:** never hardcode status-bar or notch padding. SwiftUI honors safe area by default — only push to edges with `.ignoresSafeArea()` deliberately and pad content back. Compose: `Scaffold` gives `innerPadding` — apply it; for keyboard use `Modifier.imePadding()` / `windowInsetsPadding(...)`.
|
|
100
|
+
- **Dynamic Type / font scale:** use semantic styles (`.font(.body)` / `MaterialTheme.typography.bodyLarge`), not fixed `pt`/`sp`. Let text wrap; cap with `.lineLimit` + `.minimumScaleFactor(0.8)` only when a hard ceiling exists. Verify at the largest accessibility size.
|
|
101
|
+
- **Adaptive width:** branch on the class, not a raw `375`-px guess. iPad/landscape and wide foldables → two-pane.
|
|
102
|
+
|
|
103
|
+
| | iOS | Android |
|
|
104
|
+
|---|---|---|
|
|
105
|
+
| Read width class | `@Environment(\.horizontalSizeClass)` (`.compact`/`.regular`) | `calculateWindowSizeClass(activity).widthSizeClass` (`Compact`/`Medium`/`Expanded`) |
|
|
106
|
+
| List+detail that adapts | `NavigationSplitView` | two-pane when `Expanded`, single `NavHost` when `Compact` |
|
|
107
|
+
| Breakpoints | compact = phone portrait; regular = iPad/landscape | Compact <600dp · Medium 600–840dp · Expanded ≥840dp |
|
|
108
|
+
|
|
109
|
+
5. **Theme through tokens, not literals — and support dark by deriving, not duplicating.** Reference semantic roles so dark mode is automatic.
|
|
110
|
+
- iOS: use system semantic colors (`Color(.systemBackground)`, `.primary`, `.secondary`, `Color("Brand")` from an Asset Catalog with a Dark variant) — they flip with `@Environment(\.colorScheme)`. Icons: SF Symbols (`Image(systemName: "trash")`) so they match weight/scale.
|
|
111
|
+
- Android: define a `ColorScheme` via Material 3 `lightColorScheme()`/`darkColorScheme()` (or `dynamicColorScheme(context)` for Material You on API 31+), select by `isSystemInDarkTheme()`, expose through `MaterialTheme`. Never read `Color(0xFF...)` literals inside a composable.
|
|
112
|
+
- Never gate logic on the literal color; gate on the token/role. One token table, two schemes derived from it.
|
|
113
|
+
|
|
114
|
+
6. **Accessibility is a build requirement, not a pass.** Every interactive element needs a label + role; targets ≥ 44pt (iOS HIG) / ≥ 48dp (Material). Decorative images get *no* label.
|
|
115
|
+
- iOS: `.accessibilityLabel("Delete")`, `.accessibilityAddTraits(.isButton)`, `.accessibilityHidden(true)` for decoration, group a row with `.accessibilityElement(children: .combine)` so VoiceOver reads it as one unit.
|
|
116
|
+
- Compose: `Modifier.semantics { contentDescription = "Delete" }` (or the param on `Icon`), `contentDescription = null` for decorative `Image`, `Modifier.clearAndSetSemantics {}` to merge a row, `Role.Button`/`Role.Checkbox` via `Modifier.semantics { role = ... }`.
|
|
117
|
+
- Don't override the framework focus order unless reading order is genuinely wrong; tappable area must equal visible-or-larger, never smaller than the touch-target minimum.
|
|
118
|
+
|
|
119
|
+
7. **Lifecycle & side effects: run effects in the right hook, keyed correctly, and stop fighting recomposition.** A composable body / `body` runs *many* times — never do I/O, start timers, or mutate state there.
|
|
120
|
+
- iOS: `.task { await load() }` (auto-cancels on disappear) for async load; `.onAppear`/`.onDisappear` for non-async; `.onChange(of: query) { … }` for reactions. Don't kick network off in `body`.
|
|
121
|
+
- Compose: `LaunchedEffect(key)` for suspend work on enter / when `key` changes; `rememberCoroutineScope()` for event-triggered launches; `DisposableEffect` to register+`onDispose` cleanup; `derivedStateOf` to avoid recomposing on every upstream tick; `produceState` to bridge a callback into state. The **key** must include every input the effect depends on, or it goes stale.
|
|
122
|
+
- Stop needless recomposition: read VM state with `collectAsStateWithLifecycle()`; pass stable/`@Immutable` types and lambdas; hoist heavy reads out of `items{}`; defer rapidly-changing reads (scroll offset) with a lambda (`Modifier.offset { … }`) so only the layout phase reruns. SwiftUI equivalent: split big views so a small `@State` change invalidates a small subtree, give `ForEach` stable ids, mark expensive subviews `Equatable`.
|
|
123
|
+
|
|
124
|
+
8. **Verify on a real simulator/emulator with previews + the accessibility inspectors** (see Verify) before declaring done — previews catch layout, the device catches lifecycle and gesture bugs previews can't.
|
|
125
|
+
|
|
126
|
+
## Common Errors
|
|
127
|
+
|
|
128
|
+
- **`VStack`/`Column` for a long list.** Builds every child eagerly → jank and memory blowup. Use `LazyVStack`/`List` / `LazyColumn`.
|
|
129
|
+
- **No stable item key.** `LazyColumn` without `key=` (or `List` keyed by index) reorders/animates wrong and loses scroll on insert. Key by a stable id.
|
|
130
|
+
- **State owned in the leaf you want to reuse.** Child `@State`/internal `remember` for what the parent should control → can't lift, can't test, drifts out of sync. Hoist: `Binding` / `value`+`onValueChange`.
|
|
131
|
+
- **`remember { mutableStateOf(...) }` for screen state.** Lost on rotation/process death; doesn't survive nav. Put it in a `ViewModel` (or `rememberSaveable` for trivial UI bits).
|
|
132
|
+
- **Collecting flow with `.collectAsState()`** instead of `collectAsStateWithLifecycle()` — keeps collecting in the background, wasting work and risking stale UI. Use the lifecycle-aware one.
|
|
133
|
+
- **Side effect in `body`/composable body.** Network or `mutableStateOf` write during composition → infinite recomposition or duplicate loads. Move to `.task`/`LaunchedEffect`.
|
|
134
|
+
- **Wrong/empty `LaunchedEffect` key.** `LaunchedEffect(Unit)` that reads `id` never reloads when `id` changes; over-keyed restarts constantly. Key on exactly the inputs the effect uses.
|
|
135
|
+
- **Stringly-typed nav routes** (`navigate("detail/$id")` with manual parsing) — typos compile, args lose types, deep links break silently. Use type-safe routes / `value:` + `navigationDestination(for:)`.
|
|
136
|
+
- **Single shared back stack across tabs.** Switching tabs nukes the other tab's history. Give each tab its own `NavHost`/stack and save/restore it.
|
|
137
|
+
- **Hardcoded padding for the notch/status bar / ignoring `innerPadding`.** Content slides under the bar or the keyboard. Honor safe area / apply `Scaffold` `innerPadding` + `imePadding()`.
|
|
138
|
+
- **Fixed font sizes / `.lineLimit(1)` everywhere.** Truncates at large Dynamic Type, fails accessibility. Semantic text styles; allow wrap; scale-factor only as a last resort.
|
|
139
|
+
- **Hardcoded hex colors in views.** Dark mode shows white-on-white. Use semantic colors / `MaterialTheme.colorScheme` tokens with light+dark schemes.
|
|
140
|
+
- **Touch target smaller than the icon's frame.** A 24pt icon with no padding is a 24pt target. Pad to ≥44pt/48dp.
|
|
141
|
+
- **`contentDescription`/label missing on icon buttons, or set on decorative images.** Screen reader says "button" with no name, or narrates clutter. Label actionable elements; `null`/`.accessibilityHidden(true)` decoration.
|
|
142
|
+
- **Reading rapidly-changing state (scroll offset, animation) at composition scope.** Recomposes the whole subtree every frame. Read it in a lambda (`Modifier.offset { … }`) / use `derivedStateOf`.
|
|
143
|
+
|
|
144
|
+
## Verify
|
|
145
|
+
|
|
146
|
+
1. **Builds & previews render:** `xcodebuild -scheme <S> -destination 'platform=iOS Simulator,name=iPhone 15' build` / `./gradlew assembleDebug`. SwiftUI `#Preview` and Compose `@Preview` show light **and** dark variants without crashing.
|
|
147
|
+
2. **List performance:** scroll a 500+ item list on device — no dropped frames; inserting/removing keeps scroll position. (Compose: Layout Inspector → recomposition counts stay flat per row while scrolling; a row recomposing on unrelated state changes is a fail.)
|
|
148
|
+
3. **State hoisting holds:** toggle the child's control, confirm the parent's single source of truth updates and no duplicate/stale copy exists; rotate the device (or trigger config change) — state survives (VM/`rememberSaveable`), is not reset.
|
|
149
|
+
4. **Navigation & deep link:** push → Back returns correctly; cold-launch the deep link (`xcrun simctl openurl booted app://item/42` / `adb shell am start -a android.intent.action.VIEW -d "app://item/42"`) lands on the right screen with a sane back stack; switch tabs and return — the other tab's stack is preserved.
|
|
150
|
+
5. **Adaptivity:** run iPhone portrait, iPhone landscape, and iPad / a foldable (or resizable emulator dragged across 600dp and 840dp) — layout switches single↔two-pane at the size-class boundary, nothing clips or overlaps.
|
|
151
|
+
6. **Dynamic Type / dark:** set the largest accessibility text size and dark mode (iOS Settings → Accessibility → Larger Text; emulator font scale 1.3+ / Dark theme) — no truncation, no white-on-white, all controls reachable.
|
|
152
|
+
7. **Screen reader:** enable VoiceOver (Accessibility Inspector → audit) / TalkBack — swipe through: every actionable element announces a name + role, decorative content is skipped, focus order is logical, and no target is below 44pt/48dp (Xcode Accessibility Inspector audit / Compose `testTagsAsResourceId` + Accessibility Scanner report zero issues).
|
|
153
|
+
|
|
154
|
+
Done = the screen builds, previews render light+dark, a 500+ row list scrolls without dropped frames and without per-row recomposition on unrelated changes, state is hoisted and survives a config change, typed navigation + cold deep link land correctly with per-tab back stacks preserved, layout adapts across the size-class boundaries, and the accessibility inspector/scanner reports zero issues at the largest Dynamic Type / font scale.
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: build-offline-first-sync
|
|
3
|
+
description: Designs offline-first client data layers — a local store (SQLite/Room/Core Data/WatermelonDB), a durable outbound mutation queue with idempotency keys, optimistic local writes, cursor-based delta pull, conflict resolution (last-writer-wins/vector clocks/CRDT), tombstone deletes, and reconnect reconciliation.
|
|
4
|
+
when_to_use: When an app must read/write while offline and reconcile with a server — choosing the local store, queuing offline mutations, pulling deltas since a cursor, resolving write conflicts. Distinct from manage-client-server-state (online cache/TanStack Query) and message-queue-jobs (server-side worker queues).
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## When to Use
|
|
8
|
+
|
|
9
|
+
Reach for this when the client is the **source of truth while offline** and must converge with a server later, not just cache responses:
|
|
10
|
+
|
|
11
|
+
- "App has to work in airplane mode and sync when it reconnects"
|
|
12
|
+
- "Pick a local store — SQLite vs Room vs Core Data vs WatermelonDB"
|
|
13
|
+
- "Queue writes made offline and replay them in order without dupes"
|
|
14
|
+
- "Two devices edited the same row offline — who wins?"
|
|
15
|
+
- "Pull only what changed since last sync instead of refetching everything"
|
|
16
|
+
- "Deletes keep coming back after sync" (missing tombstones)
|
|
17
|
+
- "Optimistic edit, then roll back if the server rejects it"
|
|
18
|
+
|
|
19
|
+
NOT this skill:
|
|
20
|
+
- Online data fetching / cache invalidation with a live connection (TanStack/React Query, hydration, refetch) → manage-client-server-state
|
|
21
|
+
- The **server-side** worker that processes the sync queue (consumers, DLQ, exactly-once on the backend) → message-queue-jobs
|
|
22
|
+
- The shape of the sync API itself (REST vs GraphQL, pagination params, error envelopes) → rest-graphql-contract
|
|
23
|
+
- Changing the **server** schema the deltas come from (DDL locks, rollback) → db-migration-safety
|
|
24
|
+
- Identifying who the syncing user is / token refresh on reconnect → auth-jwt-session
|
|
25
|
+
|
|
26
|
+
## Steps
|
|
27
|
+
|
|
28
|
+
1. **Pick the local store by platform + reactivity need — don't reach for raw SQLite by reflex.**
|
|
29
|
+
|
|
30
|
+
| Store | Best when | Reactive queries | Migrations |
|
|
31
|
+
|---|---|---|---|
|
|
32
|
+
| **SQLite** (SQLDelight/Drift/expo-sqlite) | Cross-platform, you want real SQL + full control | Manual (triggers/`PRAGMA data_version`) or lib-provided | Hand-written `user_version` steps |
|
|
33
|
+
| Room (Android) | Native Android, Kotlin/Flow | `Flow`/`LiveData` built-in | `Migration` objects, `fallbackToDestructive` = data loss, avoid |
|
|
34
|
+
| Core Data / SwiftData (Apple) | Native iOS, object graph + iCloud | `@FetchRequest`/`NSFetchedResultsController` | Lightweight (auto) vs mapping model |
|
|
35
|
+
| **WatermelonDB** (RN) | React Native, large datasets, lazy reads | Observables out of the box | `schemaMigrations` versioned |
|
|
36
|
+
| Realm/MongoDB Atlas Device Sync | You want sync *built in* and accept the lock-in | Live objects | Schema-versioned |
|
|
37
|
+
|
|
38
|
+
Default: **SQLite via a typed wrapper** (SQLDelight/Drift) for cross-platform; **WatermelonDB** for React Native with thousands of rows; native (Room/Core Data) only if single-platform. Avoid building your own sync on Realm Device Sync unless you adopt their whole model.
|
|
39
|
+
|
|
40
|
+
2. **Add sync bookkeeping columns to every syncable table.** The on-device schema is the server schema **plus** local metadata:
|
|
41
|
+
|
|
42
|
+
```sql
|
|
43
|
+
CREATE TABLE task (
|
|
44
|
+
id TEXT PRIMARY KEY, -- client-generated UUIDv7 (sortable), NOT server autoincrement
|
|
45
|
+
title TEXT NOT NULL,
|
|
46
|
+
updated_at INTEGER NOT NULL, -- server-assigned ms epoch on last sync (the LWW clock)
|
|
47
|
+
version INTEGER NOT NULL DEFAULT 0, -- server row version for optimistic concurrency
|
|
48
|
+
deleted_at INTEGER, -- tombstone; NULL = live
|
|
49
|
+
sync_status TEXT NOT NULL DEFAULT 'synced' -- synced | pending | conflict
|
|
50
|
+
);
|
|
51
|
+
```
|
|
52
|
+
Generate IDs **on the client** (UUIDv7/ULID) so offline-created rows have a stable PK and FKs link before they ever reach the server — never depend on a server autoincrement id you don't have yet.
|
|
53
|
+
|
|
54
|
+
3. **Reads are local-first and reactive — the UI never awaits the network.** Every screen queries the local store and observes it (`Flow`, WatermelonDB observables, `NSFetchedResultsController`, or a SQLite change-notify). Filter out tombstones (`WHERE deleted_at IS NULL`) in the read layer, not the UI. Network sync mutates the local store; the reactive query repaints. Surface freshness from a `last_synced_at` you store per-collection, not per-render guesses.
|
|
55
|
+
|
|
56
|
+
4. **Writes go optimistic + into a durable outbox in one transaction.** Apply the change to the domain table **and** append an op to `outbox` atomically, so a crash can't lose one without the other:
|
|
57
|
+
|
|
58
|
+
```sql
|
|
59
|
+
CREATE TABLE outbox (
|
|
60
|
+
op_id TEXT PRIMARY KEY, -- idempotency key, client UUID, sent as Idempotency-Key header
|
|
61
|
+
entity TEXT NOT NULL,
|
|
62
|
+
entity_id TEXT NOT NULL,
|
|
63
|
+
op TEXT NOT NULL, -- insert | update | delete
|
|
64
|
+
payload TEXT NOT NULL, -- JSON of changed fields (delta, not whole row)
|
|
65
|
+
base_version INTEGER NOT NULL, -- server version the edit was based on (0 for an offline insert; for conflict detection)
|
|
66
|
+
created_at INTEGER NOT NULL,
|
|
67
|
+
attempts INTEGER NOT NULL DEFAULT 0
|
|
68
|
+
);
|
|
69
|
+
```
|
|
70
|
+
Set the row's `sync_status='pending'`. **Coalesce** repeated edits to the same `entity_id` before send (collapse 5 title edits into the latest) so the queue doesn't replay every keystroke. A delete is an op with `op='delete'` that sets `deleted_at` locally — never `DELETE FROM` until the server confirms the tombstone.
|
|
71
|
+
|
|
72
|
+
5. **Sync engine = push-then-pull, both idempotent, with bounded backoff.** Run on connectivity-gain and on an interval:
|
|
73
|
+
1. **Push:** drain `outbox` oldest-first, `Idempotency-Key: {op_id}`. On `2xx`, apply the server's returned `{version, updated_at}` to the row, set `synced`, delete the op. On `409 Conflict`, go to step 6. On `5xx`/timeout, leave the op, bump `attempts`, retry with exponential backoff + jitter (`min(2^attempts * base, 60s)`).
|
|
74
|
+
2. **Pull:** `GET /sync?since={cursor}&limit=500`, where `cursor` is the **server-issued** opaque cursor (or `updated_at` high-watermark) from the last successful pull. Apply each changed/deleted row, then persist the new `cursor` **only after** the whole page is committed. Page until `has_more=false`. Pull **after** push so the server already reflects your writes and you don't fight your own optimistic state.
|
|
75
|
+
|
|
76
|
+
Never pull before push, never advance the cursor mid-page, and order by `(updated_at, id)` server-side so pagination is stable under concurrent writes.
|
|
77
|
+
|
|
78
|
+
6. **Resolve conflicts deterministically — pick a strategy per entity, don't mix silently.**
|
|
79
|
+
|
|
80
|
+
| Strategy | Use when | Mechanism | Cost |
|
|
81
|
+
|---|---|---|---|
|
|
82
|
+
| **Last-Writer-Wins** | Independent scalar fields, low contention (default) | Compare `updated_at`; higher wins | trivial, can lose a field |
|
|
83
|
+
| Version / optimistic concurrency | Need to *detect* and merge, not silently drop | Server rejects with `409` if `base_version` ≠ current; client re-reads + replays | a round-trip per conflict |
|
|
84
|
+
| Vector clocks | Multi-device causal ordering matters | Per-replica counters; detect concurrent vs causal | bookkeeping per row |
|
|
85
|
+
| **CRDT** (Yjs/Automerge) | Collaborative text/lists, must merge without loss | Mergeable types converge automatically | larger payloads, library |
|
|
86
|
+
|
|
87
|
+
Default **field-level LWW** for most records; escalate to **CRDT only** for collaborative documents/lists. On `409`, the server returns the current row + version: re-base the pending op onto it (re-apply the user's delta to the latest server state), bump `base_version`, re-queue. If a field truly diverges, mark `sync_status='conflict'` and surface it — never drop a user's write without a trace.
|
|
88
|
+
|
|
89
|
+
7. **Reconnect & reconciliation.** Detect connectivity transitions (`NWPathMonitor` / `ConnectivityManager` / `@react-native-community/netinfo`) — treat reachability as "maybe", confirm with the first real request, don't trust the radio flag alone. On reconnect: push outbox → pull deltas → clear stale `pending` that the server now confirms. Cap `attempts`; an op that exceeds the cap (e.g. permanent `400`/`422`) moves to a **client-side dead-letter / `conflict`** state and is surfaced to the user — it must not block the rest of the queue (head-of-line blocking).
|
|
90
|
+
|
|
91
|
+
8. **Integrity: make partial sync recoverable.** Persist the cursor only after a page fully commits, so a crash mid-pull re-fetches that page (idempotent apply makes re-fetch safe). Dedupe on apply by `(id)` + `version` — ignore an incoming row whose `version` ≤ local. Negotiate schema with a `schema_version` in the sync request; on mismatch the server returns `426 Upgrade Required` and the client forces an app update rather than corrupting data. Run local migrations (`user_version` / `schemaMigrations`) **before** the first sync after an app upgrade.
|
|
92
|
+
|
|
93
|
+
## Common Errors
|
|
94
|
+
|
|
95
|
+
- **Server-autoincrement PKs for offline-created rows.** You can't link FKs or reference the row until the server replies. Generate UUIDv7/ULID on the client; keep that id forever.
|
|
96
|
+
- **Hard-deleting locally instead of tombstoning.** The next pull from another device re-creates the row (it never saw the delete). Set `deleted_at`, sync the tombstone, GC tombstones only after all clients have pulled past them.
|
|
97
|
+
- **Advancing the sync cursor before the page is committed.** A crash mid-apply skips rows permanently — silent data loss. Commit the page, *then* persist the cursor.
|
|
98
|
+
- **No idempotency key on push.** A retried op after a timeout (where the server actually succeeded) double-applies — duplicate rows / double charges. `Idempotency-Key: {op_id}`, server dedupes.
|
|
99
|
+
- **Replaying every keystroke from the outbox.** 200 ops to sync one note. Coalesce ops per `entity_id` before push.
|
|
100
|
+
- **Pull before push.** The server hasn't seen your local edits yet, so the delta overwrites your optimistic state and the UI flickers back. Always push first.
|
|
101
|
+
- **Trusting the OS "connected" flag.** Captive portals and dead Wi-Fi report "connected". Confirm with an actual lightweight request before draining the queue.
|
|
102
|
+
- **Unbounded retries on a permanent `4xx`.** A `422` op retries forever and head-of-line-blocks every later op. Cap attempts; dead-letter the poison op; keep draining the rest.
|
|
103
|
+
- **Last-Writer-Wins on a whole row.** One device edits `title`, another edits `due_date`; whole-row LWW silently drops one field. Do **field-level** LWW or merge.
|
|
104
|
+
- **Ignoring clock skew in LWW.** Client clocks lie. Use the **server-assigned** `updated_at` as the LWW clock, not the device clock.
|
|
105
|
+
- **Migrating local schema after the first sync.** Incoming rows don't fit the old schema → crash or silent drop. Migrate on app start, before sync runs.
|
|
106
|
+
|
|
107
|
+
## Verify
|
|
108
|
+
|
|
109
|
+
1. **Airplane-mode write survives restart:** Go offline, create + edit + delete records, force-quit and relaunch → all local changes still present, `outbox` intact, `sync_status='pending'`.
|
|
110
|
+
2. **Reconnect drains correctly:** Re-enable network → outbox empties, every op acked, rows flip to `synced`, server reflects every offline change exactly once (no dupes — proves idempotency keys work).
|
|
111
|
+
3. **Delta pull is incremental:** Trigger a remote change, sync → only the changed rows transfer (inspect request: `since={cursor}` with a non-empty cursor, response page << full table). A second sync with no remote changes transfers zero rows.
|
|
112
|
+
4. **Conflict resolves deterministically:** Two clients edit the same row offline, both reconnect → result matches the documented strategy (field-level LWW = each field = latest server `updated_at`; CRDT = both edits merged), and no write vanishes silently — divergence shows as `conflict`.
|
|
113
|
+
5. **Tombstone delete stays deleted:** Delete on device A, sync; device B syncs → row disappears on B and does **not** resurrect on A's next pull.
|
|
114
|
+
6. **Flaky network / mid-sync kill:** Throttle to 2G + 30% packet loss (Network Link Conditioner / Charles), kill the app mid-pull → relaunch re-fetches the uncommitted page, converges, no duplicate or missing rows; cursor never advanced past uncommitted data.
|
|
115
|
+
7. **Poison op doesn't block the queue:** Inject an op the server rejects with `422` → it dead-letters after the attempt cap and surfaces to the user; every other queued op still syncs.
|
|
116
|
+
8. **Schema-version mismatch is safe:** Point an old client at a newer server → `426`/upgrade path, not a corrupt write or crash.
|
|
117
|
+
|
|
118
|
+
Done = a record created **offline** survives an app restart, syncs **exactly once** on reconnect, incremental pull transfers only deltas since the cursor, concurrent edits resolve per the documented strategy with **no silent data loss**, deletes stay deleted, and a mid-sync kill under a flaky network converges with no duplicate or missing rows.
|