@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.
@@ -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
+ }