@axiom-lattice/local-stores 3.1.0 → 3.1.2
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/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +18 -0
- package/dist/index.d.mts +193 -22
- package/dist/index.d.ts +193 -22
- package/dist/index.js +1482 -242
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1479 -242
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/LocalCapabilityBundleStore.test.ts +216 -0
- package/src/__tests__/LocalChannelBindingStore.test.ts +183 -0
- package/src/__tests__/LocalChannelInstallationStore.test.ts +23 -0
- package/src/__tests__/LocalProjectRoomStores.test.ts +356 -0
- package/src/__tests__/LocalProjectStore.test.ts +39 -2
- package/src/__tests__/LocalTaskStore.test.ts +58 -0
- package/src/__tests__/LocalTaskWorkItemStore.test.ts +199 -0
- package/src/__tests__/LocalThreadMessageQueueStore.test.ts +201 -0
- package/src/createLocalStoreConfig.ts +10 -0
- package/src/index.ts +5 -0
- package/src/migrations/migration.ts +1 -1
- package/src/stores/LocalCapabilityBundleStore.ts +53 -0
- package/src/stores/LocalChannelBindingStore.ts +75 -41
- package/src/stores/LocalChannelInstallationStore.ts +6 -5
- package/src/stores/LocalProjectBotMembershipStore.ts +105 -0
- package/src/stores/LocalProjectMembershipStore.ts +72 -0
- package/src/stores/LocalProjectRoomMessageStore.ts +63 -0
- package/src/stores/LocalProjectRoomStore.ts +37 -0
- package/src/stores/LocalProjectStore.ts +67 -14
- package/src/stores/LocalTaskStore.ts +82 -2
- package/src/stores/LocalTaskWorkItemStore.ts +136 -1
- package/src/stores/LocalThreadMessageQueueStore.ts +136 -28
- package/src/stores/project-room-schema.ts +243 -0
- package/src/stores/room-channel-binding-index.ts +60 -0
|
@@ -8,7 +8,14 @@ import type {
|
|
|
8
8
|
PendingMessage,
|
|
9
9
|
AddMessageParams,
|
|
10
10
|
ThreadInfo,
|
|
11
|
+
QueueScope,
|
|
11
12
|
} from "@axiom-lattice/core";
|
|
13
|
+
import {
|
|
14
|
+
parseQueuedExecutionMode,
|
|
15
|
+
parseTrustedRunContext,
|
|
16
|
+
type QueuedExecutionMode,
|
|
17
|
+
type TrustedRunContext,
|
|
18
|
+
} from "@axiom-lattice/protocols";
|
|
12
19
|
import { ensureTable, nowISO, parseISO } from "../database";
|
|
13
20
|
import { randomUUID } from "crypto";
|
|
14
21
|
|
|
@@ -27,6 +34,8 @@ CREATE TABLE IF NOT EXISTS lt_thread_message_queue (
|
|
|
27
34
|
status TEXT NOT NULL DEFAULT 'pending',
|
|
28
35
|
command TEXT,
|
|
29
36
|
custom_run_config TEXT,
|
|
37
|
+
trusted_run_context TEXT,
|
|
38
|
+
execution_mode TEXT CHECK (execution_mode IS NULL OR execution_mode = 'followup'),
|
|
30
39
|
created_at TEXT NOT NULL
|
|
31
40
|
);
|
|
32
41
|
CREATE INDEX IF NOT EXISTS idx_lt_tmq_thread ON lt_thread_message_queue(thread_id, status);
|
|
@@ -47,18 +56,30 @@ interface QueueRow {
|
|
|
47
56
|
status: string;
|
|
48
57
|
command: string | null;
|
|
49
58
|
custom_run_config: string | null;
|
|
59
|
+
trusted_run_context: string | null;
|
|
60
|
+
execution_mode: string | null;
|
|
50
61
|
created_at: string;
|
|
51
62
|
}
|
|
52
63
|
|
|
53
64
|
export class LocalThreadMessageQueueStore implements IMessageQueueStore {
|
|
54
65
|
private db: DatabaseWrapper;
|
|
66
|
+
private capacityTail: Promise<void> = Promise.resolve();
|
|
55
67
|
|
|
56
68
|
constructor(db: DatabaseWrapper) {
|
|
57
69
|
this.db = db;
|
|
58
70
|
ensureTable(db, DDL);
|
|
71
|
+
const columns = db.prepare("PRAGMA table_info(lt_thread_message_queue)").all() as Array<{ name: string }>;
|
|
72
|
+
const names = new Set(columns.map(({ name }) => name));
|
|
73
|
+
if (!names.has("trusted_run_context")) {
|
|
74
|
+
db.run("ALTER TABLE lt_thread_message_queue ADD COLUMN trusted_run_context TEXT");
|
|
75
|
+
}
|
|
76
|
+
if (!names.has("execution_mode")) {
|
|
77
|
+
db.run("ALTER TABLE lt_thread_message_queue ADD COLUMN execution_mode TEXT CHECK (execution_mode IS NULL OR execution_mode = 'followup')");
|
|
78
|
+
}
|
|
59
79
|
}
|
|
60
80
|
|
|
61
81
|
async addMessage(params: AddMessageParams): Promise<PendingMessage> {
|
|
82
|
+
const trusted = validateQueueTrust(params);
|
|
62
83
|
const now = nowISO();
|
|
63
84
|
const id = params.id || randomUUID();
|
|
64
85
|
|
|
@@ -68,22 +89,66 @@ export class LocalThreadMessageQueueStore implements IMessageQueueStore {
|
|
|
68
89
|
const nextSeq = seqRow.next_seq;
|
|
69
90
|
|
|
70
91
|
this.db.prepare(
|
|
71
|
-
`INSERT INTO lt_thread_message_queue (id, thread_id, tenant_id, assistant_id, workspace_id, project_id, message_content, message_type, sequence_order, priority, command, custom_run_config, created_at)
|
|
72
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
92
|
+
`INSERT INTO lt_thread_message_queue (id, thread_id, tenant_id, assistant_id, workspace_id, project_id, message_content, message_type, sequence_order, priority, command, custom_run_config, trusted_run_context, execution_mode, created_at)
|
|
93
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
73
94
|
).run(
|
|
74
95
|
id, params.threadId, params.tenantId, params.assistantId,
|
|
75
|
-
|
|
96
|
+
params.workspaceId ?? null, params.projectId ?? null,
|
|
76
97
|
JSON.stringify(params.content), params.type || "human", nextSeq,
|
|
77
|
-
|
|
98
|
+
params.priority ?? 0,
|
|
78
99
|
params.command ? JSON.stringify(params.command) : null,
|
|
79
100
|
params.custom_run_config ? JSON.stringify(params.custom_run_config) : null,
|
|
101
|
+
trusted.trustedRunContext ? JSON.stringify(trusted.trustedRunContext) : null,
|
|
102
|
+
trusted.executionMode ?? null,
|
|
80
103
|
now,
|
|
81
104
|
);
|
|
82
105
|
|
|
83
106
|
return this.getById(id);
|
|
84
107
|
}
|
|
85
108
|
|
|
109
|
+
async addMessageIfCapacity(params: AddMessageParams, maxSize: number): Promise<boolean> {
|
|
110
|
+
const trusted = validateQueueTrust(params);
|
|
111
|
+
let release!: () => void;
|
|
112
|
+
const previous = this.capacityTail;
|
|
113
|
+
this.capacityTail = new Promise<void>((resolve) => { release = resolve; });
|
|
114
|
+
await previous;
|
|
115
|
+
try {
|
|
116
|
+
const scope = {
|
|
117
|
+
tenantId: params.tenantId,
|
|
118
|
+
assistantId: params.assistantId,
|
|
119
|
+
workspaceId: params.workspaceId,
|
|
120
|
+
projectId: params.projectId,
|
|
121
|
+
};
|
|
122
|
+
const filter = scopeClause(scope);
|
|
123
|
+
const id = params.id || randomUUID();
|
|
124
|
+
const limit = maxSize === Infinity ? -1 : maxSize;
|
|
125
|
+
const result = this.db.prepare(
|
|
126
|
+
`INSERT INTO lt_thread_message_queue (id, thread_id, tenant_id, assistant_id, workspace_id, project_id, message_content, message_type, sequence_order, priority, command, custom_run_config, trusted_run_context, execution_mode, created_at)
|
|
127
|
+
SELECT ?, ?, ?, ?, ?, ?, ?, ?,
|
|
128
|
+
(SELECT COALESCE(MAX(sequence_order), 0) + 1 FROM lt_thread_message_queue WHERE thread_id = ?),
|
|
129
|
+
?, ?, ?, ?, ?,
|
|
130
|
+
?
|
|
131
|
+
WHERE ? = -1 OR (SELECT COUNT(*) FROM lt_thread_message_queue WHERE thread_id = ? AND status = 'pending'${filter.sql}) < ?`,
|
|
132
|
+
).run(
|
|
133
|
+
id, params.threadId, params.tenantId, params.assistantId,
|
|
134
|
+
params.workspaceId ?? null, params.projectId ?? null,
|
|
135
|
+
JSON.stringify(params.content), params.type || "human",
|
|
136
|
+
params.threadId,
|
|
137
|
+
params.priority ?? 0,
|
|
138
|
+
params.command ? JSON.stringify(params.command) : null,
|
|
139
|
+
params.custom_run_config ? JSON.stringify(params.custom_run_config) : null,
|
|
140
|
+
trusted.trustedRunContext ? JSON.stringify(trusted.trustedRunContext) : null,
|
|
141
|
+
trusted.executionMode ?? null,
|
|
142
|
+
nowISO(), limit, params.threadId, ...filter.params, limit,
|
|
143
|
+
);
|
|
144
|
+
return result.changes > 0;
|
|
145
|
+
} finally {
|
|
146
|
+
release();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
86
150
|
async addMessageAtHead(params: AddMessageParams): Promise<PendingMessage> {
|
|
151
|
+
const trusted = validateQueueTrust(params);
|
|
87
152
|
const now = nowISO();
|
|
88
153
|
const id = params.id || randomUUID();
|
|
89
154
|
|
|
@@ -92,71 +157,86 @@ export class LocalThreadMessageQueueStore implements IMessageQueueStore {
|
|
|
92
157
|
).get(params.threadId) as { next_seq: number };
|
|
93
158
|
|
|
94
159
|
this.db.prepare(
|
|
95
|
-
`INSERT INTO lt_thread_message_queue (id, thread_id, tenant_id, assistant_id, workspace_id, project_id, message_content, message_type, sequence_order, priority, command, custom_run_config, created_at)
|
|
96
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 100, ?, ?, ?)`,
|
|
160
|
+
`INSERT INTO lt_thread_message_queue (id, thread_id, tenant_id, assistant_id, workspace_id, project_id, message_content, message_type, sequence_order, priority, command, custom_run_config, trusted_run_context, execution_mode, created_at)
|
|
161
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 100, ?, ?, ?, ?, ?)`,
|
|
97
162
|
).run(
|
|
98
163
|
id, params.threadId, params.tenantId!, params.assistantId!,
|
|
99
|
-
params.workspaceId
|
|
164
|
+
params.workspaceId ?? null, params.projectId ?? null,
|
|
100
165
|
JSON.stringify(params.content), params.type || "human", seqRow.next_seq,
|
|
101
166
|
params.command ? JSON.stringify(params.command) : null,
|
|
102
167
|
params.custom_run_config ? JSON.stringify(params.custom_run_config) : null,
|
|
168
|
+
trusted.trustedRunContext ? JSON.stringify(trusted.trustedRunContext) : null,
|
|
169
|
+
trusted.executionMode ?? null,
|
|
103
170
|
now,
|
|
104
171
|
);
|
|
105
172
|
|
|
106
173
|
return this.getById(id);
|
|
107
174
|
}
|
|
108
175
|
|
|
109
|
-
async getPendingMessages(threadId: string): Promise<PendingMessage[]> {
|
|
176
|
+
async getPendingMessages(threadId: string, scope?: QueueScope): Promise<PendingMessage[]> {
|
|
177
|
+
const filter = scopeClause(scope);
|
|
110
178
|
const rows = this.db.prepare(
|
|
111
|
-
`SELECT * FROM lt_thread_message_queue WHERE thread_id = ? AND status = 'pending' ORDER BY priority DESC, sequence_order ASC`,
|
|
112
|
-
).all(threadId) as unknown as QueueRow[];
|
|
179
|
+
`SELECT * FROM lt_thread_message_queue WHERE thread_id = ? AND status = 'pending'${filter.sql} ORDER BY priority DESC, sequence_order ASC`,
|
|
180
|
+
).all(threadId, ...filter.params) as unknown as QueueRow[];
|
|
113
181
|
return rows.map(rowToMessage);
|
|
114
182
|
}
|
|
115
183
|
|
|
116
|
-
async getProcessingMessages(threadId: string): Promise<PendingMessage[]> {
|
|
184
|
+
async getProcessingMessages(threadId: string, scope?: QueueScope): Promise<PendingMessage[]> {
|
|
185
|
+
const filter = scopeClause(scope);
|
|
117
186
|
const rows = this.db.prepare(
|
|
118
|
-
`SELECT * FROM lt_thread_message_queue WHERE thread_id = ? AND status = 'processing' ORDER BY priority DESC, sequence_order ASC`,
|
|
119
|
-
).all(threadId) as unknown as QueueRow[];
|
|
187
|
+
`SELECT * FROM lt_thread_message_queue WHERE thread_id = ? AND status = 'processing'${filter.sql} ORDER BY priority DESC, sequence_order ASC`,
|
|
188
|
+
).all(threadId, ...filter.params) as unknown as QueueRow[];
|
|
120
189
|
return rows.map(rowToMessage);
|
|
121
190
|
}
|
|
122
191
|
|
|
123
|
-
async getQueueSize(threadId: string): Promise<number> {
|
|
192
|
+
async getQueueSize(threadId: string, scope?: QueueScope): Promise<number> {
|
|
193
|
+
const filter = scopeClause(scope);
|
|
124
194
|
const row = this.db.prepare(
|
|
125
|
-
`SELECT COUNT(*) as count FROM lt_thread_message_queue WHERE thread_id = ? AND status = 'pending'`,
|
|
126
|
-
).get(threadId) as { count: number };
|
|
195
|
+
`SELECT COUNT(*) as count FROM lt_thread_message_queue WHERE thread_id = ? AND status = 'pending'${filter.sql}`,
|
|
196
|
+
).get(threadId, ...filter.params) as { count: number };
|
|
127
197
|
return row.count;
|
|
128
198
|
}
|
|
129
199
|
|
|
130
200
|
async getThreadsWithPendingMessages(): Promise<ThreadInfo[]> {
|
|
131
201
|
const rows = this.db.prepare(
|
|
132
|
-
`SELECT
|
|
202
|
+
`SELECT tenant_id, assistant_id, thread_id, workspace_id, project_id FROM lt_thread_message_queue WHERE status IN ('pending', 'processing') GROUP BY tenant_id, assistant_id, thread_id, workspace_id, project_id ORDER BY thread_id`,
|
|
133
203
|
).all() as { tenant_id: string; assistant_id: string; thread_id: string; workspace_id: string | null; project_id: string | null }[];
|
|
134
204
|
return rows.map((r) => ({
|
|
135
205
|
tenantId: r.tenant_id,
|
|
136
206
|
assistantId: r.assistant_id,
|
|
137
207
|
threadId: r.thread_id,
|
|
138
|
-
workspaceId: r.workspace_id
|
|
139
|
-
projectId: r.project_id
|
|
208
|
+
workspaceId: r.workspace_id,
|
|
209
|
+
projectId: r.project_id,
|
|
140
210
|
}));
|
|
141
211
|
}
|
|
142
212
|
|
|
143
|
-
async removeMessage(messageId: string): Promise<boolean> {
|
|
144
|
-
const
|
|
213
|
+
async removeMessage(messageId: string, scope?: QueueScope): Promise<boolean> {
|
|
214
|
+
const filter = scopeClause(scope);
|
|
215
|
+
const result = this.db.prepare(`DELETE FROM lt_thread_message_queue WHERE id = ?${filter.sql}`).run(messageId, ...filter.params);
|
|
145
216
|
return result.changes > 0;
|
|
146
217
|
}
|
|
147
218
|
|
|
148
|
-
async clearMessages(threadId: string): Promise<void> {
|
|
149
|
-
|
|
219
|
+
async clearMessages(threadId: string, scope?: QueueScope): Promise<void> {
|
|
220
|
+
const filter = scopeClause(scope);
|
|
221
|
+
this.db.prepare(`DELETE FROM lt_thread_message_queue WHERE thread_id = ?${filter.sql}`).run(threadId, ...filter.params);
|
|
150
222
|
}
|
|
151
223
|
|
|
152
|
-
async markProcessing(messageId: string): Promise<void> {
|
|
153
|
-
|
|
224
|
+
async markProcessing(messageId: string, customRunConfig?: unknown, scope?: QueueScope): Promise<void> {
|
|
225
|
+
const filter = scopeClause(scope);
|
|
226
|
+
if (customRunConfig === undefined) {
|
|
227
|
+
this.db.prepare(`UPDATE lt_thread_message_queue SET status = 'processing' WHERE id = ?${filter.sql}`).run(messageId, ...filter.params);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
this.db.prepare(
|
|
231
|
+
`UPDATE lt_thread_message_queue SET status = 'processing', custom_run_config = ? WHERE id = ?${filter.sql}`,
|
|
232
|
+
).run(JSON.stringify(customRunConfig), messageId, ...filter.params);
|
|
154
233
|
}
|
|
155
234
|
|
|
156
|
-
async resetProcessingToPending(threadId: string): Promise<number> {
|
|
235
|
+
async resetProcessingToPending(threadId: string, scope?: QueueScope): Promise<number> {
|
|
236
|
+
const filter = scopeClause(scope);
|
|
157
237
|
const result = this.db.prepare(
|
|
158
|
-
`UPDATE lt_thread_message_queue SET status = 'pending' WHERE thread_id = ? AND status = 'processing'`,
|
|
159
|
-
).run(threadId);
|
|
238
|
+
`UPDATE lt_thread_message_queue SET status = 'pending' WHERE thread_id = ? AND status = 'processing'${filter.sql}`,
|
|
239
|
+
).run(threadId, ...filter.params);
|
|
160
240
|
return result.changes;
|
|
161
241
|
}
|
|
162
242
|
|
|
@@ -166,6 +246,30 @@ export class LocalThreadMessageQueueStore implements IMessageQueueStore {
|
|
|
166
246
|
}
|
|
167
247
|
}
|
|
168
248
|
|
|
249
|
+
function validateQueueTrust(params: AddMessageParams): {
|
|
250
|
+
trustedRunContext: TrustedRunContext | undefined;
|
|
251
|
+
executionMode: QueuedExecutionMode | undefined;
|
|
252
|
+
} {
|
|
253
|
+
return {
|
|
254
|
+
trustedRunContext: params.trusted_run_context === undefined
|
|
255
|
+
? undefined
|
|
256
|
+
: parseTrustedRunContext(params.trusted_run_context),
|
|
257
|
+
executionMode: params.execution_mode === undefined
|
|
258
|
+
? undefined
|
|
259
|
+
: parseQueuedExecutionMode(params.execution_mode),
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function scopeClause(scope?: QueueScope): { sql: string; params: unknown[] } {
|
|
264
|
+
if (!scope) return { sql: "", params: [] };
|
|
265
|
+
const entries = (["tenantId", "assistantId", "workspaceId", "projectId"] as const)
|
|
266
|
+
.map((key) => [key, scope[key]] as const);
|
|
267
|
+
return {
|
|
268
|
+
sql: entries.map(([key, value]) => ` AND ${key.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`)} ${value == null ? "IS NULL" : "= ?"}`).join(""),
|
|
269
|
+
params: entries.flatMap(([, value]) => value == null ? [] : [value]),
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
169
273
|
function rowToMessage(row: QueueRow): PendingMessage {
|
|
170
274
|
return {
|
|
171
275
|
id: row.id,
|
|
@@ -176,5 +280,9 @@ function rowToMessage(row: QueueRow): PendingMessage {
|
|
|
176
280
|
priority: row.priority || 0,
|
|
177
281
|
command: row.command ? JSON.parse(row.command) : undefined,
|
|
178
282
|
custom_run_config: row.custom_run_config ? JSON.parse(row.custom_run_config) : undefined,
|
|
283
|
+
trusted_run_context: row.trusted_run_context
|
|
284
|
+
? parseTrustedRunContext(JSON.parse(row.trusted_run_context))
|
|
285
|
+
: undefined,
|
|
286
|
+
execution_mode: row.execution_mode == null ? undefined : parseQueuedExecutionMode(row.execution_mode),
|
|
179
287
|
};
|
|
180
288
|
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { DatabaseWrapper } from "../database";
|
|
2
|
+
|
|
3
|
+
const schemaMarker = Symbol("projectRoomSchema");
|
|
4
|
+
type SchemaDatabase = DatabaseWrapper & { [schemaMarker]?: boolean };
|
|
5
|
+
type ColumnRow = { name: unknown; type: unknown; notnull: unknown };
|
|
6
|
+
type IndexRow = { name: unknown; unique: unknown; partial: unknown };
|
|
7
|
+
type IndexColumnRow = { name: unknown };
|
|
8
|
+
const activeTransactions = new WeakSet<DatabaseWrapper>();
|
|
9
|
+
const migrationName = "create_project_room_tables";
|
|
10
|
+
const migrationTableDdl = `CREATE TABLE IF NOT EXISTS lattice_schema_migrations (
|
|
11
|
+
store_name TEXT NOT NULL, name TEXT NOT NULL, version INTEGER NOT NULL, applied_at TEXT NOT NULL,
|
|
12
|
+
PRIMARY KEY (store_name, name)
|
|
13
|
+
)`;
|
|
14
|
+
|
|
15
|
+
const ddl = `
|
|
16
|
+
CREATE TABLE IF NOT EXISTS lt_project_rooms (
|
|
17
|
+
id TEXT NOT NULL, tenant_id TEXT NOT NULL, workspace_id TEXT NOT NULL, project_id TEXT NOT NULL,
|
|
18
|
+
type TEXT NOT NULL CHECK (type = 'main'), name TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
|
|
19
|
+
PRIMARY KEY (tenant_id, id), UNIQUE (tenant_id, project_id, type)
|
|
20
|
+
);
|
|
21
|
+
CREATE TABLE IF NOT EXISTS lt_project_memberships (
|
|
22
|
+
id TEXT NOT NULL, tenant_id TEXT NOT NULL, project_id TEXT NOT NULL, user_id TEXT NOT NULL,
|
|
23
|
+
role TEXT NOT NULL CHECK (role IN ('owner','admin','member','viewer')),
|
|
24
|
+
status TEXT NOT NULL CHECK (status IN ('active','removed')), joined_at TEXT NOT NULL, updated_at TEXT NOT NULL,
|
|
25
|
+
PRIMARY KEY (tenant_id, id), UNIQUE (tenant_id, project_id, user_id)
|
|
26
|
+
);
|
|
27
|
+
CREATE INDEX IF NOT EXISTS idx_lt_project_memberships_project ON lt_project_memberships (tenant_id, project_id);
|
|
28
|
+
CREATE TABLE IF NOT EXISTS lt_project_bot_memberships (
|
|
29
|
+
id TEXT NOT NULL, tenant_id TEXT NOT NULL, workspace_id TEXT NOT NULL, project_id TEXT NOT NULL,
|
|
30
|
+
room_id TEXT NOT NULL, assistant_id TEXT NOT NULL, role TEXT NOT NULL CHECK (role IN ('coordinator','specialist')),
|
|
31
|
+
title TEXT NOT NULL, responsibility TEXT, mention_name TEXT NOT NULL,
|
|
32
|
+
status TEXT NOT NULL CHECK (status IN ('active','paused','removed')), room_thread_id TEXT NOT NULL,
|
|
33
|
+
joined_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY (tenant_id, id),
|
|
34
|
+
UNIQUE (tenant_id, project_id, assistant_id)
|
|
35
|
+
);
|
|
36
|
+
CREATE UNIQUE INDEX IF NOT EXISTS uq_lt_project_bot_memberships_coordinator
|
|
37
|
+
ON lt_project_bot_memberships (tenant_id, project_id) WHERE role = 'coordinator' AND status IN ('active','paused');
|
|
38
|
+
CREATE UNIQUE INDEX IF NOT EXISTS uq_lt_project_bot_memberships_mention
|
|
39
|
+
ON lt_project_bot_memberships (tenant_id, room_id, mention_name) WHERE status IN ('active','paused');
|
|
40
|
+
CREATE INDEX IF NOT EXISTS idx_lt_project_bot_memberships_project ON lt_project_bot_memberships (tenant_id, project_id);
|
|
41
|
+
CREATE INDEX IF NOT EXISTS idx_lt_project_bot_memberships_room ON lt_project_bot_memberships (tenant_id, room_id);
|
|
42
|
+
CREATE TABLE IF NOT EXISTS lt_project_room_messages (
|
|
43
|
+
id TEXT NOT NULL, tenant_id TEXT NOT NULL, workspace_id TEXT NOT NULL, project_id TEXT NOT NULL, room_id TEXT NOT NULL,
|
|
44
|
+
author TEXT NOT NULL, content TEXT NOT NULL, mentions TEXT NOT NULL DEFAULT '[]', reply_to_message_id TEXT,
|
|
45
|
+
source TEXT NOT NULL CHECK (source IN ('user','agent','task','routine','system')), source_id TEXT,
|
|
46
|
+
idempotency_key TEXT, created_at TEXT NOT NULL, PRIMARY KEY (tenant_id, id)
|
|
47
|
+
);
|
|
48
|
+
CREATE UNIQUE INDEX IF NOT EXISTS uq_lt_project_room_messages_idempotency
|
|
49
|
+
ON lt_project_room_messages (tenant_id, room_id, idempotency_key) WHERE idempotency_key IS NOT NULL;
|
|
50
|
+
CREATE INDEX IF NOT EXISTS idx_lt_project_room_messages_room_created
|
|
51
|
+
ON lt_project_room_messages (tenant_id, room_id, created_at DESC, id DESC);
|
|
52
|
+
`;
|
|
53
|
+
|
|
54
|
+
const tableColumns: Record<string, string[]> = {
|
|
55
|
+
lt_project_rooms: ["id", "tenant_id", "workspace_id", "project_id", "type", "name", "created_at", "updated_at"],
|
|
56
|
+
lt_project_memberships: ["id", "tenant_id", "project_id", "user_id", "role", "status", "joined_at", "updated_at"],
|
|
57
|
+
lt_project_bot_memberships: ["id", "tenant_id", "workspace_id", "project_id", "room_id", "assistant_id", "role", "title", "responsibility", "mention_name", "status", "room_thread_id", "joined_at", "updated_at"],
|
|
58
|
+
lt_project_room_messages: ["id", "tenant_id", "workspace_id", "project_id", "room_id", "author", "content", "mentions", "reply_to_message_id", "source", "source_id", "idempotency_key", "created_at"],
|
|
59
|
+
};
|
|
60
|
+
const indexes: Record<string, { table: string; columns: string[]; unique: boolean; partial: boolean; sql?: string[] }> = {
|
|
61
|
+
idx_lt_project_memberships_project: { table: "lt_project_memberships", columns: ["tenant_id", "project_id"], unique: false, partial: false },
|
|
62
|
+
uq_lt_project_bot_memberships_coordinator: { table: "lt_project_bot_memberships", columns: ["tenant_id", "project_id"], unique: true, partial: true, sql: ["role = 'coordinator'", "status in ('active','paused')"] },
|
|
63
|
+
uq_lt_project_bot_memberships_mention: { table: "lt_project_bot_memberships", columns: ["tenant_id", "room_id", "mention_name"], unique: true, partial: true, sql: ["status in ('active','paused')"] },
|
|
64
|
+
idx_lt_project_bot_memberships_project: { table: "lt_project_bot_memberships", columns: ["tenant_id", "project_id"], unique: false, partial: false },
|
|
65
|
+
idx_lt_project_bot_memberships_room: { table: "lt_project_bot_memberships", columns: ["tenant_id", "room_id"], unique: false, partial: false },
|
|
66
|
+
uq_lt_project_room_messages_idempotency: { table: "lt_project_room_messages", columns: ["tenant_id", "room_id", "idempotency_key"], unique: true, partial: true, sql: ["idempotency_key is not null"] },
|
|
67
|
+
idx_lt_project_room_messages_room_created: { table: "lt_project_room_messages", columns: ["tenant_id", "room_id", "created_at", "id"], unique: false, partial: false, sql: ["created_at desc", "id desc"] },
|
|
68
|
+
};
|
|
69
|
+
const uniqueConstraints: Record<string, string[][]> = {
|
|
70
|
+
lt_project_rooms: [["tenant_id", "id"], ["tenant_id", "project_id", "type"]],
|
|
71
|
+
lt_project_memberships: [["tenant_id", "id"], ["tenant_id", "project_id", "user_id"]],
|
|
72
|
+
lt_project_bot_memberships: [["tenant_id", "id"], ["tenant_id", "project_id", "assistant_id"]],
|
|
73
|
+
lt_project_room_messages: [["tenant_id", "id"]],
|
|
74
|
+
};
|
|
75
|
+
const checkConstraints: Record<string, Array<{ name: string; fragment: string }>> = {
|
|
76
|
+
lt_project_rooms: [{ name: "type", fragment: "type='main'" }],
|
|
77
|
+
lt_project_memberships: [
|
|
78
|
+
{ name: "role", fragment: "role in ('owner','admin','member','viewer')" },
|
|
79
|
+
{ name: "status", fragment: "status in ('active','removed')" },
|
|
80
|
+
],
|
|
81
|
+
lt_project_bot_memberships: [
|
|
82
|
+
{ name: "role", fragment: "role in ('coordinator','specialist')" },
|
|
83
|
+
{ name: "status", fragment: "status in ('active','paused','removed')" },
|
|
84
|
+
],
|
|
85
|
+
lt_project_room_messages: [{ name: "source", fragment: "source in ('user','agent','task','routine','system')" }],
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
function rows(db: DatabaseWrapper, sql: string): Record<string, unknown>[] {
|
|
89
|
+
const result = db.getRawDb().exec(sql);
|
|
90
|
+
if (!result[0]) return [];
|
|
91
|
+
return result[0].values.map((values) => Object.fromEntries(result[0].columns.map((column, index) => [column, values[index]])));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function normalizeSql(value: string): string {
|
|
95
|
+
return value.toLowerCase()
|
|
96
|
+
.replace(/["`]/g, "")
|
|
97
|
+
.replace(/\[|\]/g, "")
|
|
98
|
+
.replace(/\s+/g, " ")
|
|
99
|
+
.replace(/\s*([=,])\s*/g, "$1")
|
|
100
|
+
.replace(/[()]/g, "")
|
|
101
|
+
.trim();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function extractCheckExpressions(sql: string): string[] {
|
|
105
|
+
const checks: string[] = [];
|
|
106
|
+
let quoted = false;
|
|
107
|
+
for (let index = 0; index < sql.length; index += 1) {
|
|
108
|
+
if (sql[index] === "'") {
|
|
109
|
+
if (quoted && sql[index + 1] === "'") { index += 1; continue; }
|
|
110
|
+
quoted = !quoted;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (quoted || sql.slice(index, index + 5).toLowerCase() !== "check") continue;
|
|
114
|
+
const before = sql[index - 1];
|
|
115
|
+
const after = sql[index + 5];
|
|
116
|
+
if ((before && /[a-z0-9_]/i.test(before)) || (after && /[a-z0-9_]/i.test(after))) continue;
|
|
117
|
+
let opening = index + 5;
|
|
118
|
+
while (/\s/.test(sql[opening] ?? "")) opening += 1;
|
|
119
|
+
if (sql[opening] !== "(") continue;
|
|
120
|
+
let depth = 1;
|
|
121
|
+
let expressionQuoted = false;
|
|
122
|
+
for (let cursor = opening + 1; cursor < sql.length; cursor += 1) {
|
|
123
|
+
if (sql[cursor] === "'") {
|
|
124
|
+
if (expressionQuoted && sql[cursor + 1] === "'") { cursor += 1; continue; }
|
|
125
|
+
expressionQuoted = !expressionQuoted;
|
|
126
|
+
} else if (!expressionQuoted && sql[cursor] === "(") {
|
|
127
|
+
depth += 1;
|
|
128
|
+
} else if (!expressionQuoted && sql[cursor] === ")") {
|
|
129
|
+
depth -= 1;
|
|
130
|
+
if (depth === 0) {
|
|
131
|
+
checks.push(sql.slice(opening + 1, cursor));
|
|
132
|
+
index = cursor;
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return checks;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Validates every table and named index required by project-room version 1. */
|
|
142
|
+
export function validateProjectRoomSchema(db: DatabaseWrapper): void {
|
|
143
|
+
for (const [table, expected] of Object.entries(tableColumns)) {
|
|
144
|
+
const actual = rows(db, `PRAGMA table_info(${table})`) as ColumnRow[];
|
|
145
|
+
if (actual.map((column) => column.name).join(",") !== expected.join(",") || actual.some((column) => column.type !== "TEXT")
|
|
146
|
+
|| actual.some((column) => column.name !== "responsibility" && column.name !== "reply_to_message_id" && column.name !== "source_id" && column.name !== "idempotency_key" && column.notnull !== 1)) {
|
|
147
|
+
throw new Error(`Invalid project room schema: table '${table}' has incompatible columns`);
|
|
148
|
+
}
|
|
149
|
+
const uniqueIndexes = (rows(db, `PRAGMA index_list(${table})`) as IndexRow[]).filter((index) => index.unique === 1);
|
|
150
|
+
const signatures = uniqueIndexes.map((index) => (rows(db, `PRAGMA index_info(${String(index.name)})`) as IndexColumnRow[]).map((column) => column.name).join(","));
|
|
151
|
+
if (uniqueConstraints[table].some((constraint) => !signatures.includes(constraint.join(",")))) {
|
|
152
|
+
throw new Error(`Invalid project room schema: table '${table}' is missing required uniqueness`);
|
|
153
|
+
}
|
|
154
|
+
const definition = rows(db, `SELECT sql FROM sqlite_master WHERE type = 'table' AND name = '${table}'`)[0]?.sql;
|
|
155
|
+
const checks = typeof definition === "string" ? extractCheckExpressions(definition).map(normalizeSql) : [];
|
|
156
|
+
const missingCheck = checkConstraints[table].find((constraint) => !checks.includes(normalizeSql(constraint.fragment)));
|
|
157
|
+
if (missingCheck) {
|
|
158
|
+
throw new Error(`Invalid project room schema: table '${table}' is missing required CHECK constraint '${missingCheck.name}'`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
for (const [name, expected] of Object.entries(indexes)) {
|
|
162
|
+
const listed = (rows(db, `PRAGMA index_list(${expected.table})`) as IndexRow[]).find((index) => index.name === name);
|
|
163
|
+
const columns = (rows(db, `PRAGMA index_info(${name})`) as IndexColumnRow[]).map((column) => column.name);
|
|
164
|
+
const definition = rows(db, `SELECT sql FROM sqlite_master WHERE type = 'index' AND name = '${name}'`)[0]?.sql;
|
|
165
|
+
const normalized = typeof definition === "string" ? definition.toLowerCase().replace(/\s+/g, " ") : "";
|
|
166
|
+
if (!listed || listed.unique !== Number(expected.unique) || listed.partial !== Number(expected.partial)
|
|
167
|
+
|| columns.join(",") !== expected.columns.join(",") || expected.sql?.some((fragment) => !normalized.includes(fragment))) {
|
|
168
|
+
throw new Error(`Invalid project room schema: index '${name}' is incompatible`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Atomically installs and validates project-room migration version 1. */
|
|
174
|
+
export function ensureProjectRoomSchema(db: DatabaseWrapper): void {
|
|
175
|
+
const marked = db as SchemaDatabase;
|
|
176
|
+
if (marked[schemaMarker]) return;
|
|
177
|
+
const raw = db.getRawDb();
|
|
178
|
+
const hasMigrationTable = rows(db, "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'lattice_schema_migrations'").length > 0;
|
|
179
|
+
const applied = hasMigrationTable ? rows(db, `SELECT name, version FROM lattice_schema_migrations WHERE store_name = 'project_room' AND name = '${migrationName}'`)[0] : undefined;
|
|
180
|
+
if (applied) {
|
|
181
|
+
if (applied.version !== 1) throw new Error(`Invalid project room schema migration version: ${String(applied.version)}`);
|
|
182
|
+
validateProjectRoomSchema(db);
|
|
183
|
+
marked[schemaMarker] = true;
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
raw.exec("BEGIN IMMEDIATE");
|
|
187
|
+
try {
|
|
188
|
+
raw.exec(migrationTableDdl);
|
|
189
|
+
raw.exec(ddl);
|
|
190
|
+
validateProjectRoomSchema(db);
|
|
191
|
+
raw.run("INSERT INTO lattice_schema_migrations (store_name, name, version, applied_at) VALUES (?, ?, ?, ?)", ["project_room", migrationName, 1, new Date().toISOString()]);
|
|
192
|
+
raw.exec("COMMIT");
|
|
193
|
+
} catch (error) {
|
|
194
|
+
try { raw.exec("ROLLBACK"); } catch { /* preserve the schema failure */ }
|
|
195
|
+
throw error;
|
|
196
|
+
}
|
|
197
|
+
db.save();
|
|
198
|
+
marked[schemaMarker] = true;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Runs synchronous sql.js work atomically. This helper is intentionally non-reentrant. */
|
|
202
|
+
export function transaction<T>(db: DatabaseWrapper, work: () => T): T {
|
|
203
|
+
if (activeTransactions.has(db)) throw new Error("Nested local store transactions are not supported");
|
|
204
|
+
activeTransactions.add(db);
|
|
205
|
+
let began = false;
|
|
206
|
+
try {
|
|
207
|
+
db.exec("BEGIN IMMEDIATE");
|
|
208
|
+
began = true;
|
|
209
|
+
const result = work();
|
|
210
|
+
db.exec("COMMIT");
|
|
211
|
+
db.save();
|
|
212
|
+
return result;
|
|
213
|
+
} catch (error) {
|
|
214
|
+
if (began) {
|
|
215
|
+
try { db.exec("ROLLBACK"); } catch { /* preserve the original error */ }
|
|
216
|
+
db.save();
|
|
217
|
+
}
|
|
218
|
+
throw error;
|
|
219
|
+
} finally {
|
|
220
|
+
activeTransactions.delete(db);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Executes a write without persisting mid-transaction. */
|
|
225
|
+
export function write(db: DatabaseWrapper, sql: string, params: Array<string | number | null>): number {
|
|
226
|
+
const raw = db.getRawDb();
|
|
227
|
+
raw.run(sql, params);
|
|
228
|
+
return raw.getRowsModified();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function timestamp(previous?: string): string {
|
|
232
|
+
const previousTime = previous === undefined ? Number.NaN : Date.parse(previous);
|
|
233
|
+
const now = Date.now();
|
|
234
|
+
return new Date(Math.max(now, Number.isFinite(previousTime) ? previousTime + 1 : now)).toISOString();
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function validDate(value: unknown): value is Date {
|
|
238
|
+
return value instanceof Date && !Number.isNaN(value.getTime());
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function isUniqueError(error: unknown): boolean {
|
|
242
|
+
return error instanceof Error && error.message.includes("UNIQUE constraint failed");
|
|
243
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ChannelBindingMigrationConflictError,
|
|
3
|
+
type ChannelBindingMigrationConflict,
|
|
4
|
+
} from "@axiom-lattice/protocols";
|
|
5
|
+
import type { DatabaseWrapper } from "../database";
|
|
6
|
+
|
|
7
|
+
const UNIQUE_INDEX_SQL = `CREATE UNIQUE INDEX IF NOT EXISTS idx_lt_cb_subject_unique
|
|
8
|
+
ON lt_channel_bindings(channel, channel_installation_id, tenant_id, sender_id)`;
|
|
9
|
+
const LOOKUP_INDEX_SQL = `CREATE INDEX IF NOT EXISTS idx_lt_cb_resolve
|
|
10
|
+
ON lt_channel_bindings(channel, sender_id, channel_installation_id, tenant_id)`;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Installs global binding-subject uniqueness without silently reconciling legacy rows.
|
|
14
|
+
*
|
|
15
|
+
* @param db - Synchronous sql.js database wrapper.
|
|
16
|
+
* @throws {ChannelBindingMigrationConflictError} When existing subjects require operator repair.
|
|
17
|
+
*/
|
|
18
|
+
export function ensureChannelBindingSubjectIndex(db: DatabaseWrapper): void {
|
|
19
|
+
db.exec("BEGIN");
|
|
20
|
+
try {
|
|
21
|
+
const conflicts = db.prepare(`SELECT tenant_id, channel, channel_installation_id, sender_id, COUNT(*) AS count
|
|
22
|
+
FROM lt_channel_bindings
|
|
23
|
+
GROUP BY channel, channel_installation_id, tenant_id, sender_id
|
|
24
|
+
HAVING COUNT(*) > 1
|
|
25
|
+
ORDER BY tenant_id, channel, channel_installation_id, sender_id`).all() as ChannelBindingConflictRow[];
|
|
26
|
+
if (conflicts.length > 0) {
|
|
27
|
+
throw new ChannelBindingMigrationConflictError(conflicts.map(mapConflict));
|
|
28
|
+
}
|
|
29
|
+
db.exec("DROP INDEX IF EXISTS idx_lt_cb_room_subject");
|
|
30
|
+
db.exec(UNIQUE_INDEX_SQL);
|
|
31
|
+
db.exec(LOOKUP_INDEX_SQL);
|
|
32
|
+
db.exec("COMMIT");
|
|
33
|
+
} catch (error) {
|
|
34
|
+
try {
|
|
35
|
+
db.exec("ROLLBACK");
|
|
36
|
+
} catch {
|
|
37
|
+
// Preserve the migration failure that triggered rollback.
|
|
38
|
+
}
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
db.save();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface ChannelBindingConflictRow {
|
|
45
|
+
tenant_id: string;
|
|
46
|
+
channel: string;
|
|
47
|
+
channel_installation_id: string;
|
|
48
|
+
sender_id: string;
|
|
49
|
+
count: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function mapConflict(row: ChannelBindingConflictRow): ChannelBindingMigrationConflict {
|
|
53
|
+
return {
|
|
54
|
+
tenantId: row.tenant_id,
|
|
55
|
+
channel: row.channel,
|
|
56
|
+
channelInstallationId: row.channel_installation_id,
|
|
57
|
+
senderId: row.sender_id,
|
|
58
|
+
count: row.count,
|
|
59
|
+
};
|
|
60
|
+
}
|