{"slug": "my-ai-audit-tool-was-merging-claude-and-cursor-sessions-the-bug-was-one-unique", "title": "My AI audit tool was merging Claude and Cursor sessions. The bug was one UNIQUE constraint.", "summary": "A developer fixed a bug in Chron, an MCP server that logs AI coding sessions, where Claude and Cursor sessions were incorrectly merged due to a UNIQUE constraint on the session title. The issue was that the title alone was not unique across tools, and the fix involved using a composite unique index on (title, COALESCE(ai_tool, '')) to handle NULL values correctly. The developer also updated the lookup query to filter by both title and tool.", "body_md": "I maintain [Chron](https://www.npmjs.com/package/chron-mcp), an MCP server that writes an audit log of AI coding sessions to a local SQLite database. Every message gets a timestamp, and every session records which tool produced it: Claude, Cursor, Codex.\n\nA while back I opened the history and found Claude messages and Cursor messages interleaved inside a single session. Two different tools, two different terminals, one session record.\n\nFor a tool whose entire job is attribution, that is about as bad as a bug gets.\n\nThe sessions table looked like this:\n\n```\nCREATE TABLE sessions (\n  id       TEXT PRIMARY KEY,\n  title    TEXT NOT NULL UNIQUE,\n  ai_tool  TEXT,\n  created_at TEXT NOT NULL,\n  updated_at TEXT NOT NULL\n);\n```\n\nWhen a tool starts work it calls `init_session` with a title and its own `ai_tool`. If a session with that title already exists, resume it. Otherwise create a new one.\n\nBoth Claude and Cursor generate a short descriptive title from the task. Working in the same repo on the same task, they generate the same title. Something like `General assistance session`.\n\n`UNIQUE(title)` then guarantees only one row can exist for that title. So the second tool does not get its own session. It resumes the first one. Claude's messages and Cursor's messages land under the same session id, and `ai_tool` on that session reports whichever tool happened to start first.\n\nThe constraint was doing exactly what it was written to do. It was just the wrong constraint. A title was never the identity of a session. The pair `(title, tool)` was.\n\nThe obvious move:\n\n```\nCREATE UNIQUE INDEX idx_sessions_title_tool ON sessions(title, ai_tool);\n```\n\nThis is wrong, and it is wrong in a way that survives a casual test.\n\n`ai_tool` is nullable. In SQL, `NULL` is never equal to `NULL`, and that includes comparisons inside a unique index. The constraint stops constraining the moment the column is NULL:\n\n```\nCREATE TABLE a (title TEXT NOT NULL, ai_tool TEXT);\nCREATE UNIQUE INDEX ia ON a(title, ai_tool);\n\nINSERT INTO a VALUES ('Session', NULL);\nINSERT INTO a VALUES ('Session', NULL);   -- succeeds\n\nSELECT count(*) FROM a;  -- 2\n```\n\nTwo identical rows under a unique index. This is standard SQL behaviour rather than a SQLite quirk, but it is easy to forget the moment you add a nullable column to a composite key.\n\nIt mattered here because Chron has legitimate NULL `ai_tool` rows: sessions created through the library API without a tool set.\n\nIndex an expression instead of the raw column:\n\n```\nCREATE UNIQUE INDEX idx_sessions_title_tool\n  ON sessions(title, COALESCE(ai_tool, ''));\n```\n\nNULL collapses to the empty string, which does compare equal to itself:\n\n```\nCREATE TABLE b (title TEXT NOT NULL, ai_tool TEXT);\nCREATE UNIQUE INDEX ib ON b(title, COALESCE(ai_tool, ''));\n\nINSERT INTO b VALUES ('Session', NULL);\nINSERT INTO b VALUES ('Session', NULL);      -- UNIQUE constraint failed\nINSERT INTO b VALUES ('Session', 'claude');  -- ok\nINSERT INTO b VALUES ('Session', 'cursor');  -- ok\n```\n\nOne row per tool, duplicates within a tool still rejected. That is the property I actually wanted.\n\nThe lookup code had to match, of course. Resume was searching by title alone, so even with the right constraint it would have kept finding the other tool's row:\n\n``` js\n// before\nconst existing = await db.select().from(sessions)\n  .where(eq(sessions.title, args.title));\n\n// after\nconst existing = await db.select().from(sessions)\n  .where(and(\n    eq(sessions.title, args.title),\n    requestedTool === null\n      ? isNull(sessions.ai_tool)\n      : eq(sessions.ai_tool, requestedTool),\n  ));\n```\n\nA constraint and the query that relies on it are one unit. Changing only one of them just moves the bug.\n\nSQLite has no `ALTER TABLE ... DROP CONSTRAINT`. The inline `UNIQUE` on `title` is part of the table definition, so removing it means rebuilding the table:\n\n```\nawait client.execute('PRAGMA foreign_keys = OFF');\nawait client.execute('BEGIN');\ntry {\n  await client.execute(`CREATE TABLE sessions_migration (\n    id TEXT PRIMARY KEY,\n    title TEXT NOT NULL,     -- no UNIQUE\n    ai_tool TEXT,\n    ...\n  )`);\n  await client.execute(\n    'INSERT INTO sessions_migration SELECT id,title,ai_tool,... FROM sessions'\n  );\n  await client.execute('DROP TABLE sessions');\n  await client.execute('ALTER TABLE sessions_migration RENAME TO sessions');\n  await client.execute('COMMIT');\n} catch (e) {\n  try { await client.execute('ROLLBACK'); } catch { /* ignore */ }\n  throw e;\n} finally {\n  await client.execute('PRAGMA foreign_keys = ON');\n}\n```\n\nStandard shadow-table dance. Nothing surprising.\n\nHere is the part worth the post.\n\nI first wrote the migration guard as: *if the composite index does not exist, migrate.* It reads perfectly sensibly, and it fails on exactly the databases that need it.\n\nThe schema bootstrap runs `CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_title_tool ...` **before** the migration check. On an existing database that still carries the legacy inline `UNIQUE(title)`, that statement succeeds. It creates the composite index on the old table. The old constraint is still sitting there, untouched.\n\nSo by the time the migration check runs, the index exists, the guard concludes \"already migrated\", and it skips. The table keeps `UNIQUE(title)` forever.\n\nFresh databases were fine, because they were created correctly from the start. The test suite passed, because tests build fresh databases. Every real user upgrading from an older version would have kept the bug.\n\nThe guard has to ask about the constraint, not about a side effect that usually correlates with it. The only way to see the constraint is the stored DDL:\n\n``` js\nconst info = await client.execute(\n  \"SELECT sql FROM sqlite_master WHERE type='table' AND name='sessions'\"\n);\nconst tableSql = String(info.rows[0]?.sql ?? '');\nconst hasLegacyTitleUnique =\n  /\\btitle\\s+TEXT\\s+NOT\\s+NULL\\s+UNIQUE\\b/i.test(tableSql);\n\nif (hasLegacyTitleUnique || compositeIdxMissing) {\n  // rebuild\n}\n```\n\nRegex against DDL is not elegant. It is, however, the thing that is actually true.\n\nAnd the test has to start from the old schema, not a fresh one:\n\n```\n// build a legacy database on purpose\nawait client.execute(`CREATE TABLE sessions (\n  id TEXT PRIMARY KEY,\n  title TEXT NOT NULL UNIQUE,   -- the old constraint\n  ai_tool TEXT, ...\n)`);\nawait client.execute(`INSERT INTO sessions VALUES ('old-claude', 'General assistance session', 'claude', ...)`);\n\nawait initDb(dbPath);\n\nconst table = await client.execute(\n  \"SELECT sql FROM sqlite_master WHERE type='table' AND name='sessions'\"\n);\nexpect(String(table.rows[0].sql)).not.toMatch(/\\btitle\\s+TEXT\\s+NOT\\s+NULL\\s+UNIQUE\\b/i);\n\n// cursor can now hold the same title\nawait client.execute(`INSERT INTO sessions VALUES ('new-cursor', 'General assistance session', 'cursor', ...)`);\n\n// but a duplicate within the same tool is still rejected\nawait expect(client.execute(\n  `INSERT INTO sessions VALUES ('dupe-claude', 'General assistance session', 'claude', ...)`\n)).rejects.toThrow();\n```\n\n**A UNIQUE constraint is an identity claim.** `UNIQUE(title)` asserted \"a title identifies a session\". That was never true, and the database enforced the false claim faithfully until the day two tools showed up.\n\n**Nullable columns in composite unique indexes usually do not do what you want.** If the column can be NULL, index an expression.\n\n**Migration guards should test for the thing you are fixing.** Not for a marker that normally accompanies it. Ordering inside your own bootstrap can invalidate the marker.\n\n**A migration test that starts from a fresh schema tests nothing.** Construct the old schema, put a row in it, then migrate. This is the one that nearly got me: the fix was correct, the suite was green, and real upgrades would have stayed broken.\n\nThat last point is the general shape of the lesson. Green tests told me the bug was fixed. They were testing a database that never had the bug.\n\nChron is on npm as `chron-mcp` if you want to look at the code, or run `npx chron-mcp` to point it at your own AI sessions.", "url": "https://wpnews.pro/news/my-ai-audit-tool-was-merging-claude-and-cursor-sessions-the-bug-was-one-unique", "canonical_source": "https://dev.to/sirinivask/my-ai-audit-tool-was-merging-claude-and-cursor-sessions-the-bug-was-one-unique-constraint-5amc", "published_at": "2026-09-07 18:12:25+00:00", "updated_at": "2026-09-07 18:32:25.282799+00:00", "lang": "en", "topics": ["developer-tools", "ai-tools"], "entities": ["Chron", "Claude", "Cursor", "Codex", "SQLite"], "alternates": {"html": "https://wpnews.pro/news/my-ai-audit-tool-was-merging-claude-and-cursor-sessions-the-bug-was-one-unique", "markdown": "https://wpnews.pro/news/my-ai-audit-tool-was-merging-claude-and-cursor-sessions-the-bug-was-one-unique.md", "text": "https://wpnews.pro/news/my-ai-audit-tool-was-merging-claude-and-cursor-sessions-the-bug-was-one-unique.txt", "jsonld": "https://wpnews.pro/news/my-ai-audit-tool-was-merging-claude-and-cursor-sessions-the-bug-was-one-unique.jsonld"}}