@m6d/cortex-server 2.3.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/contracts/src/runtime/index.ts +99 -3
  2. package/dist/contracts/src/graph/helpers.d.ts +5 -5
  3. package/dist/contracts/src/runtime/index.d.ts +175 -3
  4. package/dist/src/lib/adapters/database/index.d.ts +20 -2
  5. package/dist/src/lib/adapters/database/mssql/attachments.d.ts +9 -9
  6. package/dist/src/lib/adapters/database/mssql/index.d.ts +133 -28
  7. package/dist/src/lib/adapters/database/mssql/llm-requests.d.ts +3 -2
  8. package/dist/src/lib/adapters/database/mssql/messages.d.ts +6 -5
  9. package/dist/src/lib/adapters/database/mssql/outbox.d.ts +103 -0
  10. package/dist/src/lib/adapters/database/mssql/threads.d.ts +21 -14
  11. package/dist/src/lib/adapters/database/postgres/attachments.d.ts +9 -9
  12. package/dist/src/lib/adapters/database/postgres/index.d.ts +133 -28
  13. package/dist/src/lib/adapters/database/postgres/llm-requests.d.ts +3 -2
  14. package/dist/src/lib/adapters/database/postgres/messages.d.ts +6 -5
  15. package/dist/src/lib/adapters/database/postgres/outbox.d.ts +103 -0
  16. package/dist/src/lib/adapters/database/postgres/threads.d.ts +21 -14
  17. package/dist/src/lib/ai/attachments.d.ts +2 -2
  18. package/dist/src/lib/ai/client-tools.d.ts +1 -1
  19. package/dist/src/lib/auth/middleware.d.ts +2 -2
  20. package/dist/src/lib/auth/playground.d.ts +14 -0
  21. package/dist/src/lib/cc/client.d.ts +9 -1
  22. package/dist/src/lib/cc/flusher.d.ts +40 -0
  23. package/dist/src/lib/cc/sync-events.d.ts +45 -0
  24. package/dist/src/lib/cc/types.d.ts +1 -1
  25. package/dist/src/lib/config.d.ts +11 -0
  26. package/dist/src/lib/db/schema.mssql.d.ts +161 -0
  27. package/dist/src/lib/db/schema.pg.d.ts +199 -0
  28. package/dist/src/lib/factory.d.ts +3 -0
  29. package/dist/src/lib/routes/owned-thread.d.ts +5 -4
  30. package/package.json +2 -2
  31. package/src/lib/adapters/database/index.ts +15 -1
  32. package/src/lib/adapters/database/mssql/index.ts +13 -4
  33. package/src/lib/adapters/database/mssql/llm-requests.ts +35 -15
  34. package/src/lib/adapters/database/mssql/messages.ts +81 -30
  35. package/src/lib/adapters/database/mssql/outbox.ts +71 -0
  36. package/src/lib/adapters/database/mssql/threads.ts +42 -11
  37. package/src/lib/adapters/database/postgres/index.ts +13 -4
  38. package/src/lib/adapters/database/postgres/llm-requests.ts +35 -15
  39. package/src/lib/adapters/database/postgres/messages.ts +81 -30
  40. package/src/lib/adapters/database/postgres/outbox.ts +66 -0
  41. package/src/lib/adapters/database/postgres/threads.ts +39 -11
  42. package/src/lib/ai/finish-turn.ts +3 -1
  43. package/src/lib/ai/prompt.ts +3 -1
  44. package/src/lib/auth/middleware.ts +34 -8
  45. package/src/lib/auth/playground.ts +72 -0
  46. package/src/lib/cc/client.ts +21 -1
  47. package/src/lib/cc/flusher.ts +147 -0
  48. package/src/lib/cc/sync-events.ts +65 -0
  49. package/src/lib/cc/types.ts +3 -0
  50. package/src/lib/config.ts +11 -0
  51. package/src/lib/db/migrations/mssql/20260829050547_friendly_red_shift/migration.sql +8 -0
  52. package/src/lib/db/migrations/mssql/20260829050547_friendly_red_shift/snapshot.json +581 -0
  53. package/src/lib/db/migrations/mssql/20260830022424_easy_nicolaos/migration.sql +5 -0
  54. package/src/lib/db/migrations/mssql/20260830022424_easy_nicolaos/snapshot.json +614 -0
  55. package/src/lib/db/migrations/pg/20260829050547_perfect_wildside/migration.sql +7 -0
  56. package/src/lib/db/migrations/pg/20260829050547_perfect_wildside/snapshot.json +650 -0
  57. package/src/lib/db/migrations/pg/20260830022424_serious_deadpool/migration.sql +4 -0
  58. package/src/lib/db/migrations/pg/20260830022424_serious_deadpool/snapshot.json +690 -0
  59. package/src/lib/db/schema.mssql.ts +32 -1
  60. package/src/lib/db/schema.pg.ts +24 -0
  61. package/src/lib/factory.ts +22 -8
  62. package/src/lib/routes/threads.ts +5 -1
