Skip to content

Commit 3cf80fe

Browse files
jaredcosulichclaude
andcommitted
feat: Durable Group Storage
Moved the user's groups (the `labels` map) from `chrome.storage.local` to `chrome.storage.sync`, so they survive the uninstall that destroyed them on 2026-08-12 — including the implicit uninstall Chrome performs when an unpacked extension's directory goes missing. Routing lives in one key→area table (`storageAreas.js`) that `Chrome`, the worker's `update`/`getStorage`, and the dev-server shim all fan out through, so mixed-area reads and writes (`labels` from sync alongside `previousLabels` and the `url-*` records from local) keep working unchanged. `previousLabels` deliberately stays local: it is ten full snapshots of `labels` and would breach sync's 8,192-byte per-item cap on its own. Sync brought three constraints local storage never had, each handled: writes are capped at 120/minute, so a `labels` write byte-identical to the last persisted one is dropped and `recordInGroupTab`'s per-tab write is now conditional on an actual mutation; an item over quota is measured before the write and redirected to local; and a rejected write (signed out, sync disabled) falls back to local. The last two record a `syncStatus` the Import/Export page surfaces as a warning, positioned directly above the Export snapshot it tells the user to copy — silence there would look exactly like a working backup. A one-way-safe migration copies local→sync on boot and never deletes the local copy, so a sync failure degrades to the old behavior instead of losing data. The extension ID is pinned to TabCommand's Web Store key so the unpacked and published builds stop presenting separate storage partitions, and `npm run build` now mirrors into a stable `dist/extension/` instead of letting `emptyOutDir` delete the directory Chrome has loaded — the proximate cause of the incident. Four defects surfaced during the build, none in the plan. The export crashed after a recovery: `resolveLabelUrls` dereferenced the per-URL record unconditionally, so once groups came back from sync without their local `url-*` records the whole Export panel died — leaving no way to back up the groups just recovered; URLs are now reconstructed from their urlKey, since dropping the member would have been worse (an export that looks complete while silently losing URLs). `LabelCollection` and `Search` both carried `if (areaName !== 'local') return`, which silently swallowed every `labels` change once labels moved to sync — group cards would stop live-updating on rename and the search index would go stale; both now gate per-key through a shared `changedInArea`. The migration also had to run on app boot, not just worker boot: MV3 recycles the worker constantly, so a not-yet-migrated user opening the full-page app first would read an empty sync area and see zero groups. And `navigator.clipboard.writeText` was left unhandled on the copy-your-snapshot action the sync warnings send people to, where it rejects on a hardened profile or non-secure context. Worth recording for future sessions: the test-attribution engine classified 10 failures as "inherited pre-existing base debt, not touched by this branch" and advised against fixing them here. Checking one against `origin/main` showed it passing there — all 10 were change-introduced, and two were the live-update listener bugs above. Trusting the attribution would have shipped them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Ppc87ZP3yvSy15PkKwBJa
1 parent 7ed04b4 commit 3cf80fe

115 files changed

Lines changed: 8200 additions & 4612 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.codeyam/dependency-graph.json

Lines changed: 3702 additions & 3445 deletions
Large diffs are not rendered by default.

.codeyam/glossary.json

Lines changed: 182 additions & 8 deletions
Large diffs are not rendered by default.

.codeyam/harness/isolate.jsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,28 @@ const FAV_NOW = 1782486000000;
5151
const D = 86400000;
5252

