@axiom-lattice/local-stores 3.1.1 → 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 +9 -0
- package/dist/index.d.mts +142 -14
- package/dist/index.d.ts +142 -14
- package/dist/index.js +1194 -206
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1189 -203
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/LocalCapabilityBundleStore.test.ts +1 -2
- 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__/LocalTaskStore.test.ts +58 -0
- package/src/__tests__/LocalTaskWorkItemStore.test.ts +92 -0
- package/src/__tests__/LocalThreadMessageQueueStore.test.ts +126 -3
- package/src/createLocalStoreConfig.ts +8 -0
- package/src/index.ts +4 -0
- package/src/migrations/migration.ts +1 -1
- 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/LocalTaskStore.ts +82 -2
- package/src/stores/LocalTaskWorkItemStore.ts +91 -1
- package/src/stores/LocalThreadMessageQueueStore.ts +52 -6
- package/src/stores/project-room-schema.ts +243 -0
- package/src/stores/room-channel-binding-index.ts +60 -0
|
@@ -10,6 +10,12 @@ import type {
|
|
|
10
10
|
ThreadInfo,
|
|
11
11
|
QueueScope,
|
|
12
12
|
} from "@axiom-lattice/core";
|
|
13
|
+
import {
|
|
14
|
+
parseQueuedExecutionMode,
|
|
15
|
+
parseTrustedRunContext,
|
|
16
|
+
type QueuedExecutionMode,
|
|
17
|
+
type TrustedRunContext,
|
|
18
|
+
} from "@axiom-lattice/protocols";
|
|
13
19
|
import { ensureTable, nowISO, parseISO } from "../database";
|
|
14
20
|
import { randomUUID } from "crypto";
|
|
15
21
|
|
|
@@ -28,6 +34,8 @@ CREATE TABLE IF NOT EXISTS lt_thread_message_queue (
|
|
|
28
34
|
status TEXT NOT NULL DEFAULT 'pending',
|
|
29
35
|
command TEXT,
|
|
30
36
|
custom_run_config TEXT,
|
|
37
|
+
trusted_run_context TEXT,
|
|
38
|
+
execution_mode TEXT CHECK (execution_mode IS NULL OR execution_mode = 'followup'),
|
|
31
39
|
created_at TEXT NOT NULL
|
|
32
40
|
);
|
|
33
41
|
CREATE INDEX IF NOT EXISTS idx_lt_tmq_thread ON lt_thread_message_queue(thread_id, status);
|
|
@@ -48,6 +56,8 @@ interface QueueRow {
|
|
|
48
56
|
status: string;
|
|
49
57
|
command: string | null;
|
|
50
58
|
custom_run_config: string | null;
|
|
59
|
+
trusted_run_context: string | null;
|
|
60
|
+
execution_mode: string | null;
|
|
51
61
|
created_at: string;
|
|
52
62
|
}
|
|
53
63
|
|
|
@@ -58,9 +68,18 @@ export class LocalThreadMessageQueueStore implements IMessageQueueStore {
|
|
|
58
68
|
constructor(db: DatabaseWrapper) {
|
|
59
69
|
this.db = db;
|
|
60
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
|
+
}
|
|
61
79
|
}
|
|
62
80
|
|
|
63
81
|
async addMessage(params: AddMessageParams): Promise<PendingMessage> {
|
|
82
|
+
const trusted = validateQueueTrust(params);
|
|
64
83
|
const now = nowISO();
|
|
65
84
|
const id = params.id || randomUUID();
|
|
66
85
|
|
|
@@ -70,8 +89,8 @@ export class LocalThreadMessageQueueStore implements IMessageQueueStore {
|
|
|
70
89
|
const nextSeq = seqRow.next_seq;
|
|
71
90
|
|
|
72
91
|
this.db.prepare(
|
|
73
|
-
`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)
|
|
74
|
-
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
75
94
|
).run(
|
|
76
95
|
id, params.threadId, params.tenantId, params.assistantId,
|
|
77
96
|
params.workspaceId ?? null, params.projectId ?? null,
|
|
@@ -79,6 +98,8 @@ export class LocalThreadMessageQueueStore implements IMessageQueueStore {
|
|
|
79
98
|
params.priority ?? 0,
|
|
80
99
|
params.command ? JSON.stringify(params.command) : null,
|
|
81
100
|
params.custom_run_config ? JSON.stringify(params.custom_run_config) : null,
|
|
101
|
+
trusted.trustedRunContext ? JSON.stringify(trusted.trustedRunContext) : null,
|
|
102
|
+
trusted.executionMode ?? null,
|
|
82
103
|
now,
|
|
83
104
|
);
|
|
84
105
|
|
|
@@ -86,6 +107,7 @@ export class LocalThreadMessageQueueStore implements IMessageQueueStore {
|
|
|
86
107
|
}
|
|
87
108
|
|
|
88
109
|
async addMessageIfCapacity(params: AddMessageParams, maxSize: number): Promise<boolean> {
|
|
110
|
+
const trusted = validateQueueTrust(params);
|
|
89
111
|
let release!: () => void;
|
|
90
112
|
const previous = this.capacityTail;
|
|
91
113
|
this.capacityTail = new Promise<void>((resolve) => { release = resolve; });
|
|
@@ -101,10 +123,11 @@ export class LocalThreadMessageQueueStore implements IMessageQueueStore {
|
|
|
101
123
|
const id = params.id || randomUUID();
|
|
102
124
|
const limit = maxSize === Infinity ? -1 : maxSize;
|
|
103
125
|
const result = this.db.prepare(
|
|
104
|
-
`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)
|
|
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)
|
|
105
127
|
SELECT ?, ?, ?, ?, ?, ?, ?, ?,
|
|
106
128
|
(SELECT COALESCE(MAX(sequence_order), 0) + 1 FROM lt_thread_message_queue WHERE thread_id = ?),
|
|
107
|
-
?, ?, ?,
|
|
129
|
+
?, ?, ?, ?, ?,
|
|
130
|
+
?
|
|
108
131
|
WHERE ? = -1 OR (SELECT COUNT(*) FROM lt_thread_message_queue WHERE thread_id = ? AND status = 'pending'${filter.sql}) < ?`,
|
|
109
132
|
).run(
|
|
110
133
|
id, params.threadId, params.tenantId, params.assistantId,
|
|
@@ -114,6 +137,8 @@ export class LocalThreadMessageQueueStore implements IMessageQueueStore {
|
|
|
114
137
|
params.priority ?? 0,
|
|
115
138
|
params.command ? JSON.stringify(params.command) : null,
|
|
116
139
|
params.custom_run_config ? JSON.stringify(params.custom_run_config) : null,
|
|
140
|
+
trusted.trustedRunContext ? JSON.stringify(trusted.trustedRunContext) : null,
|
|
141
|
+
trusted.executionMode ?? null,
|
|
117
142
|
nowISO(), limit, params.threadId, ...filter.params, limit,
|
|
118
143
|
);
|
|
119
144
|
return result.changes > 0;
|
|
@@ -123,6 +148,7 @@ export class LocalThreadMessageQueueStore implements IMessageQueueStore {
|
|
|
123
148
|
}
|
|
124
149
|
|
|
125
150
|
async addMessageAtHead(params: AddMessageParams): Promise<PendingMessage> {
|
|
151
|
+
const trusted = validateQueueTrust(params);
|
|
126
152
|
const now = nowISO();
|
|
127
153
|
const id = params.id || randomUUID();
|
|
128
154
|
|
|
@@ -131,14 +157,16 @@ export class LocalThreadMessageQueueStore implements IMessageQueueStore {
|
|
|
131
157
|
).get(params.threadId) as { next_seq: number };
|
|
132
158
|
|
|
133
159
|
this.db.prepare(
|
|
134
|
-
`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)
|
|
135
|
-
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, ?, ?, ?, ?, ?)`,
|
|
136
162
|
).run(
|
|
137
163
|
id, params.threadId, params.tenantId!, params.assistantId!,
|
|
138
164
|
params.workspaceId ?? null, params.projectId ?? null,
|
|
139
165
|
JSON.stringify(params.content), params.type || "human", seqRow.next_seq,
|
|
140
166
|
params.command ? JSON.stringify(params.command) : null,
|
|
141
167
|
params.custom_run_config ? JSON.stringify(params.custom_run_config) : null,
|
|
168
|
+
trusted.trustedRunContext ? JSON.stringify(trusted.trustedRunContext) : null,
|
|
169
|
+
trusted.executionMode ?? null,
|
|
142
170
|
now,
|
|
143
171
|
);
|
|
144
172
|
|
|
@@ -218,6 +246,20 @@ export class LocalThreadMessageQueueStore implements IMessageQueueStore {
|
|
|
218
246
|
}
|
|
219
247
|
}
|
|
220
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
|
+
|
|
221
263
|
function scopeClause(scope?: QueueScope): { sql: string; params: unknown[] } {
|
|
222
264
|
if (!scope) return { sql: "", params: [] };
|
|
223
265
|
const entries = (["tenantId", "assistantId", "workspaceId", "projectId"] as const)
|
|
@@ -238,5 +280,9 @@ function rowToMessage(row: QueueRow): PendingMessage {
|
|
|
238
280
|
priority: row.priority || 0,
|
|
239
281
|
command: row.command ? JSON.parse(row.command) : undefined,
|
|
240
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),
|
|
241
287
|
};
|
|
242
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
|
+
}
|