@m6d/cortex-server 2.3.0 → 2.4.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.
- package/contracts/src/runtime/index.ts +99 -3
- package/dist/contracts/src/graph/helpers.d.ts +5 -5
- package/dist/contracts/src/runtime/index.d.ts +175 -3
- package/dist/src/lib/adapters/database/index.d.ts +20 -2
- package/dist/src/lib/adapters/database/mssql/attachments.d.ts +9 -9
- package/dist/src/lib/adapters/database/mssql/index.d.ts +133 -28
- package/dist/src/lib/adapters/database/mssql/llm-requests.d.ts +3 -2
- package/dist/src/lib/adapters/database/mssql/messages.d.ts +6 -5
- package/dist/src/lib/adapters/database/mssql/outbox.d.ts +103 -0
- package/dist/src/lib/adapters/database/mssql/threads.d.ts +21 -14
- package/dist/src/lib/adapters/database/postgres/attachments.d.ts +9 -9
- package/dist/src/lib/adapters/database/postgres/index.d.ts +133 -28
- package/dist/src/lib/adapters/database/postgres/llm-requests.d.ts +3 -2
- package/dist/src/lib/adapters/database/postgres/messages.d.ts +6 -5
- package/dist/src/lib/adapters/database/postgres/outbox.d.ts +103 -0
- package/dist/src/lib/adapters/database/postgres/threads.d.ts +21 -14
- package/dist/src/lib/ai/attachments.d.ts +2 -2
- package/dist/src/lib/ai/client-tools.d.ts +1 -1
- package/dist/src/lib/auth/middleware.d.ts +2 -2
- package/dist/src/lib/auth/playground.d.ts +14 -0
- package/dist/src/lib/cc/client.d.ts +9 -1
- package/dist/src/lib/cc/flusher.d.ts +40 -0
- package/dist/src/lib/cc/sync-events.d.ts +45 -0
- package/dist/src/lib/cc/types.d.ts +1 -1
- package/dist/src/lib/config.d.ts +11 -0
- package/dist/src/lib/db/schema.mssql.d.ts +161 -0
- package/dist/src/lib/db/schema.pg.d.ts +199 -0
- package/dist/src/lib/factory.d.ts +3 -0
- package/dist/src/lib/routes/owned-thread.d.ts +5 -4
- package/package.json +1 -1
- package/src/lib/adapters/database/index.ts +15 -1
- package/src/lib/adapters/database/mssql/index.ts +13 -4
- package/src/lib/adapters/database/mssql/llm-requests.ts +35 -15
- package/src/lib/adapters/database/mssql/messages.ts +81 -30
- package/src/lib/adapters/database/mssql/outbox.ts +71 -0
- package/src/lib/adapters/database/mssql/threads.ts +42 -11
- package/src/lib/adapters/database/postgres/index.ts +13 -4
- package/src/lib/adapters/database/postgres/llm-requests.ts +35 -15
- package/src/lib/adapters/database/postgres/messages.ts +81 -30
- package/src/lib/adapters/database/postgres/outbox.ts +66 -0
- package/src/lib/adapters/database/postgres/threads.ts +39 -11
- package/src/lib/ai/finish-turn.ts +3 -1
- package/src/lib/ai/prompt.ts +3 -1
- package/src/lib/auth/middleware.ts +34 -8
- package/src/lib/auth/playground.ts +72 -0
- package/src/lib/cc/client.ts +21 -1
- package/src/lib/cc/flusher.ts +147 -0
- package/src/lib/cc/sync-events.ts +65 -0
- package/src/lib/cc/types.ts +3 -0
- package/src/lib/config.ts +11 -0
- package/src/lib/db/migrations/mssql/20260829050547_friendly_red_shift/migration.sql +8 -0
- package/src/lib/db/migrations/mssql/20260829050547_friendly_red_shift/snapshot.json +581 -0
- package/src/lib/db/migrations/mssql/20260830022424_easy_nicolaos/migration.sql +5 -0
- package/src/lib/db/migrations/mssql/20260830022424_easy_nicolaos/snapshot.json +614 -0
- package/src/lib/db/migrations/pg/20260829050547_perfect_wildside/migration.sql +7 -0
- package/src/lib/db/migrations/pg/20260829050547_perfect_wildside/snapshot.json +650 -0
- package/src/lib/db/migrations/pg/20260830022424_serious_deadpool/migration.sql +4 -0
- package/src/lib/db/migrations/pg/20260830022424_serious_deadpool/snapshot.json +690 -0
- package/src/lib/db/schema.mssql.ts +32 -1
- package/src/lib/db/schema.pg.ts +24 -0
- package/src/lib/factory.ts +22 -8
- 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(
|
|
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({
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
.
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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
|
|
85
|
-
.
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
.
|
|
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(
|
|
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
|
-
|
|
31
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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.
|
|
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(
|
|
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(
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
(
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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(
|
|
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({
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
.
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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
|
|
85
|
-
.
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
.
|
|
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
|
+
}
|