5353
const ISOLATION_PROPS = {
54+
// The banner shown when the user's groups are NOT reaching chrome.storage.sync.
55+
// Both states are worth pinning because their COPY differs by remedy: an
56+
// over-quota set needs the user to export and prune, while an unavailable sync
57+
// area needs them to sign in. The component renders null for a healthy or
58+
// absent status, so there is deliberately no third variant here — that state is
59+
// covered visually by the Import / Export page scenarios, where the absence of
60+
// the banner is the observable thing.
61+
SyncWarning: {
62+
default: {
63+
status: { status: 'too-large', key: 'labels', bytes: 9147, at: 1786545000000 },
64+
},
65+
variants: {
66+
'sync-failed': {
67+
status: {
68+
status: 'sync-failed',
69+
key: 'labels',
70+
message: 'Please sign in to Chrome to use chrome.storage.sync',
71+
at: 1786545000000,
72+
},
73+
},
74+
},
75+
},
5476
// The sidebar wordmark introduced by the visual redesign: the 4-color mark plus
5577
// the Tab/Command text wordmark. Single visual state; click is a no-op here.
5678
AppBrand: {

.codeyam/journal/entries/2026-08-12T16-28-30Z.json

Lines changed: 507 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
{
2+
"claimStartedAt": "2026-08-12T14:29:17Z",
3+
"completedAt": "2026-08-12T16:28:30Z",
4+
"completedBy": {
5+
"agentProvider": "claude",
6+
"codeyamEditorVersion": "0.1.0",
7+
"currentStep": "ui-step21",
8+
"machine": "codeyam-editor-codeyam-editor-11",
9+
"pid": 7,
10+
"priority": 0,
11+
"selectedPlan": "durable-group-storage",
12+
"sessionId": "b60b2824-0e5c-48a3-8d63-da11d133e34e",
13+
"startedAt": "2026-08-12T14:29:17Z"
14+
},
15+
"createdAt": "2026-08-12T11:02:18Z",
16+
"deliverables": [
17+
".gitignore",
18+
"CONTRIBUTING.md",
19+
"README.md",
20+
"eslint.config.mjs",
21+
"manifest.json",
22+
"package.json",
23+
"scripts/sync-extension-dir.mjs",
24+
"scripts/sync-extension-dir.test.js",
25+
"service_worker.js",
26+
"service_worker.test.js",
27+
"src/index.jsx",
28+
"src/lib/components/LabelCollection/LabelCollection.jsx",
29+
"src/lib/components/LabelCollection/LabelCollection.test.jsx",
30+
"src/lib/components/LabelForm/LabelForm.test.jsx",
31+
"src/lib/components/Search/Search.jsx",
32+
"src/lib/components/Search/Search.test.jsx",
33+
"src/lib/components/SyncWarning/SyncWarning.css",
34+
"src/lib/components/SyncWarning/SyncWarning.jsx",
35+
"src/lib/components/SyncWarning/index.js",
36+
"src/lib/pages/ImportExport/ImportExport.jsx",
37+
"src/lib/pages/ImportExport/ImportExport.test.jsx",
38+
"src/lib/pages/UrlDetails/UrlDetails.test.jsx",
39+
"src/lib/utils/Chrome/Chrome.js",
40+
"src/lib/utils/Chrome/Chrome.test.js",
41+
"src/lib/utils/chromeShim/chromeShim.js",
42+
"src/lib/utils/chromeShim/chromeShim.test.js",
43+
"src/lib/utils/importExport.js",
44+
"src/lib/utils/importExport.test.js",
45+
"src/lib/utils/migrateLabelsToSync.js",
46+
"src/lib/utils/migrateLabelsToSync.test.js",
47+
"src/lib/utils/storageAccess.js",
48+
"src/lib/utils/storageAccess.test.js",
49+
"src/lib/utils/storageAreas.js",
50+
"src/lib/utils/storageAreas.test.js",
51+
"src/lib/utils/syncQuota.js",
52+
"src/lib/utils/syncQuota.test.js",
53+
"vite.config.mjs"
54+
],
55+
"journalTimestamp": "2026-08-12T16:28:30Z",
56+
"mode": "ui",
57+
"planFile": "completed/durable-group-storage.md",
58+
"slug": "durable-group-storage",
59+
"title": "Durable Group Storage"
60+
}
File renamed without changes.

.codeyam/scenarios/coverage-plan/app.json

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"dimension": "tracksCursor",
77
"value": "true",
88
"rationale": "source-derived state tracksCursor=true",
9-
"status": "planned"
9+
"status": "covered"
1010
},
1111
{
1212
"dimension": "tracksCursor",
@@ -18,7 +18,7 @@
1818
"dimension": "labelsElement",
1919
"value": "true",
2020
"rationale": "source-derived state labelsElement=true",
21-
"status": "planned"
21+
"status": "covered"
2222
},
2323
{
2424
"dimension": "labelsElement",
@@ -30,7 +30,7 @@
3030
"dimension": "addition",
3131
"value": "true",
3232
"rationale": "source-derived state addition=true",
33-
"status": "planned"
33+
"status": "covered"
3434
},
3535
{
3636
"dimension": "addition",
@@ -42,7 +42,7 @@
4242
"dimension": "removal",
4343
"value": "true",
4444
"rationale": "source-derived state removal=true",
45-
"status": "planned"
45+
"status": "covered"
4646
},
4747
{
4848
"dimension": "removal",
@@ -54,7 +54,7 @@
5454
"dimension": "isHome",
5555
"value": "true",
5656
"rationale": "source-derived state isHome=true",
57-
"status": "planned"
57+
"status": "covered"
5858
},
5959
{
6060
"dimension": "isHome",
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
{
2+
"surface": "ImportExport",
3+
"surface_kind": "component",
4+
"states": [
5+
{
6+
"dimension": "importLabels",
7+
"value": "empty",
8+
"rationale": "source-derived state importLabels=empty",
9+
"status": "planned"
10+
},
11+
{
12+
"dimension": "importLabels",
13+
"value": "one",
14+
"rationale": "source-derived state importLabels=one",
15+
"status": "planned"
16+
},
17+
{
18+
"dimension": "importLabels",
19+
"value": "many",
20+
"rationale": "source-derived state importLabels=many",
21+
"status": "planned"
22+
},
23+
{
24+
"dimension": "previousLabelsResult",
25+
"value": "empty",
26+
"rationale": "source-derived state previousLabelsResult=empty",
27+
"status": "planned"
28+
},
29+
{
30+
"dimension": "previousLabelsResult",
31+
"value": "one",
32+
"rationale": "source-derived state previousLabelsResult=one",
33+
"status": "planned"
34+
},
35+
{
36+
"dimension": "previousLabelsResult",
37+
"value": "many",
38+
"rationale": "source-derived state previousLabelsResult=many",
39+
"status": "planned"
40+
},
41+
{
42+
"dimension": "_previousLabels",
43+
"value": "empty",
44+
"rationale": "source-derived state _previousLabels=empty",
45+
"status": "planned"
46+
},
47+
{
48+
"dimension": "_previousLabels",
49+
"value": "one",
50+
"rationale": "source-derived state _previousLabels=one",
51+
"status": "planned"
52+
},
53+
{
54+
"dimension": "_previousLabels",
55+
"value": "many",
56+
"rationale": "source-derived state _previousLabels=many",
57+
"status": "planned"
58+
},
59+
{
60+
"dimension": "onComplete",
61+
"value": "true",
62+
"rationale": "source-derived state onComplete=true",
63+
"status": "planned"
64+
},
65+
{
66+
"dimension": "onComplete",
67+
"value": "false",
68+
"rationale": "source-derived state onComplete=false",
69+
"status": "covered"
70+
},
71+
{
72+
"dimension": "copied",
73+
"value": "true",
74+
"rationale": "source-derived state copied=true",
75+
"status": "planned"
76+
},
77+
{
78+
"dimension": "copied",
79+
"value": "false",
80+
"rationale": "source-derived state copied=false",
81+
"status": "covered"
82+
}
83+
]
84+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"surface": "SyncWarning",
3+
"surface_kind": "component",
4+
"states": [
5+
{
6+
"dimension": "status",
7+
"value": "true",
8+
"rationale": "source-derived state status=true",
9+
"status": "planned"
10+
},
11+
{
12+
"dimension": "status",
13+
"value": "false",
14+
"rationale": "source-derived state status=false",
15+
"status": "covered"
16+
}
17+
]
18+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"name": "Home - First Run Migration From Local",
3+
"id": "b112bd0c-450c-421a-8865-86d2d9872a87",
4+
"url": "/",
5+
"dimensions": [
6+
"Desktop"
7+
],
8+
"mocks": {},
9+
"browserState": {
10+
"localStorage": {
11+
"local::labels": "{\"Work\":{\"title\":\"Work\",\"backgroundColor\":\"#1873E4\",\"position\":0,\"urlKeys\":[\"url-https://github.com/codeyam/tabcommand\",\"url-https://www.figma.com/file/tabcommand\"]},\"Reading\":{\"title\":\"Reading\",\"backgroundColor\":\"#1F8E43\",\"position\":1,\"urlKeys\":[\"url-https://react.dev/learn\"]}}",
12+
"url-https://www.figma.com/file/tabcommand": "{\"url\":\"https://www.figma.com/file/tabcommand\",\"title\":\"TabCommand UI — Figma\",\"favicon\":\"\",\"processes\":{\"samples\":0}}",
13+
"url-https://react.dev/learn": "{\"url\":\"https://react.dev/learn\",\"title\":\"Quick Start – React\",\"favicon\":\"\",\"processes\":{\"samples\":0}}",
14+
"url-https://github.com/codeyam/tabcommand": "{\"url\":\"https://github.com/codeyam/tabcommand\",\"title\":\"codeyam/tabcommand — GitHub\",\"favicon\":\"https://github.githubassets.com/favicons/favicon.svg\",\"processes\":{\"samples\":0}}"
15+
}
16+
},
17+
"scenarioType": "application",
18+
"pageFilePath": "src/lib/pages/App/App.jsx",
19+
"description": "The upgrade path. Groups exist only in the old local area and sync is empty — every existing user’s state the moment they take this version. The boot migration copies local into sync and the UI is unchanged, which is the point: the user sees nothing happen while their groups become durable."
20+
}

0 commit comments

Comments
 (0)