baa-conductor

git clone 

commit
8affb3b
parent
cf7aa3a
author
codex@macbookpro
date
2026-03-30 19:32:32 +0800 CST
fix: preserve created_at on renewal upserts
4 files changed,  +373, -6
M packages/artifact-db/src/index.test.js
+157, -0
  1@@ -17,6 +17,26 @@ import {
  2   ArtifactStore
  3 } from "../dist/index.js";
  4 
  5+const STORE_SOURCE = readFileSync(new URL("./store.ts", import.meta.url), "utf8");
  6+
  7+function extractStoreSql(name) {
  8+  const marker = `const ${name} = \``;
  9+  const start = STORE_SOURCE.indexOf(marker);
 10+  assert.notEqual(start, -1, `Could not find ${name} in store.ts`);
 11+
 12+  const sqlStart = start + marker.length;
 13+  const sqlEnd = STORE_SOURCE.indexOf("`;", sqlStart);
 14+  assert.notEqual(sqlEnd, -1, `Could not parse ${name} from store.ts`);
 15+
 16+  return STORE_SOURCE.slice(sqlStart, sqlEnd);
 17+}
 18+
 19+function getStoreDb(store) {
 20+  const db = Reflect.get(store, "db");
 21+  assert.ok(db instanceof DatabaseSync);
 22+  return db;
 23+}
 24+
 25 test("ArtifactStore writes message, execution, session, and index artifacts synchronously", async () => {
 26   const rootDir = mkdtempSync(join(tmpdir(), "artifact-db-test-"));
 27   const stateDir = join(rootDir, "state");
 28@@ -322,6 +342,143 @@ test("ArtifactStore persists renewal storage records and enqueues sync payloads"
 29   }
 30 });
 31 
 32+test("ArtifactStore UPSERT SQL preserves created_at for renewal storage rows on conflict", async () => {
 33+  const rootDir = mkdtempSync(join(tmpdir(), "artifact-db-created-at-upsert-test-"));
 34+  const stateDir = join(rootDir, "state");
 35+  const databasePath = join(stateDir, ARTIFACT_DB_FILENAME);
 36+  const artifactDir = join(stateDir, ARTIFACTS_DIRNAME);
 37+  const store = new ArtifactStore({
 38+    artifactDir,
 39+    databasePath
 40+  });
 41+
 42+  try {
 43+    const db = getStoreDb(store);
 44+    const upsertLocalConversationSql = extractStoreSql("UPSERT_LOCAL_CONVERSATION_SQL");
 45+    const upsertConversationLinkSql = extractStoreSql("UPSERT_CONVERSATION_LINK_SQL");
 46+    const upsertRenewalJobSql = extractStoreSql("UPSERT_RENEWAL_JOB_SQL");
 47+
 48+    await store.upsertLocalConversation({
 49+      automationStatus: "manual",
 50+      createdAt: 100,
 51+      localConversationId: "lc_created_at",
 52+      platform: "claude",
 53+      updatedAt: 110
 54+    });
 55+    db.prepare(upsertLocalConversationSql).run(
 56+      "lc_created_at",
 57+      "claude",
 58+      "auto",
 59+      "Updated title",
 60+      "Updated summary",
 61+      null,
 62+      null,
 63+      null,
 64+      null,
 65+      999,
 66+      220
 67+    );
 68+
 69+    const storedConversation = await store.getLocalConversation("lc_created_at");
 70+    assert.ok(storedConversation);
 71+    assert.equal(storedConversation.createdAt, 100);
 72+    assert.equal(storedConversation.updatedAt, 220);
 73+    assert.equal(storedConversation.automationStatus, "auto");
 74+    assert.equal(storedConversation.title, "Updated title");
 75+
 76+    await store.upsertConversationLink({
 77+      createdAt: 200,
 78+      linkId: "link_created_at",
 79+      localConversationId: "lc_created_at",
 80+      observedAt: 300,
 81+      pageTitle: "Before",
 82+      platform: "claude",
 83+      remoteConversationId: "conv_created_at",
 84+      updatedAt: 310
 85+    });
 86+    db.prepare(upsertConversationLinkSql).run(
 87+      "link_created_at",
 88+      "lc_created_at",
 89+      "claude",
 90+      "conv_created_at",
 91+      "firefox-claude",
 92+      "https://claude.ai/chat/conv_created_at",
 93+      "After",
 94+      "/chat/conv_created_at",
 95+      "/chat/:conversationId",
 96+      "{\"conversationId\":\"conv_created_at\"}",
 97+      "browser.proxy_delivery",
 98+      "tab:1",
 99+      "{\"tabId\":1}",
100+      0,
101+      400,
102+      999,
103+      410
104+    );
105+
106+    const storedLink = await store.getConversationLink("link_created_at");
107+    assert.ok(storedLink);
108+    assert.equal(storedLink.createdAt, 200);
109+    assert.equal(storedLink.updatedAt, 410);
110+    assert.equal(storedLink.pageTitle, "After");
111+    assert.equal(storedLink.isActive, false);
112+
113+    const message = await store.insertMessage({
114+      conversationId: "conv_created_at",
115+      createdAt: 500,
116+      id: "msg_created_at",
117+      observedAt: 500,
118+      platform: "claude",
119+      rawText: "续命消息",
120+      role: "assistant"
121+    });
122+    await store.insertRenewalJob({
123+      createdAt: 600,
124+      jobId: "job_created_at",
125+      localConversationId: "lc_created_at",
126+      messageId: message.id,
127+      payload: "[renew] ping",
128+      targetSnapshot: {
129+        clientId: "firefox-claude"
130+      },
131+      updatedAt: 610
132+    });
133+    db.prepare(upsertRenewalJobSql).run(
134+      "job_created_at",
135+      "lc_created_at",
136+      message.id,
137+      "running",
138+      "[renew] pong",
139+      "text",
140+      "{\"clientId\":\"firefox-claude\",\"targetId\":\"tab:1\"}",
141+      1,
142+      3,
143+      null,
144+      700,
145+      "temporary failure",
146+      "logs/renewal/created-at.jsonl",
147+      700,
148+      null,
149+      999,
150+      710
151+    );
152+
153+    const storedJob = await store.getRenewalJob("job_created_at");
154+    assert.ok(storedJob);
155+    assert.equal(storedJob.createdAt, 600);
156+    assert.equal(storedJob.updatedAt, 710);
157+    assert.equal(storedJob.status, "running");
158+    assert.equal(storedJob.payload, "[renew] pong");
159+    assert.match(storedJob.targetSnapshot, /tab:1/u);
160+  } finally {
161+    store.close();
162+    rmSync(rootDir, {
163+      force: true,
164+      recursive: true
165+    });
166+  }
167+});
168+
169 test("ArtifactStore findConversationLinkByRemoteConversation only returns active links", async () => {
170   const rootDir = mkdtempSync(join(tmpdir(), "artifact-db-renewal-active-link-test-"));
171   const stateDir = join(rootDir, "state");
M packages/artifact-db/src/store.ts
+27, -5
 1@@ -109,7 +109,7 @@ ON CONFLICT(id) DO UPDATE SET
 2   execution_count = excluded.execution_count,
 3   summary = excluded.summary,
 4   static_path = excluded.static_path,
 5-  created_at = excluded.created_at;
 6+  created_at = COALESCE(sessions.created_at, excluded.created_at);
 7 `;
 8 
 9 const UPSERT_LOCAL_CONVERSATION_SQL = `
10@@ -135,7 +135,7 @@ ON CONFLICT(local_conversation_id) DO UPDATE SET
11   last_message_at = excluded.last_message_at,
12   cooldown_until = excluded.cooldown_until,
13   paused_at = excluded.paused_at,
14-  created_at = excluded.created_at,
15+  created_at = COALESCE(local_conversations.created_at, excluded.created_at),
16   updated_at = excluded.updated_at;
17 `;
18 
19@@ -174,10 +174,32 @@ ON CONFLICT(link_id) DO UPDATE SET
20   target_payload = excluded.target_payload,
21   is_active = excluded.is_active,
22   observed_at = excluded.observed_at,
23-  created_at = excluded.created_at,
24+  created_at = COALESCE(conversation_links.created_at, excluded.created_at),
25   updated_at = excluded.updated_at;
26 `;
27 
28+const UPDATE_CONVERSATION_LINK_SQL = `
29+UPDATE conversation_links
30+SET
31+  local_conversation_id = ?,
32+  platform = ?,
33+  remote_conversation_id = ?,
34+  client_id = ?,
35+  page_url = ?,
36+  page_title = ?,
37+  route_path = ?,
38+  route_pattern = ?,
39+  route_params = ?,
40+  target_kind = ?,
41+  target_id = ?,
42+  target_payload = ?,
43+  is_active = ?,
44+  observed_at = ?,
45+  created_at = ?,
46+  updated_at = ?
47+WHERE link_id = ?;
48+`;
49+
50 const INSERT_RENEWAL_JOB_SQL = `
51 INSERT INTO renewal_jobs (
52   job_id,
53@@ -235,7 +257,7 @@ ON CONFLICT(job_id) DO UPDATE SET
54   log_path = excluded.log_path,
55   started_at = excluded.started_at,
56   finished_at = excluded.finished_at,
57-  created_at = excluded.created_at,
58+  created_at = COALESCE(renewal_jobs.created_at, excluded.created_at),
59   updated_at = excluded.updated_at;
60 `;
61 
62@@ -948,7 +970,7 @@ export class ArtifactStore {
63 
64       const merged = mergeConversationLinkRecords(canonical, candidate);
65       canonicalByKey.set(dedupeKey, merged);
66-      this.run(UPSERT_CONVERSATION_LINK_SQL, conversationLinkParams(merged));
67+      this.run(UPDATE_CONVERSATION_LINK_SQL, [...conversationLinkParams(merged).slice(1), merged.linkId]);
68       this.run("DELETE FROM conversation_links WHERE link_id = ?;", [candidate.linkId]);
69     }
70   }
M packages/d1-client/src/index.test.js
+180, -0
  1@@ -380,6 +380,186 @@ describe("D1SyncWorker", () => {
  2     }
  3   });
  4 
  5+  it("preserves created_at for renewal storage rows during D1 upsert conflicts", async () => {
  6+    const tmpDir = mkdtempSync(join(tmpdir(), "d1-test-"));
  7+    let queueDb = null;
  8+    let remoteDb = null;
  9+
 10+    try {
 11+      queueDb = new DatabaseSync(join(tmpDir, "queue.db"));
 12+      remoteDb = new DatabaseSync(join(tmpDir, "remote.db"));
 13+      remoteDb.exec(readFileSync(new URL("./d1-setup.sql", import.meta.url), "utf8"));
 14+
 15+      const queue = new SyncQueue(queueDb);
 16+      const worker = new D1SyncWorker({
 17+        d1: {
 18+          prepare(statement) {
 19+            const prepared = remoteDb.prepare(statement);
 20+            return {
 21+              async run(...params) {
 22+                prepared.run(...params);
 23+              }
 24+            };
 25+          }
 26+        },
 27+        queue,
 28+        log: () => {}
 29+      });
 30+
 31+      queue.enqueueSyncRecord({
 32+        tableName: "local_conversations",
 33+        recordId: "lc_immutable",
 34+        operation: "insert",
 35+        payload: {
 36+          local_conversation_id: "lc_immutable",
 37+          platform: "claude",
 38+          automation_status: "manual",
 39+          title: "Before",
 40+          created_at: 100,
 41+          updated_at: 110
 42+        }
 43+      });
 44+      queue.enqueueSyncRecord({
 45+        tableName: "messages",
 46+        recordId: "msg_immutable",
 47+        operation: "insert",
 48+        payload: {
 49+          id: "msg_immutable",
 50+          platform: "claude",
 51+          conversation_id: "conv_immutable",
 52+          role: "assistant",
 53+          raw_text: "hello",
 54+          observed_at: 200,
 55+          static_path: "/tmp/msg_immutable.txt",
 56+          created_at: 200
 57+        }
 58+      });
 59+      queue.enqueueSyncRecord({
 60+        tableName: "conversation_links",
 61+        recordId: "link_immutable",
 62+        operation: "insert",
 63+        payload: {
 64+          link_id: "link_immutable",
 65+          local_conversation_id: "lc_immutable",
 66+          platform: "claude",
 67+          remote_conversation_id: "conv_immutable",
 68+          page_title: "Before",
 69+          observed_at: 210,
 70+          created_at: 210,
 71+          updated_at: 220
 72+        }
 73+      });
 74+      queue.enqueueSyncRecord({
 75+        tableName: "renewal_jobs",
 76+        recordId: "job_immutable",
 77+        operation: "insert",
 78+        payload: {
 79+          job_id: "job_immutable",
 80+          local_conversation_id: "lc_immutable",
 81+          message_id: "msg_immutable",
 82+          status: "pending",
 83+          payload: "[renew]",
 84+          payload_kind: "text",
 85+          attempt_count: 0,
 86+          max_attempts: 3,
 87+          next_attempt_at: 300,
 88+          created_at: 300,
 89+          updated_at: 310
 90+        }
 91+      });
 92+
 93+      for (const record of queue.dequeuePendingSyncRecords(10)) {
 94+        await worker.syncRecord(record);
 95+      }
 96+
 97+      queue.enqueueSyncRecord({
 98+        tableName: "local_conversations",
 99+        recordId: "lc_immutable",
100+        operation: "update",
101+        payload: {
102+          local_conversation_id: "lc_immutable",
103+          platform: "claude",
104+          automation_status: "auto",
105+          title: "After",
106+          created_at: 999,
107+          updated_at: 410
108+        }
109+      });
110+      queue.enqueueSyncRecord({
111+        tableName: "conversation_links",
112+        recordId: "link_immutable",
113+        operation: "update",
114+        payload: {
115+          link_id: "link_immutable",
116+          local_conversation_id: "lc_immutable",
117+          platform: "claude",
118+          remote_conversation_id: "conv_immutable",
119+          page_title: "After",
120+          observed_at: 420,
121+          created_at: 999,
122+          updated_at: 430
123+        }
124+      });
125+      queue.enqueueSyncRecord({
126+        tableName: "renewal_jobs",
127+        recordId: "job_immutable",
128+        operation: "update",
129+        payload: {
130+          job_id: "job_immutable",
131+          local_conversation_id: "lc_immutable",
132+          message_id: "msg_immutable",
133+          status: "running",
134+          payload: "[renew] updated",
135+          payload_kind: "text",
136+          attempt_count: 1,
137+          max_attempts: 3,
138+          next_attempt_at: null,
139+          last_attempt_at: 430,
140+          created_at: 999,
141+          updated_at: 440
142+        }
143+      });
144+
145+      for (const record of queue.dequeuePendingSyncRecords(10)) {
146+        await worker.syncRecord(record);
147+      }
148+
149+      const localConversation = remoteDb.prepare(`
150+        SELECT created_at, updated_at, automation_status, title
151+        FROM local_conversations
152+        WHERE local_conversation_id = ?;
153+      `).get("lc_immutable");
154+      const conversationLink = remoteDb.prepare(`
155+        SELECT created_at, updated_at, page_title
156+        FROM conversation_links
157+        WHERE link_id = ?;
158+      `).get("link_immutable");
159+      const renewalJob = remoteDb.prepare(`
160+        SELECT created_at, updated_at, status, payload
161+        FROM renewal_jobs
162+        WHERE job_id = ?;
163+      `).get("job_immutable");
164+
165+      assert.equal(localConversation?.created_at, 100);
166+      assert.equal(localConversation?.updated_at, 410);
167+      assert.equal(localConversation?.automation_status, "auto");
168+      assert.equal(localConversation?.title, "After");
169+
170+      assert.equal(conversationLink?.created_at, 210);
171+      assert.equal(conversationLink?.updated_at, 430);
172+      assert.equal(conversationLink?.page_title, "After");
173+
174+      assert.equal(renewalJob?.created_at, 300);
175+      assert.equal(renewalJob?.updated_at, 440);
176+      assert.equal(renewalJob?.status, "running");
177+      assert.equal(renewalJob?.payload, "[renew] updated");
178+    } finally {
179+      queueDb?.close();
180+      remoteDb?.close();
181+      rmSync(tmpDir, { recursive: true, force: true });
182+    }
183+  });
184+
185   it("rejects records for non-whitelisted tables", async () => {
186     const tmpDir = mkdtempSync(join(tmpdir(), "d1-test-"));
187 
M packages/d1-client/src/sync-worker.ts
+9, -1
 1@@ -327,7 +327,7 @@ function buildUpsertSql(
 2 ): GeneratedSql {
 3   const placeholders = keys.map(() => "?").join(", ");
 4   const columns = keys.join(", ");
 5-  const updates = keys.map((k) => `${k} = excluded.${k}`).join(", ");
 6+  const updates = keys.map((k) => buildUpsertAssignment(tableName, k)).join(", ");
 7   const params = keys.map((k) => payload[k]);
 8 
 9   // Use INSERT OR REPLACE which covers both insert and update.
10@@ -338,6 +338,14 @@ function buildUpsertSql(
11   return { statement, params };
12 }
13 
14+function buildUpsertAssignment(tableName: string, columnName: string): string {
15+  if (columnName === "created_at") {
16+    return `${columnName} = COALESCE(${tableName}.created_at, excluded.created_at)`;
17+  }
18+
19+  return `${columnName} = excluded.${columnName}`;
20+}
21+
22 function buildDeleteSql(
23   tableName: string,
24   payload: Record<string, unknown>,