Skip to content

Commit b612fa8

Browse files
Claudeclaude
authored andcommitted
feat: free→pro migration — local .gitmem data migrates to Supabase on activate
When a free-tier user runs `activate`, existing local data (learnings, sessions, decisions, scar_usage) is now automatically migrated to their Supabase project. Migration is idempotent (upsert with merge-duplicates) and archives local files to .pre-migration after success. New module: src/commands/migrate-local.ts - hasLocalData() — detects existing local JSON files - migrateLocalToSupabase() — reads local collections, upserts to Supabase - archiveLocalData() — renames .json → .json.pre-migration Stress test v1.3: 166/166 PASS (was 150) - Day 6 added: seeds local data, runs migration, verifies in Supabase, confirms usable via MCP (log, search, recall), tests archive + idempotency Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ec90827 commit b612fa8

4 files changed

Lines changed: 557 additions & 11 deletions

File tree

src/commands/activate.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import * as readline from "readline";
2323
import { fileURLToPath } from "url";
2424
import { getGitmemDir, getInstallId } from "../services/gitmem-dir.js";
2525
import { validateLicense, clearLicenseCache } from "../services/license.js";
26+
import { hasLocalData, migrateLocalToSupabase, archiveLocalData } from "./migrate-local.js";
2627

2728
function createReadline(): readline.Interface {
2829
return readline.createInterface({
@@ -535,6 +536,64 @@ export async function main(args: string[]): Promise<void> {
535536
// Clear any stale license cache
536537
clearLicenseCache();
537538

539+
// Step 7: Migrate local data to Supabase (free → pro upgrade)
540+
if (supabaseUrl && supabaseKey && missingTables.length === 0 && hasLocalData(gitmemDir)) {
541+
console.log("Migrating Local Data");
542+
console.log(" Found existing local data from free tier...");
543+
console.log("");
544+
545+
const migrationResult = await migrateLocalToSupabase({
546+
supabaseUrl,
547+
supabaseKey,
548+
gitmemDir,
549+
onProgress: (msg) => console.log(msg),
550+
});
551+
552+
// Report results
553+
const collections = Object.keys(migrationResult.migrated);
554+
let totalMigrated = 0;
555+
let totalSkipped = 0;
556+
let totalErrors = 0;
557+
558+
for (const col of collections) {
559+
const m = migrationResult.migrated[col];
560+
const s = migrationResult.skipped[col];
561+
const e = migrationResult.errors[col]?.length || 0;
562+
totalMigrated += m;
563+
totalSkipped += s;
564+
totalErrors += e;
565+
if (m > 0) {
566+
console.log(` ✓ ${col}: ${m} records migrated${s > 0 ? ` (${s} skipped)` : ""}`);
567+
}
568+
}
569+
570+
// Show errors if any
571+
if (totalErrors > 0) {
572+
console.log("");
573+
for (const col of collections) {
574+
for (const err of migrationResult.errors[col] || []) {
575+
console.log(` ⚠ ${col}: ${err}`);
576+
}
577+
}
578+
}
579+
580+
if (totalMigrated > 0) {
581+
console.log("");
582+
console.log(` ✓ Migrated ${totalMigrated} records to Supabase`);
583+
584+
// Archive local files so they aren't re-read
585+
const archived = archiveLocalData(gitmemDir);
586+
if (archived.length > 0) {
587+
console.log(` ✓ Local files archived (${archived.join(", ")}.json → .pre-migration)`);
588+
}
589+
} else if (migrationResult.hasLocalData) {
590+
console.log(" ⚠ Migration encountered errors. Local data preserved.");
591+
console.log(" Re-run activate after resolving issues.");
592+
}
593+
594+
console.log("");
595+
}
596+
538597
// Summary
539598
console.log("");
540599
console.log("─────────────────────");

src/commands/migrate-local.ts

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
/**
2+
* Local-to-Supabase Migration
3+
*
4+
* Migrates existing free-tier local .gitmem/ data to Supabase when
5+
* a user upgrades to Pro. Called during `activate` after schema is
6+
* verified and credentials are saved.
7+
*
8+
* Collections migrated:
9+
* - learnings (scars, wins, patterns, anti-patterns)
10+
* - sessions
11+
* - decisions
12+
* - scar_usage
13+
*
14+
* Threads are NOT migrated — they remain local (thread lifecycle is
15+
* tied to .gitmem/threads.json and managed by session_start).
16+
*
17+
* Migration is idempotent: uses Supabase upsert (merge-duplicates)
18+
* so re-running is safe. Existing Supabase records with same ID are
19+
* updated, not duplicated.
20+
*/
21+
22+
import * as fs from "fs";
23+
import * as path from "path";
24+
import { getGitmemDir } from "../services/gitmem-dir.js";
25+
26+
/** Collections that map to Supabase tables */
27+
const MIGRATABLE_COLLECTIONS = ["learnings", "sessions", "decisions", "scar_usage"] as const;
28+
29+
/** Fields that should NOT be sent to Supabase (local-only or computed) */
30+
const STRIP_FIELDS = new Set(["is_starter"]);
31+
32+
/** Fields that Supabase will reject if null (remove instead of sending null) */
33+
const NULLABLE_STRIP = new Set(["embedding"]);
34+
35+
export interface MigrationResult {
36+
migrated: Record<string, number>;
37+
skipped: Record<string, number>;
38+
errors: Record<string, string[]>;
39+
total: number;
40+
hasLocalData: boolean;
41+
}
42+
43+
/**
44+
* Check if there is local data worth migrating
45+
*/
46+
export function hasLocalData(gitmemDir?: string): boolean {
47+
const dir = gitmemDir || getGitmemDir();
48+
for (const collection of MIGRATABLE_COLLECTIONS) {
49+
const filePath = path.join(dir, `${collection}.json`);
50+
if (fs.existsSync(filePath)) {
51+
try {
52+
const data = JSON.parse(fs.readFileSync(filePath, "utf-8"));
53+
if (Array.isArray(data) && data.length > 0) return true;
54+
} catch {
55+
// Corrupt file — skip
56+
}
57+
}
58+
}
59+
return false;
60+
}
61+
62+
/**
63+
* Read a local collection JSON file
64+
*/
65+
function readLocalCollection(dir: string, collection: string): Record<string, unknown>[] {
66+
const filePath = path.join(dir, `${collection}.json`);
67+
if (!fs.existsSync(filePath)) return [];
68+
try {
69+
const data = JSON.parse(fs.readFileSync(filePath, "utf-8"));
70+
return Array.isArray(data) ? data : [];
71+
} catch {
72+
return [];
73+
}
74+
}
75+
76+
/**
77+
* Clean a record for Supabase insertion:
78+
* - Strip local-only fields
79+
* - Remove null values for non-nullable columns
80+
* - Ensure id exists
81+
*/
82+
function cleanRecord(record: Record<string, unknown>): Record<string, unknown> | null {
83+
if (!record.id) return null;
84+
85+
const cleaned: Record<string, unknown> = {};
86+
for (const [key, value] of Object.entries(record)) {
87+
if (STRIP_FIELDS.has(key)) continue;
88+
if (NULLABLE_STRIP.has(key) && (value === null || value === undefined)) continue;
89+
cleaned[key] = value;
90+
}
91+
return cleaned;
92+
}
93+
94+
/**
95+
* Migrate local .gitmem data to Supabase
96+
*
97+
* @param supabaseUrl - User's Supabase project URL
98+
* @param supabaseKey - User's service role key
99+
* @param tablePrefix - Table prefix (default: "gitmem_")
100+
* @param gitmemDir - Override .gitmem directory path
101+
* @param onProgress - Callback for progress reporting
102+
*/
103+
export async function migrateLocalToSupabase(opts: {
104+
supabaseUrl: string;
105+
supabaseKey: string;
106+
tablePrefix?: string;
107+
gitmemDir?: string;
108+
onProgress?: (msg: string) => void;
109+
}): Promise<MigrationResult> {
110+
const { supabaseUrl, supabaseKey, tablePrefix = "gitmem_", onProgress } = opts;
111+
const dir = opts.gitmemDir || getGitmemDir();
112+
const log = onProgress || ((msg: string) => console.log(msg));
113+
114+
const result: MigrationResult = {
115+
migrated: {},
116+
skipped: {},
117+
errors: {},
118+
total: 0,
119+
hasLocalData: false,
120+
};
121+
122+
const restUrl = `${supabaseUrl}/rest/v1`;
123+
124+
for (const collection of MIGRATABLE_COLLECTIONS) {
125+
const records = readLocalCollection(dir, collection);
126+
const tableName = `${tablePrefix}${collection}`;
127+
128+
result.migrated[collection] = 0;
129+
result.skipped[collection] = 0;
130+
result.errors[collection] = [];
131+
132+
if (records.length === 0) continue;
133+
result.hasLocalData = true;
134+
135+
log(` Migrating ${records.length} ${collection}...`);
136+
137+
for (const record of records) {
138+
const cleaned = cleanRecord(record);
139+
if (!cleaned) {
140+
result.skipped[collection]++;
141+
continue;
142+
}
143+
144+
try {
145+
const response = await fetch(`${restUrl}/${tableName}`, {
146+
method: "POST",
147+
headers: {
148+
"apikey": supabaseKey,
149+
"Authorization": `Bearer ${supabaseKey}`,
150+
"Content-Type": "application/json",
151+
"Prefer": "return=minimal,resolution=merge-duplicates",
152+
"Content-Profile": "public",
153+
},
154+
body: JSON.stringify(cleaned),
155+
signal: AbortSignal.timeout(10_000),
156+
});
157+
158+
if (response.ok) {
159+
result.migrated[collection]++;
160+
} else {
161+
const text = await response.text();
162+
// Only log first 3 errors per collection to avoid spam
163+
if (result.errors[collection].length < 3) {
164+
result.errors[collection].push(
165+
`${String(cleaned.id).substring(0, 8)}: ${response.status} - ${text.substring(0, 100)}`
166+
);
167+
}
168+
result.skipped[collection]++;
169+
}
170+
} catch (err) {
171+
if (result.errors[collection].length < 3) {
172+
result.errors[collection].push(
173+
`${String(cleaned.id).substring(0, 8)}: ${err instanceof Error ? err.message : "Unknown error"}`
174+
);
175+
}
176+
result.skipped[collection]++;
177+
}
178+
179+
result.total++;
180+
}
181+
}
182+
183+
return result;
184+
}
185+
186+
/**
187+
* Rename local collection files after successful migration
188+
* Adds .pre-migration suffix so data isn't lost but won't be re-read by free tier
189+
*/
190+
export function archiveLocalData(gitmemDir?: string): string[] {
191+
const dir = gitmemDir || getGitmemDir();
192+
const archived: string[] = [];
193+
194+
for (const collection of MIGRATABLE_COLLECTIONS) {
195+
const filePath = path.join(dir, `${collection}.json`);
196+
if (fs.existsSync(filePath)) {
197+
const archivePath = `${filePath}.pre-migration`;
198+
// Don't overwrite existing archive
199+
if (!fs.existsSync(archivePath)) {
200+
fs.renameSync(filePath, archivePath);
201+
archived.push(collection);
202+
}
203+
}
204+
}
205+
206+
return archived;
207+
}

testing/clean-room/PRO-STRESS-TEST-PLAN.md

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
# GitMem Pro Stress Test Plan v1.0
1+
# GitMem Pro Stress Test Plan v1.3
22

33
## Overview
44

5-
Comprehensive end-to-end test of all GitMem Pro features on a fresh Supabase project. Simulates 5 days of real interactive usage with session cycling, data accumulation, and cross-session persistence verification.
5+
Comprehensive end-to-end test of all GitMem Pro features on a fresh Supabase project. Simulates 6 days of real interactive usage with session cycling, data accumulation, cross-session persistence verification, and free-to-pro upgrade migration.
66

77
**Test file:** `pro-stress-test.mjs`
8-
**Last run:** 2026-05-25 — 150/150 PASS (all canonical tools, real sub-agent handoff)
8+
**Last run:** 2026-05-25 — 166/166 PASS (all canonical tools, real sub-agent handoff, free→pro migration)
99

1010
## Prerequisites
1111

@@ -44,7 +44,7 @@ su developer -c "cd /home/developer/my-project && node /tmp/test-harness/stress-
4444
' < testing/clean-room/pro-stress-test.mjs
4545
```
4646

47-
## Test coverage — 141 tests across 5 simulated days
47+
## Test coverage — 166 tests across 6 simulated days
4848

4949
### Day 1: Initial setup (78 tests)
5050
- `session_start` — first session on blank project
@@ -154,7 +154,28 @@ su developer -c "cd /home/developer/my-project && node /tmp/test-harness/stress-
154154
| cache-flush | 1 | 4 |
155155
| contribute_feedback | 1 | 4 |
156156
| gitmem-help | 1 | 5 |
157-
| **TOTAL** | **150** | |
157+
| migrateLocalToSupabase | 2 | 6 |
158+
| hasLocalData | 2 | 6 |
159+
| archiveLocalData | 1 | 6 |
160+
| verify migration (Supabase) | 4 | 6 |
161+
| verify migration (MCP tools) | 4 | 6 |
162+
| idempotency re-migration | 1 | 6 |
163+
| local file archiving | 2 | 6 |
164+
| **TOTAL** | **166** | |
165+
166+
### Day 6: Free → Pro Upgrade — Local Data Migration (16 tests)
167+
- Seed local `.gitmem/` with 15 learnings, 3 sessions, 4 decisions, 5 scar_usage (simulating free tier user)
168+
- `hasLocalData()` — detects existing local data
169+
- `migrateLocalToSupabase()` — migrates all 27 records to Supabase via PostgREST upsert
170+
- Verify counts in Supabase per collection (learnings, sessions, decisions, scar_usage)
171+
- Start MCP server and verify migrated data is usable:
172+
- `log` — shows migrated learnings
173+
- `search` — finds migrated scars by content
174+
- `recall` — surfaces migrated scars for relevant plans
175+
- `archiveLocalData()` — renames `.json``.json.pre-migration`
176+
- Verify local files renamed, `hasLocalData()` returns false
177+
- Re-run migration to verify idempotency (upsert doesn't duplicate)
178+
- `session_close`
158179

159180
## What this test validates
160181

@@ -171,6 +192,7 @@ su developer -c "cd /home/developer/my-project && node /tmp/test-harness/stress-
171192
11. **Analytics** — cross-session analysis works
172193
12. **Knowledge graph** — traverse returns stats and connections
173194
13. **Scar lifecycle** — create → recall → confirm → reflect → record usage → archive
195+
14. **Free → Pro migration** — local `.gitmem/` JSON data migrated to Supabase during activate, verified usable via MCP tools, local files archived, re-migration idempotent
174196

175197
## Version history
176198

@@ -179,3 +201,4 @@ su developer -c "cd /home/developer/my-project && node /tmp/test-harness/stress-
179201
| v1.0 | 2026-05-25 | 141 | 141 PASS |
180202
| v1.1 | 2026-05-25 | 147 | 147 PASS — added record_scar_usage_batch, transcripts, promote/dismiss_suggestion |
181203
| v1.2 | 2026-05-25 | 150 | 150 PASS — real sub-agent handoff, ANSI color output, per-test timing, progress bars |
204+
| v1.3 | 2026-05-25 | 166 | 166 PASS — free→pro migration (local data → Supabase), archive + idempotency |

0 commit comments

Comments
 (0)