- 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
+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");
+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 }
+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
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>,