@@ -2,6 +2,7 @@
2
2
  import { and, desc, eq, getColumns, inArray, type InferInsertModel } from "drizzle-orm";
3
3
  import { threads, messages } from "@/db/schema.mssql";
4
4
  import type { ChatMessage } from "@/types";
5
+ import { messageUpsertEvent, threadUpsertEvent } from "@/cc/sync-events";
5
6
  import type { DatabaseAdapter } from "@/adapters/database/index";
6
7
  import {
7
8
  mergeStoredContent,
@@ -9,10 +10,14 @@ import {
9
10
  withOwnedAttachments,
10
11
  } from "@/adapters/database/message-content";
11
12
  import type { MssqlDb } from "./client";
13
+ import { enqueueSyncEvents } from "./outbox";
12
14
 
13
15
  const DEFAULT_MESSAGE_LIMIT = 100;
14
16
 
15
- export function createMessagesRepository(db: MssqlDb) {
17
+ export function createMessagesRepository(
18
+ db: MssqlDb,
19
+ enqueue: typeof enqueueSyncEvents = enqueueSyncEvents,
20
+ ) {
16
21
  return {
17
22
  /**
18
23
  * Fetches the newest N and reverses, so a limit keeps the most recent
@@ -45,7 +50,12 @@ export function createMessagesRepository(db: MssqlDb) {
45
50
  options?.replaceAttachments,
46
51
  );
47
52
  const existingMessages = await db
48
- .select({ id: messages.id, content: messages.content })
53
+ .select({
54
+ id: messages.id,
55
+ content: messages.content,
56
+ ordinal: messages.ordinal,
57
+ createdAt: messages.createdAt,
58
+ })
49
59
  .from(messages)
50
60
  .where(
51
61
  inArray(
@@ -54,39 +64,80 @@ export function createMessagesRepository(db: MssqlDb) {
54
64
  ),
55
65
  )
56
66
  .execute();
57
- const existingById = new Map(
58
- existingMessages.map((message) => [message.id, message.content]),
59
- );
67
+ const existingById = new Map(existingMessages.map((message) => [message.id, message]));
60
68
 
61
69
  const newMessages = incomingMessages.filter((x) => !existingById.has(x.id));
62
- if (newMessages.length) {
63
- await db
64
- .insert(messages)
65
- .values(
66
- newMessages.map(
67
- (x, ordinal) =>
68
- ({
69
- id: x.id,
70
- role: x.role,
71
- threadId,
72
- content: x,
73
- text: textOf(x),
74
- ordinal,
75
- }) satisfies InferInsertModel<typeof messages>,
76
- ),
77
- )
70
+ const updates = incomingMessages
71
+ .filter((x) => existingById.has(x.id))
72
+ .map((message) => {
73
+ const existing = existingById.get(message.id)!;
74
+ return {
75
+ existing,
76
+ content: mergeStoredContent(existing.content, message),
77
+ };
78
+ });
79
+ const now = new Date();
80
+ await db.transaction(async function (tx) {
81
+ const [thread] = await tx
82
+ .select()
83
+ .top(1)
84
+ .from(threads)
85
+ .where(eq(threads.id, threadId))
78
86
  .execute();
79
- }
87
+ if (newMessages.length) {
88
+ await tx
89
+ .insert(messages)
90
+ .values(
91
+ newMessages.map(
92
+ (x, ordinal) =>
93
+ ({
94
+ id: x.id,
95
+ role: x.role,
96
+ threadId,
97
+ content: x,
98
+ text: textOf(x),
99
+ ordinal,
100
+ createdAt: now,
101
+ }) satisfies InferInsertModel<typeof messages>,
102
+ ),
103
+ )
104
+ .execute();
105
+ }
80
106
 
81
- for (const message of incomingMessages.filter((x) => existingById.has(x.id))) {
82
- const content = mergeStoredContent(existingById.get(message.id)!, message);
107
+ for (const { content } of updates) {
108
+ await tx
109
+ .update(messages)
110
+ .set({ content, text: textOf(content) })
111
+ .where(eq(messages.id, content.id))
112
+ .execute();
113
+ }
83
114
 
84
- await db
85
- .update(messages)
86
- .set({ content, text: textOf(content) })
87
- .where(eq(messages.id, message.id))
88
- .execute();
89
- }
115
+ await enqueue(tx, [
116
+ // A current thread.upsert leads the batch so cc's thread
117
+ // row exists (and stays fresh) before its children.
118
+ ...(thread ? [threadUpsertEvent(thread)] : []),
119
+ ...newMessages.map((x, ordinal) =>
120
+ messageUpsertEvent({
121
+ threadId,
122
+ message: x,
123
+ text: textOf(x),
124
+ ordinal,
125
+ createdAt: now,
126
+ }),
127
+ ),
128
+ // Edits keep their original timestamp and slot so the
129
+ // mirrored transcript does not reorder.
130
+ ...updates.map(({ existing, content }) =>
131
+ messageUpsertEvent({
132
+ threadId,
133
+ message: content,
134
+ text: textOf(content),
135
+ ordinal: existing.ordinal,
136
+ createdAt: existing.createdAt,
137
+ }),
138
+ ),
139
+ ]);
140
+ });
90
141
  },
91
142
  } satisfies DatabaseAdapter["messages"];
92
143
  }
@@ -0,0 +1,71 @@
1
+ import { asc, inArray } from "drizzle-orm";
2
+ import type { SyncEvent } from "@cortex/contracts/runtime";
3
+ import { syncMeta, syncOutbox } from "@/db/schema.mssql";
4
+ import type { DatabaseAdapter } from "@/adapters/database/index";
5
+ import type { MssqlDb } from "./client";
6
+
7
+ /** A db or one of its transactions, so a write and its events commit together. */
8
+ type Executor = Pick<MssqlDb, "insert">;
9
+
10
+ export async function enqueueSyncEvents(executor: Executor, events: SyncEvent[]) {
11
+ if (!events.length) return;
12
+ await executor
13
+ .insert(syncOutbox)
14
+ .values(events.map((event) => ({ event })))
15
+ .execute();
16
+ }
17
+
18
+ export function createOutboxRepository(db: MssqlDb) {
19
+ return {
20
+ enqueue: (events) => enqueueSyncEvents(db, events),
21
+
22
+ async peek(limit) {
23
+ return await db
24
+ .select({ id: syncOutbox.id, event: syncOutbox.event })
25
+ .from(syncOutbox)
26
+ .orderBy(asc(syncOutbox.id))
27
+ .offset(0)
28
+ .fetch(limit)
29
+ .execute();
30
+ },
31
+
32
+ async epoch() {
33
+ const [row] = await db
34
+ .select({ epoch: syncMeta.epoch })
35
+ .from(syncMeta)
36
+ .orderBy(asc(syncMeta.id))
37
+ .offset(0)
38
+ .fetch(1)
39
+ .execute();
40
+ if (row) return row.epoch;
41
+
42
+ // First flush on a fresh database mints its generation id: a
43
+ // time-ordered UUIDv7, so cc can tell a later generation from a
44
+ // delayed batch out of a superseded one by plain comparison. A
45
+ // concurrent mint loses on the primary key (swallowed below) and
46
+ // reads the winner; anything else that leaves no persisted row
47
+ // aborts the flush rather than syncing under an epoch a restart
48
+ // would not repeat.
49
+ try {
50
+ await db.insert(syncMeta).values({ id: 1, epoch: Bun.randomUUIDv7() }).execute();
51
+ } catch {
52
+ // The reselect decides: a lost mint race reads the winner, a
53
+ // genuine write failure leaves nothing and throws below.
54
+ }
55
+ const [persisted] = await db
56
+ .select({ epoch: syncMeta.epoch })
57
+ .from(syncMeta)
58
+ .orderBy(asc(syncMeta.id))
59
+ .offset(0)
60
+ .fetch(1)
61
+ .execute();
62
+ if (!persisted) throw new Error("sync_meta epoch missing after mint");
63
+ return persisted.epoch;
64
+ },
65
+
66
+ async ack(ids) {
67
+ if (!ids.length) return;
68
+ await db.delete(syncOutbox).where(inArray(syncOutbox.id, ids)).execute();
69
+ },
70
+ } satisfies DatabaseAdapter["outbox"];
71
+ }
@@ -1,12 +1,18 @@
1
1
  import { and, desc, eq } from "drizzle-orm";
2
2
  import { attachments, threads } from "@/db/schema.mssql";
3
+ import { threadDeleteEvent, threadUpsertEvent } from "@/cc/sync-events";
3
4
  import type { Thread } from "@/types";
4
5
  import type { ThreadContextMeta } from "@/ai/context/types";
5
6
  import type { DatabaseAdapter } from "@/adapters/database/index";
6
7
  import type { MssqlDb } from "./client";
8
+ import { enqueueSyncEvents } from "./outbox";
7
9
  import type { StorageAdapter } from "@/adapters/storage/index";
8
10
 
9
- export function createThreadsRepository(db: MssqlDb, storage?: StorageAdapter) {
11
+ export function createThreadsRepository(
12
+ db: MssqlDb,
13
+ storage?: StorageAdapter,
14
+ enqueue: typeof enqueueSyncEvents = enqueueSyncEvents,
15
+ ) {
10
16
  return {
11
17
  async list(userId, agentId) {
12
18
  return await db
@@ -26,9 +32,17 @@ export function createThreadsRepository(db: MssqlDb, storage?: StorageAdapter) {
26
32
  return result.length ? (result[0] as Thread) : null;
27
33
  },
28
34
 
29
- async create(userId, agentId) {
30
- const result = await db.insert(threads).output().values({ userId, agentId }).execute();
31
- return result[0] as Thread;
35
+ async create(userId, agentId, options) {
36
+ return await db.transaction(async function (tx) {
37
+ const result = await tx
38
+ .insert(threads)
39
+ .output()
40
+ .values({ userId, agentId, isTest: options?.isTest ?? false })
41
+ .execute();
42
+ const thread = result[0] as Thread;
43
+ await enqueue(tx, [threadUpsertEvent(thread)]);
44
+ return thread;
45
+ });
32
46
  },
33
47
 
34
48
  /** Object storage has no foreign keys, so attachment paths must be swept before row cascade. */
@@ -44,13 +58,21 @@ export function createThreadsRepository(db: MssqlDb, storage?: StorageAdapter) {
44
58
  await storage.delete(attachmentPaths);
45
59
  }
46
60
 
47
- // Avoid SQL Server error 547 from the NO ACTION attachments.message_id constraint.
48
- await db.delete(attachments).where(eq(attachments.threadId, threadId)).execute();
61
+ await db.transaction(async function (tx) {
62
+ // Avoid SQL Server error 547 from the NO ACTION attachments.message_id constraint.
63
+ await tx.delete(attachments).where(eq(attachments.threadId, threadId)).execute();
49
64
 
50
- await db
51
- .delete(threads)
52
- .where(and(eq(threads.id, threadId), eq(threads.userId, userId)))
53
- .execute();
65
+ const deleted = await tx
66
+ .delete(threads)
67
+ .where(and(eq(threads.id, threadId), eq(threads.userId, userId)))
68
+ .output({ id: threads.id })
69
+ .execute();
70
+ // The ownership filter can match nothing; a delete event for
71
+ // someone else's thread id must not touch cc's mirror.
72
+ if (deleted.length) {
73
+ await enqueue(tx, [threadDeleteEvent(threadId)]);
74
+ }
75
+ });
54
76
  },
55
77
 
56
78
  async touch(threadId) {
@@ -65,7 +87,16 @@ export function createThreadsRepository(db: MssqlDb, storage?: StorageAdapter) {
65
87
  },
66
88
 
67
89
  async updateTitle(threadId, title) {
68
- await db.update(threads).set({ title }).where(eq(threads.id, threadId)).execute();
90
+ await db.transaction(async function (tx) {
91
+ const result = await tx
92
+ .update(threads)
93
+ .set({ title })
94
+ .where(eq(threads.id, threadId))
95
+ .output()
96
+ .execute();
97
+ const thread = result[0];
98
+ if (thread) await enqueue(tx, [threadUpsertEvent(thread)]);
99
+ });
69
100
  },
70
101
 
71
102
  async updateSession(threadId, session) {
@@ -5,19 +5,28 @@ import { createThreadsRepository } from "./threads";
5
5
  import { createMessagesRepository } from "./messages";
6
6
  import { createLlmRequestsRepository } from "./llm-requests";
7
7
  import { createAttachmentsRepository } from "./attachments";
8
+ import { createOutboxRepository, enqueueSyncEvents } from "./outbox";
8
9
 
9
10
  /**
10
11
  * The Postgres implementation of the database contract. One repository per
11
12
  * table; only threads takes storage, because deleting a thread is the one
12
13
  * operation that also has to reach into object storage.
13
14
  */
14
- export function createPostgresAdapter(connectionString: string, storage?: StorageAdapter) {
15
+ export function createPostgresAdapter(
16
+ connectionString: string,
17
+ storage?: StorageAdapter,
18
+ options?: { syncMirror?: boolean },
19
+ ) {
15
20
  const db = createPostgresDb(connectionString);
21
+ // Without a Control Center there is no flusher to drain the outbox, so
22
+ // enqueueing would only grow sync_outbox forever.
23
+ const enqueue = options?.syncMirror === false ? async () => {} : enqueueSyncEvents;
16
24
 
17
25
  return {
18
- threads: createThreadsRepository(db, storage),
19
- messages: createMessagesRepository(db),
20
- llmRequests: createLlmRequestsRepository(db),
26
+ threads: createThreadsRepository(db, storage, enqueue),
27
+ messages: createMessagesRepository(db, enqueue),
28
+ llmRequests: createLlmRequestsRepository(db, enqueue),
21
29
  attachments: createAttachmentsRepository(db),
30
+ outbox: createOutboxRepository(db),
22
31
  } satisfies DatabaseAdapter;
23
32
  }
@@ -2,29 +2,49 @@
2
2
  // Drizzle types query builders per dialect. See DatabaseAdapter in ../index.ts.
3
3
  import { asc, eq, type InferInsertModel } from "drizzle-orm";
4
4
  import { llmRequests } from "@/db/schema.pg";
5
+ import { usageRecordEvent } from "@/cc/sync-events";
5
6
  import type { DatabaseAdapter } from "@/adapters/database/index";
6
7
  import type { PostgresDb } from "./client";
8
+ import { enqueueSyncEvents } from "./outbox";
7
9
 
8
- export function createLlmRequestsRepository(db: PostgresDb) {
10
+ export function createLlmRequestsRepository(
11
+ db: PostgresDb,
12
+ enqueue: typeof enqueueSyncEvents = enqueueSyncEvents,
13
+ ) {
9
14
  return {
10
- async insert(requests) {
15
+ async insert(threadId, requests) {
11
16
  if (!requests.length) return;
12
17
 
13
- await db
14
- .insert(llmRequests)
15
- .values(
16
- requests.map(
17
- (r) =>
18
- ({
18
+ await db.transaction(async function (tx) {
19
+ await tx
20
+ .insert(llmRequests)
21
+ .values(
22
+ requests.map(
23
+ (r) =>
24
+ ({
25
+ messageId: r.messageId,
26
+ stepNumber: r.stepNumber,
27
+ prompt: r.prompt,
28
+ output: r.output,
29
+ tokenUsage: r.tokenUsage,
30
+ }) satisfies InferInsertModel<typeof llmRequests>,
31
+ ),
32
+ )
33
+ .execute();
34
+ await enqueue(
35
+ tx,
36
+ requests
37
+ .filter((r) => r.tokenUsage !== null)
38
+ .map((r) =>
39
+ usageRecordEvent({
40
+ threadId,
19
41
  messageId: r.messageId,
20
42
  stepNumber: r.stepNumber,
21
- prompt: r.prompt,
22
- output: r.output,
23
- tokenUsage: r.tokenUsage,
24
- }) satisfies InferInsertModel<typeof llmRequests>,
25
- ),
26
- )
27
- .execute();
43
+ tokenUsage: r.tokenUsage!,
44
+ }),
45
+ ),
46
+ );
47
+ });
28
48
  },
29
49
 
30
50
  /** Ordered by step so the inspector replays a turn in the order it ran. */
@@ -3,6 +3,7 @@
3
3
  import { and, desc, eq, getColumns, inArray, type InferInsertModel } from "drizzle-orm";
4
4
  import { threads, messages } from "@/db/schema.pg";
5
5
  import type { ChatMessage } from "@/types";
6
+ import { messageUpsertEvent, threadUpsertEvent } from "@/cc/sync-events";
6
7
  import type { DatabaseAdapter } from "@/adapters/database/index";
7
8
  import {
8
9
  mergeStoredContent,
@@ -10,10 +11,14 @@ import {
10
11
  withOwnedAttachments,
11
12
  } from "@/adapters/database/message-content";
12
13
  import type { PostgresDb } from "./client";
14
+ import { enqueueSyncEvents } from "./outbox";
13
15
 
14
16
  const DEFAULT_MESSAGE_LIMIT = 100;
15
17
 
16
- export function createMessagesRepository(db: PostgresDb) {
18
+ export function createMessagesRepository(
19
+ db: PostgresDb,
20
+ enqueue: typeof enqueueSyncEvents = enqueueSyncEvents,
21
+ ) {
17
22
  return {
18
23
  /**
19
24
  * Fetches the newest N and reverses, so a limit keeps the most recent
@@ -45,7 +50,12 @@ export function createMessagesRepository(db: PostgresDb) {
45
50
  options?.replaceAttachments,
46
51
  );
47
52
  const existingMessages = await db
48
- .select({ id: messages.id, content: messages.content })
53
+ .select({
54
+ id: messages.id,
55
+ content: messages.content,
56
+ ordinal: messages.ordinal,
57
+ createdAt: messages.createdAt,
58
+ })
49
59
  .from(messages)
50
60
  .where(
51
61
  inArray(
@@ -54,39 +64,80 @@ export function createMessagesRepository(db: PostgresDb) {
54
64
  ),
55
65
  )
56
66
  .execute();
57
- const existingById = new Map(
58
- existingMessages.map((message) => [message.id, message.content]),
59
- );
67
+ const existingById = new Map(existingMessages.map((message) => [message.id, message]));
60
68
 
61
69
  const newMessages = incomingMessages.filter((x) => !existingById.has(x.id));
62
- if (newMessages.length) {
63
- await db
64
- .insert(messages)
65
- .values(
66
- newMessages.map(
67
- (x, ordinal) =>
68
- ({
69
- id: x.id,
70
- role: x.role,
71
- threadId,
72
- content: x,
73
- text: textOf(x),
74
- ordinal,
75
- }) satisfies InferInsertModel<typeof messages>,
76
- ),
77
- )
70
+ const updates = incomingMessages
71
+ .filter((x) => existingById.has(x.id))
72
+ .map((message) => {
73
+ const existing = existingById.get(message.id)!;
74
+ return {
75
+ existing,
76
+ content: mergeStoredContent(existing.content, message),
77
+ };
78
+ });
79
+ const now = new Date();
80
+ await db.transaction(async function (tx) {
81
+ const [thread] = await tx
82
+ .select()
83
+ .from(threads)
84
+ .where(eq(threads.id, threadId))
85
+ .limit(1)
78
86
  .execute();
79
- }
87
+ if (newMessages.length) {
88
+ await tx
89
+ .insert(messages)
90
+ .values(
91
+ newMessages.map(
92
+ (x, ordinal) =>
93
+ ({
94
+ id: x.id,
95
+ role: x.role,
96
+ threadId,
97
+ content: x,
98
+ text: textOf(x),
99
+ ordinal,
100
+ createdAt: now,
101
+ }) satisfies InferInsertModel<typeof messages>,
102
+ ),
103
+ )
104
+ .execute();
105
+ }
80
106
 
81
- for (const message of incomingMessages.filter((x) => existingById.has(x.id))) {
82
- const content = mergeStoredContent(existingById.get(message.id)!, message);
107
+ for (const { content } of updates) {
108
+ await tx
109
+ .update(messages)
110
+ .set({ content, text: textOf(content) })
111
+ .where(eq(messages.id, content.id))
112
+ .execute();
113
+ }
83
114
 
84
- await db
85
- .update(messages)
86
- .set({ content, text: textOf(content) })
87
- .where(eq(messages.id, message.id))
88
- .execute();
89
- }
115
+ await enqueue(tx, [
116
+ // A current thread.upsert leads the batch so cc's thread
117
+ // row exists (and stays fresh) before its children.
118
+ ...(thread ? [threadUpsertEvent(thread)] : []),
119
+ ...newMessages.map((x, ordinal) =>
120
+ messageUpsertEvent({
121
+ threadId,
122
+ message: x,
123
+ text: textOf(x),
124
+ ordinal,
125
+ createdAt: now,
126
+ }),
127
+ ),
128
+ // Edits keep their original timestamp and slot so the
129
+ // mirrored transcript does not reorder.
130
+ ...updates.map(({ existing, content }) =>
131
+ messageUpsertEvent({
132
+ threadId,
133
+ message: content,
134
+ text: textOf(content),
135
+ ordinal: existing.ordinal,
136
+ createdAt: existing.createdAt,
137
+ }),
138
+ ),
139
+ ]);
140
+ });
90
141
  },
91
142
  } satisfies DatabaseAdapter["messages"];
92
143
  }
@@ -0,0 +1,66 @@
1
+ // fallow-ignore-file code-duplication -- mirrors ../mssql/outbox.ts by design;
2
+ // Drizzle types query builders per dialect. See DatabaseAdapter in ../index.ts.
3
+ import { asc, inArray } from "drizzle-orm";
4
+ import type { SyncEvent } from "@cortex/contracts/runtime";
5
+ import { syncMeta, syncOutbox } from "@/db/schema.pg";
6
+ import type { DatabaseAdapter } from "@/adapters/database/index";
7
+ import type { PostgresDb } from "./client";
8
+
9
+ /** A db or one of its transactions, so a write and its events commit together. */
10
+ type Executor = Pick<PostgresDb, "insert">;
11
+
12
+ export async function enqueueSyncEvents(executor: Executor, events: SyncEvent[]) {
13
+ if (!events.length) return;
14
+ await executor
15
+ .insert(syncOutbox)
16
+ .values(events.map((event) => ({ event })))
17
+ .execute();
18
+ }
19
+
20
+ export function createOutboxRepository(db: PostgresDb) {
21
+ return {
22
+ enqueue: (events) => enqueueSyncEvents(db, events),
23
+
24
+ async peek(limit) {
25
+ return await db
26
+ .select({ id: syncOutbox.id, event: syncOutbox.event })
27
+ .from(syncOutbox)
28
+ .orderBy(asc(syncOutbox.id))
29
+ .limit(limit)
30
+ .execute();
31
+ },
32
+
33
+ async epoch() {
34
+ const [row] = await db
35
+ .select({ epoch: syncMeta.epoch })
36
+ .from(syncMeta)
37
+ .limit(1)
38
+ .execute();
39
+ if (row) return row.epoch;
40
+
41
+ // First flush on a fresh database mints its generation id: a
42
+ // time-ordered UUIDv7, so cc can tell a later generation from a
43
+ // delayed batch out of a superseded one by plain comparison. A
44
+ // concurrent mint loses on the primary key and reads the winner;
45
+ // anything else that leaves no persisted row aborts the flush
46
+ // rather than syncing under an epoch a restart would not repeat.
47
+ await db
48
+ .insert(syncMeta)
49
+ .values({ id: 1, epoch: Bun.randomUUIDv7() })
50
+ .onConflictDoNothing()
51
+ .execute();
52
+ const [persisted] = await db
53
+ .select({ epoch: syncMeta.epoch })
54
+ .from(syncMeta)
55
+ .limit(1)
56
+ .execute();
57
+ if (!persisted) throw new Error("sync_meta epoch missing after mint");
58
+ return persisted.epoch;
59
+ },
60
+
61
+ async ack(ids) {
62
+ if (!ids.length) return;
63
+ await db.delete(syncOutbox).where(inArray(syncOutbox.id, ids)).execute();
64
+ },
65
+ } satisfies DatabaseAdapter["outbox"];
66
+ }