@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
|
@@ -3,13 +3,14 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { DatabaseWrapper } from "../database";
|
|
6
|
-
import
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
CreateBindingInput,
|
|
6
|
+
import {
|
|
7
|
+
DuplicateChannelBindingSubjectError,
|
|
8
|
+
type Binding, type BindingListParams, type BindingMutablePatch,
|
|
9
|
+
type BindingRegistry, type CreateBindingInput,
|
|
10
10
|
} from "@axiom-lattice/protocols";
|
|
11
11
|
import { ensureTable, nowISO, parseISO } from "../database";
|
|
12
12
|
import { randomUUID } from "crypto";
|
|
13
|
+
import { ensureChannelBindingSubjectIndex } from "./room-channel-binding-index";
|
|
13
14
|
|
|
14
15
|
const DDL = `
|
|
15
16
|
CREATE TABLE IF NOT EXISTS lt_channel_bindings (
|
|
@@ -29,7 +30,6 @@ CREATE TABLE IF NOT EXISTS lt_channel_bindings (
|
|
|
29
30
|
created_at TEXT NOT NULL,
|
|
30
31
|
updated_at TEXT NOT NULL
|
|
31
32
|
);
|
|
32
|
-
CREATE INDEX IF NOT EXISTS idx_lt_cb_resolve ON lt_channel_bindings(channel, sender_id, channel_installation_id, tenant_id);
|
|
33
33
|
`;
|
|
34
34
|
|
|
35
35
|
interface BindingRow {
|
|
@@ -56,6 +56,23 @@ export class LocalChannelBindingStore implements BindingRegistry {
|
|
|
56
56
|
constructor(db: DatabaseWrapper) {
|
|
57
57
|
this.db = db;
|
|
58
58
|
ensureTable(db, DDL);
|
|
59
|
+
ensureChannelBindingSubjectIndex(db);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async findById(tenantId: string, id: string): Promise<Binding | null> {
|
|
63
|
+
const row = this.db.prepare(
|
|
64
|
+
"SELECT * FROM lt_channel_bindings WHERE id = ? AND tenant_id = ?",
|
|
65
|
+
).get(id, tenantId) as unknown as BindingRow | undefined;
|
|
66
|
+
return row ? mapRowToBinding(row) : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async findBySubject(params: {
|
|
70
|
+
tenantId: string; channel: string; channelInstallationId: string; senderId: string;
|
|
71
|
+
}): Promise<Binding | null> {
|
|
72
|
+
const row = this.db.prepare(`SELECT * FROM lt_channel_bindings
|
|
73
|
+
WHERE tenant_id = ? AND channel = ? AND channel_installation_id = ? AND sender_id = ? LIMIT 1`)
|
|
74
|
+
.get(params.tenantId, params.channel, params.channelInstallationId, params.senderId) as unknown as BindingRow | undefined;
|
|
75
|
+
return row ? mapRowToBinding(row) : null;
|
|
59
76
|
}
|
|
60
77
|
|
|
61
78
|
async resolve(params: {
|
|
@@ -76,31 +93,34 @@ export class LocalChannelBindingStore implements BindingRegistry {
|
|
|
76
93
|
const id = randomUUID();
|
|
77
94
|
const now = nowISO();
|
|
78
95
|
|
|
79
|
-
|
|
80
|
-
`INSERT INTO lt_channel_bindings
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
96
|
+
try {
|
|
97
|
+
this.db.prepare(`INSERT INTO lt_channel_bindings
|
|
98
|
+
(id, channel, channel_installation_id, tenant_id, sender_id, agent_id,
|
|
99
|
+
thread_id, thread_mode, sender_display_name, sender_metadata, workspace_id, project_id, enabled, created_at, updated_at)
|
|
100
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
101
|
+
).run(
|
|
102
|
+
id, input.channel, input.channelInstallationId, input.tenantId,
|
|
103
|
+
input.senderId, input.agentId, input.threadId || null, input.threadMode || "fixed",
|
|
104
|
+
input.senderDisplayName || null,
|
|
105
|
+
input.senderMetadata ? JSON.stringify(input.senderMetadata) : null,
|
|
106
|
+
input.workspaceId || null, input.projectId || null, (input.enabled ?? true) ? 1 : 0,
|
|
107
|
+
now, now,
|
|
108
|
+
);
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if (error instanceof Error && /UNIQUE constraint failed/.test(error.message)) {
|
|
111
|
+
throw new DuplicateChannelBindingSubjectError();
|
|
112
|
+
}
|
|
113
|
+
throw error;
|
|
114
|
+
}
|
|
92
115
|
|
|
93
116
|
return (await this.getById(id))!;
|
|
94
117
|
}
|
|
95
118
|
|
|
96
|
-
async update(id: string, patch:
|
|
97
|
-
const existing = await this.
|
|
119
|
+
async update(tenantId: string, id: string, patch: BindingMutablePatch): Promise<Binding> {
|
|
120
|
+
const existing = await this.findById(tenantId, id);
|
|
98
121
|
if (!existing) throw new Error(`Binding ${id} not found`);
|
|
99
122
|
|
|
100
123
|
const updated = {
|
|
101
|
-
channel: patch.channel ?? existing.channel,
|
|
102
|
-
channelInstallationId: patch.channelInstallationId ?? existing.channelInstallationId,
|
|
103
|
-
senderId: patch.senderId ?? existing.senderId,
|
|
104
124
|
agentId: patch.agentId ?? existing.agentId,
|
|
105
125
|
threadId: patch.threadId !== undefined ? patch.threadId : existing.threadId,
|
|
106
126
|
workspaceId: patch.workspaceId !== undefined ? patch.workspaceId : existing.workspaceId,
|
|
@@ -115,39 +135,38 @@ export class LocalChannelBindingStore implements BindingRegistry {
|
|
|
115
135
|
|
|
116
136
|
this.db.prepare(
|
|
117
137
|
`UPDATE lt_channel_bindings SET
|
|
118
|
-
|
|
119
|
-
thread_id = ?, workspace_id = ?, project_id = ?, thread_mode = ?,
|
|
138
|
+
agent_id = ?, thread_id = ?, workspace_id = ?, project_id = ?, thread_mode = ?,
|
|
120
139
|
sender_display_name = ?, sender_metadata = ?, enabled = ?, updated_at = ?
|
|
121
|
-
WHERE id = ?`,
|
|
140
|
+
WHERE id = ? AND tenant_id = ?`,
|
|
122
141
|
).run(
|
|
123
|
-
updated.channel, updated.channelInstallationId, updated.senderId,
|
|
124
142
|
updated.agentId, updated.threadId || null, updated.workspaceId || null,
|
|
125
143
|
updated.projectId || null, updated.threadMode, updated.senderDisplayName || null,
|
|
126
144
|
updated.senderMetadata ? JSON.stringify(updated.senderMetadata) : null,
|
|
127
|
-
updated.enabled ? 1 : 0, now, id,
|
|
145
|
+
updated.enabled ? 1 : 0, now, id, tenantId,
|
|
128
146
|
);
|
|
129
147
|
|
|
130
|
-
return (await this.
|
|
148
|
+
return (await this.findById(tenantId, id))!;
|
|
131
149
|
}
|
|
132
150
|
|
|
133
|
-
async delete(id: string): Promise<void> {
|
|
134
|
-
this.db.prepare(`DELETE FROM lt_channel_bindings WHERE id = ?`).run(id);
|
|
151
|
+
async delete(tenantId: string, id: string): Promise<void> {
|
|
152
|
+
this.db.prepare(`DELETE FROM lt_channel_bindings WHERE id = ? AND tenant_id = ?`).run(id, tenantId);
|
|
135
153
|
}
|
|
136
154
|
|
|
137
|
-
async list(params: {
|
|
138
|
-
channel?: string;
|
|
139
|
-
agentId?: string;
|
|
140
|
-
tenantId: string;
|
|
141
|
-
channelInstallationId?: string;
|
|
142
|
-
limit?: number;
|
|
143
|
-
offset?: number;
|
|
144
|
-
}): Promise<Binding[]> {
|
|
155
|
+
async list(params: BindingListParams): Promise<Binding[]> {
|
|
145
156
|
const conditions: string[] = ["tenant_id = ?"];
|
|
146
157
|
const values: unknown[] = [params.tenantId];
|
|
147
158
|
|
|
148
159
|
if (params.channel) { conditions.push("channel = ?"); values.push(params.channel); }
|
|
149
160
|
if (params.agentId) { conditions.push("agent_id = ?"); values.push(params.agentId); }
|
|
150
161
|
if (params.channelInstallationId) { conditions.push("channel_installation_id = ?"); values.push(params.channelInstallationId); }
|
|
162
|
+
if (params.excludeChannels?.length) {
|
|
163
|
+
conditions.push(`channel NOT IN (${params.excludeChannels.map(() => "?").join(", ")})`);
|
|
164
|
+
values.push(...params.excludeChannels);
|
|
165
|
+
}
|
|
166
|
+
for (const prefix of params.excludeInstallationIdPrefixes ?? []) {
|
|
167
|
+
conditions.push("substr(channel_installation_id, 1, length(?)) <> ?");
|
|
168
|
+
values.push(prefix, prefix);
|
|
169
|
+
}
|
|
151
170
|
|
|
152
171
|
const limit = params.limit ?? 50;
|
|
153
172
|
const offset = params.offset ?? 0;
|
|
@@ -163,7 +182,16 @@ export class LocalChannelBindingStore implements BindingRegistry {
|
|
|
163
182
|
return rows.map(mapRowToBinding);
|
|
164
183
|
}
|
|
165
184
|
|
|
166
|
-
async import(bindings: CreateBindingInput[]): Promise<Binding[]> {
|
|
185
|
+
async import(tenantId: string, bindings: CreateBindingInput[]): Promise<Binding[]> {
|
|
186
|
+
if (bindings.some((binding) => binding.channel === "room")) {
|
|
187
|
+
throw new Error("Room bindings cannot be imported through the public store API");
|
|
188
|
+
}
|
|
189
|
+
if (bindings.some((binding) => binding.channelInstallationId.startsWith("room-internal:"))) {
|
|
190
|
+
throw new Error("Internal bindings cannot be imported through the public store API");
|
|
191
|
+
}
|
|
192
|
+
if (bindings.some((binding) => binding.tenantId !== tenantId)) {
|
|
193
|
+
throw new Error("Binding import tenant mismatch");
|
|
194
|
+
}
|
|
167
195
|
const result: Binding[] = [];
|
|
168
196
|
for (const input of bindings) {
|
|
169
197
|
result.push(await this.create(input));
|
|
@@ -172,7 +200,13 @@ export class LocalChannelBindingStore implements BindingRegistry {
|
|
|
172
200
|
}
|
|
173
201
|
|
|
174
202
|
async export(params: { tenantId: string }): Promise<Binding[]> {
|
|
175
|
-
return this.list({
|
|
203
|
+
return this.list({
|
|
204
|
+
tenantId: params.tenantId,
|
|
205
|
+
excludeChannels: ["room"],
|
|
206
|
+
excludeInstallationIdPrefixes: ["room-internal:"],
|
|
207
|
+
limit: 10000,
|
|
208
|
+
offset: 0,
|
|
209
|
+
});
|
|
176
210
|
}
|
|
177
211
|
|
|
178
212
|
private async getById(id: string): Promise<Binding | null> {
|
|
@@ -7,7 +7,7 @@ import type {
|
|
|
7
7
|
ChannelInstallationStore,
|
|
8
8
|
ChannelInstallation,
|
|
9
9
|
ChannelInstallationType,
|
|
10
|
-
|
|
10
|
+
CreateChannelInstallationInput,
|
|
11
11
|
UpdateChannelInstallationRequest,
|
|
12
12
|
} from "@axiom-lattice/protocols";
|
|
13
13
|
import { ensureTable, nowISO, parseISO } from "../database";
|
|
@@ -92,7 +92,7 @@ export class LocalChannelInstallationStore implements ChannelInstallationStore {
|
|
|
92
92
|
async createInstallation(
|
|
93
93
|
tenantId: string,
|
|
94
94
|
installationId: string,
|
|
95
|
-
data:
|
|
95
|
+
data: CreateChannelInstallationInput,
|
|
96
96
|
): Promise<ChannelInstallation> {
|
|
97
97
|
const now = nowISO();
|
|
98
98
|
|
|
@@ -114,7 +114,7 @@ export class LocalChannelInstallationStore implements ChannelInstallationStore {
|
|
|
114
114
|
tenantId,
|
|
115
115
|
channel: data.channel,
|
|
116
116
|
name: data.name,
|
|
117
|
-
config: data.config,
|
|
117
|
+
config: { ...data.config },
|
|
118
118
|
enabled: data.enabled !== false,
|
|
119
119
|
fallbackAgentId: data.fallbackAgentId,
|
|
120
120
|
rejectWhenNoBinding: data.rejectWhenNoBinding ?? false,
|
|
@@ -129,7 +129,7 @@ export class LocalChannelInstallationStore implements ChannelInstallationStore {
|
|
|
129
129
|
updates: UpdateChannelInstallationRequest,
|
|
130
130
|
): Promise<ChannelInstallation | null> {
|
|
131
131
|
const existing = await this.getInstallationById(installationId);
|
|
132
|
-
if (!existing) return null;
|
|
132
|
+
if (!existing || existing.tenantId !== tenantId) return null;
|
|
133
133
|
|
|
134
134
|
const setClauses: string[] = [];
|
|
135
135
|
const values: unknown[] = [];
|
|
@@ -145,10 +145,11 @@ export class LocalChannelInstallationStore implements ChannelInstallationStore {
|
|
|
145
145
|
const now = nowISO();
|
|
146
146
|
setClauses.push("updated_at = ?");
|
|
147
147
|
values.push(now);
|
|
148
|
+
values.push(tenantId);
|
|
148
149
|
values.push(installationId);
|
|
149
150
|
|
|
150
151
|
this.db.prepare(
|
|
151
|
-
`UPDATE lt_channel_installations SET ${setClauses.join(", ")} WHERE id = ?`,
|
|
152
|
+
`UPDATE lt_channel_installations SET ${setClauses.join(", ")} WHERE tenant_id = ? AND id = ?`,
|
|
152
153
|
).run(...values);
|
|
153
154
|
|
|
154
155
|
return this.getInstallationById(installationId);
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { ProjectBotMembership, ProjectBotMembershipStatus, ProjectBotMembershipStore, ProjectBotRole } from "@axiom-lattice/protocols";
|
|
2
|
+
import { DatabaseWrapper } from "../database";
|
|
3
|
+
import { ensureProjectRoomSchema, isUniqueError, timestamp, transaction, validDate, write } from "./project-room-schema";
|
|
4
|
+
|
|
5
|
+
type Row = { id: unknown; tenant_id: unknown; workspace_id: unknown; project_id: unknown; room_id: unknown; assistant_id: unknown; role: unknown; title: unknown; responsibility: unknown; mention_name: unknown; status: unknown; room_thread_id: unknown; joined_at: unknown; updated_at: unknown };
|
|
6
|
+
type Conflict = "coordinator_conflict" | "mention_conflict";
|
|
7
|
+
const columns = "id, tenant_id, workspace_id, project_id, room_id, assistant_id, role, title, responsibility, mention_name, status, room_thread_id, joined_at, updated_at";
|
|
8
|
+
|
|
9
|
+
export class ProjectBotMembershipIdConflictError extends Error { constructor(tenantId: string, id: string) { super(`Project bot membership ID '${id}' already exists in tenant '${tenantId}'`); this.name = "ProjectBotMembershipIdConflictError"; } }
|
|
10
|
+
function isRole(value: unknown): value is ProjectBotRole { return value === "coordinator" || value === "specialist"; }
|
|
11
|
+
function isStatus(value: unknown): value is ProjectBotMembershipStatus { return value === "active" || value === "paused" || value === "removed"; }
|
|
12
|
+
function mapRow(row: Row): ProjectBotMembership {
|
|
13
|
+
const joinedAt = typeof row.joined_at === "string" ? new Date(row.joined_at) : undefined;
|
|
14
|
+
const updatedAt = typeof row.updated_at === "string" ? new Date(row.updated_at) : undefined;
|
|
15
|
+
if (typeof row.id !== "string" || typeof row.tenant_id !== "string" || typeof row.workspace_id !== "string"
|
|
16
|
+
|| typeof row.project_id !== "string" || typeof row.room_id !== "string" || typeof row.assistant_id !== "string"
|
|
17
|
+
|| !isRole(row.role) || typeof row.title !== "string" || (row.responsibility !== null && typeof row.responsibility !== "string")
|
|
18
|
+
|| typeof row.mention_name !== "string" || !isStatus(row.status) || typeof row.room_thread_id !== "string"
|
|
19
|
+
|| !validDate(joinedAt) || !validDate(updatedAt)) throw new Error("Invalid project bot membership row");
|
|
20
|
+
return { id: row.id, tenantId: row.tenant_id, workspaceId: row.workspace_id, projectId: row.project_id,
|
|
21
|
+
roomId: row.room_id, assistantId: row.assistant_id, role: row.role, title: row.title,
|
|
22
|
+
...(row.responsibility === null ? {} : { responsibility: row.responsibility }), mentionName: row.mention_name,
|
|
23
|
+
status: row.status, roomThreadId: row.room_thread_id, joinedAt, updatedAt };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Persists durable project bot roster memberships in local SQLite. */
|
|
27
|
+
export class LocalProjectBotMembershipStore implements ProjectBotMembershipStore {
|
|
28
|
+
constructor(private readonly db: DatabaseWrapper) { ensureProjectRoomSchema(db); }
|
|
29
|
+
async list(tenantId: string, projectId: string): Promise<ProjectBotMembership[]> { return (this.db.prepare(`SELECT ${columns} FROM lt_project_bot_memberships WHERE tenant_id = ? AND project_id = ? ORDER BY joined_at ASC, id ASC`).all(tenantId, projectId) as Row[]).map(mapRow); }
|
|
30
|
+
async findById(tenantId: string, id: string): Promise<ProjectBotMembership | null> { const row = this.byId(tenantId, id); return row ? mapRow(row) : null; }
|
|
31
|
+
async findByAssistant(tenantId: string, projectId: string, assistantId: string): Promise<ProjectBotMembership | null> { const row = this.db.prepare(`SELECT ${columns} FROM lt_project_bot_memberships WHERE tenant_id = ? AND project_id = ? AND assistant_id = ?`).get(tenantId, projectId, assistantId) as Row | undefined; return row ? mapRow(row) : null; }
|
|
32
|
+
async save(input: Omit<ProjectBotMembership, "joinedAt" | "updatedAt">): Promise<{ kind: "created" | "updated" | "reactivated"; membership: ProjectBotMembership } | { kind: Conflict }> {
|
|
33
|
+
try {
|
|
34
|
+
return transaction(this.db, () => {
|
|
35
|
+
const existing = this.db.prepare(`SELECT ${columns} FROM lt_project_bot_memberships WHERE tenant_id = ? AND project_id = ? AND assistant_id = ?`).get(input.tenantId, input.projectId, input.assistantId) as Row | undefined;
|
|
36
|
+
if (existing) return this.reactivate(existing, input);
|
|
37
|
+
if (this.byId(input.tenantId, input.id)) throw new ProjectBotMembershipIdConflictError(input.tenantId, input.id);
|
|
38
|
+
const conflict = this.preflight(input.tenantId, input.projectId, input.roomId, input.role, input.mentionName, input.status, input.id);
|
|
39
|
+
if (conflict) return { kind: conflict };
|
|
40
|
+
const now = timestamp();
|
|
41
|
+
try { write(this.db, "INSERT INTO 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) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [input.id, input.tenantId, input.workspaceId, input.projectId, input.roomId, input.assistantId, input.role, input.title, input.responsibility ?? null, input.mentionName, input.status, input.roomThreadId, now, now]); }
|
|
42
|
+
catch (error) {
|
|
43
|
+
if (!isUniqueError(error)) throw error;
|
|
44
|
+
if (this.byId(input.tenantId, input.id)) throw new ProjectBotMembershipIdConflictError(input.tenantId, input.id);
|
|
45
|
+
const raced = this.byAssistant(input.tenantId, input.projectId, input.assistantId);
|
|
46
|
+
if (raced) return this.reactivate(raced, input);
|
|
47
|
+
const racedConflict = this.preflight(input.tenantId, input.projectId, input.roomId, input.role, input.mentionName, input.status, input.id);
|
|
48
|
+
if (racedConflict) return { kind: racedConflict };
|
|
49
|
+
throw new Error("Unclassified project bot membership uniqueness conflict");
|
|
50
|
+
}
|
|
51
|
+
return { kind: "created", membership: mapRow(this.byId(input.tenantId, input.id) as Row) };
|
|
52
|
+
});
|
|
53
|
+
} catch (error) { const conflict = this.conflict(error); if (conflict) return { kind: conflict }; throw error; }
|
|
54
|
+
}
|
|
55
|
+
async update(input: { tenantId: string; id: string; patch: Partial<Pick<ProjectBotMembership, "role" | "title" | "responsibility" | "mentionName" | "status">>; expectedUpdatedAt: Date }): Promise<{ kind: "updated"; membership: ProjectBotMembership } | { kind: "not_found" | "conflict" | Conflict }> {
|
|
56
|
+
try {
|
|
57
|
+
return transaction(this.db, () => {
|
|
58
|
+
const row = this.byId(input.tenantId, input.id); if (!row) return { kind: "not_found" as const };
|
|
59
|
+
const existing = mapRow(row);
|
|
60
|
+
if (!validDate(input.expectedUpdatedAt) || existing.updatedAt.getTime() !== input.expectedUpdatedAt.getTime()) return { kind: "conflict" as const };
|
|
61
|
+
const candidate = { ...existing, ...input.patch };
|
|
62
|
+
const conflict = this.preflight(candidate.tenantId, candidate.projectId, candidate.roomId, candidate.role, candidate.mentionName, candidate.status, candidate.id);
|
|
63
|
+
if (conflict) return { kind: conflict };
|
|
64
|
+
let changes: number;
|
|
65
|
+
try { changes = write(this.db, "UPDATE lt_project_bot_memberships SET role = ?, title = ?, responsibility = ?, mention_name = ?, status = ?, updated_at = ? WHERE tenant_id = ? AND id = ? AND updated_at = ?", [candidate.role, candidate.title, candidate.responsibility ?? null, candidate.mentionName, candidate.status, timestamp(row.updated_at as string), input.tenantId, input.id, row.updated_at as string]); }
|
|
66
|
+
catch (error) {
|
|
67
|
+
if (!isUniqueError(error)) throw error;
|
|
68
|
+
const racedConflict = this.preflight(candidate.tenantId, candidate.projectId, candidate.roomId, candidate.role, candidate.mentionName, candidate.status, candidate.id);
|
|
69
|
+
if (racedConflict) return { kind: racedConflict };
|
|
70
|
+
throw new Error("Unclassified project bot membership uniqueness conflict");
|
|
71
|
+
}
|
|
72
|
+
if (changes === 0) return { kind: "conflict" as const };
|
|
73
|
+
return { kind: "updated" as const, membership: mapRow(this.byId(input.tenantId, input.id) as Row) };
|
|
74
|
+
});
|
|
75
|
+
} catch (error) { const conflict = this.conflict(error); if (conflict) return { kind: conflict }; throw error; }
|
|
76
|
+
}
|
|
77
|
+
private byId(tenantId: string, id: string): Row | undefined { return this.db.prepare(`SELECT ${columns} FROM lt_project_bot_memberships WHERE tenant_id = ? AND id = ?`).get(tenantId, id) as Row | undefined; }
|
|
78
|
+
private byAssistant(tenantId: string, projectId: string, assistantId: string): Row | undefined { return this.db.prepare(`SELECT ${columns} FROM lt_project_bot_memberships WHERE tenant_id = ? AND project_id = ? AND assistant_id = ?`).get(tenantId, projectId, assistantId) as Row | undefined; }
|
|
79
|
+
private reactivate(existing: Row, input: Omit<ProjectBotMembership, "joinedAt" | "updatedAt">): { kind: "updated" | "reactivated"; membership: ProjectBotMembership } | { kind: Conflict } {
|
|
80
|
+
const persisted = mapRow(existing);
|
|
81
|
+
const wasRemoved = persisted.status === "removed";
|
|
82
|
+
const conflict = this.preflight(input.tenantId, input.projectId, persisted.roomId, input.role, input.mentionName, "active", persisted.id);
|
|
83
|
+
if (conflict) return { kind: conflict };
|
|
84
|
+
try { write(this.db, "UPDATE lt_project_bot_memberships SET role = ?, title = ?, responsibility = ?, mention_name = ?, status = 'active', updated_at = ? WHERE tenant_id = ? AND id = ?", [input.role, input.title, input.responsibility ?? null, input.mentionName, timestamp(existing.updated_at as string), input.tenantId, persisted.id]); }
|
|
85
|
+
catch (error) {
|
|
86
|
+
if (!isUniqueError(error)) throw error;
|
|
87
|
+
const racedConflict = this.preflight(input.tenantId, input.projectId, persisted.roomId, input.role, input.mentionName, "active", persisted.id);
|
|
88
|
+
if (racedConflict) return { kind: racedConflict };
|
|
89
|
+
throw new Error("Unclassified project bot membership uniqueness conflict");
|
|
90
|
+
}
|
|
91
|
+
return { kind: wasRemoved ? "reactivated" : "updated", membership: mapRow(this.byId(input.tenantId, persisted.id) as Row) };
|
|
92
|
+
}
|
|
93
|
+
private preflight(tenantId: string, projectId: string, roomId: string, role: ProjectBotRole, mentionName: string, status: ProjectBotMembershipStatus, excludeId: string): Conflict | undefined {
|
|
94
|
+
if (status === "removed") return undefined;
|
|
95
|
+
if (role === "coordinator" && this.db.prepare("SELECT 1 FROM lt_project_bot_memberships WHERE tenant_id = ? AND project_id = ? AND id <> ? AND role = 'coordinator' AND status IN ('active', 'paused')").get(tenantId, projectId, excludeId)) return "coordinator_conflict";
|
|
96
|
+
if (this.db.prepare("SELECT 1 FROM lt_project_bot_memberships WHERE tenant_id = ? AND room_id = ? AND mention_name = ? AND id <> ? AND status IN ('active', 'paused')").get(tenantId, roomId, mentionName, excludeId)) return "mention_conflict";
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
private conflict(error: unknown): Conflict | undefined {
|
|
100
|
+
if (!isUniqueError(error) || !(error instanceof Error)) return undefined;
|
|
101
|
+
if (error.message.includes("tenant_id, lt_project_bot_memberships.project_id")) return "coordinator_conflict";
|
|
102
|
+
if (error.message.includes("room_id") && error.message.includes("mention_name")) return "mention_conflict";
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { ProjectHumanRole, ProjectMembership, ProjectMembershipMutationResult, ProjectMembershipStore } from "@axiom-lattice/protocols";
|
|
2
|
+
import { DatabaseWrapper } from "../database";
|
|
3
|
+
import { ensureProjectRoomSchema, isUniqueError, timestamp, transaction, validDate, write } from "./project-room-schema";
|
|
4
|
+
|
|
5
|
+
type Row = { id: unknown; tenant_id: unknown; project_id: unknown; user_id: unknown; role: unknown; status: unknown; joined_at: unknown; updated_at: unknown };
|
|
6
|
+
const columns = "id, tenant_id, project_id, user_id, role, status, joined_at, updated_at";
|
|
7
|
+
|
|
8
|
+
export class DuplicateProjectMembershipError extends Error { constructor() { super("Project membership already exists for tenant, project, and user"); this.name = "DuplicateProjectMembershipError"; } }
|
|
9
|
+
export class ProjectMembershipIdConflictError extends Error { constructor() { super("Project membership ID already exists for tenant"); this.name = "ProjectMembershipIdConflictError"; } }
|
|
10
|
+
function role(value: unknown): value is ProjectHumanRole { return value === "owner" || value === "admin" || value === "member" || value === "viewer"; }
|
|
11
|
+
function mapRow(row: Row): ProjectMembership {
|
|
12
|
+
const joinedAt = typeof row.joined_at === "string" ? new Date(row.joined_at) : undefined;
|
|
13
|
+
const updatedAt = typeof row.updated_at === "string" ? new Date(row.updated_at) : undefined;
|
|
14
|
+
if (typeof row.id !== "string" || typeof row.tenant_id !== "string" || typeof row.project_id !== "string"
|
|
15
|
+
|| typeof row.user_id !== "string" || !role(row.role) || (row.status !== "active" && row.status !== "removed")
|
|
16
|
+
|| !validDate(joinedAt) || !validDate(updatedAt)) throw new Error("Invalid project membership row");
|
|
17
|
+
return { id: row.id, tenantId: row.tenant_id, projectId: row.project_id, userId: row.user_id,
|
|
18
|
+
role: row.role, status: row.status, joinedAt, updatedAt };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Persists human project memberships in local SQLite. */
|
|
22
|
+
export class LocalProjectMembershipStore implements ProjectMembershipStore {
|
|
23
|
+
constructor(private readonly db: DatabaseWrapper) { ensureProjectRoomSchema(db); }
|
|
24
|
+
async list(tenantId: string, projectId: string): Promise<ProjectMembership[]> { return (this.db.prepare(`SELECT ${columns} FROM lt_project_memberships WHERE tenant_id = ? AND project_id = ? ORDER BY joined_at ASC, id ASC`).all(tenantId, projectId) as Row[]).map(mapRow); }
|
|
25
|
+
async findByUser(tenantId: string, projectId: string, userId: string): Promise<ProjectMembership | null> { const row = this.db.prepare(`SELECT ${columns} FROM lt_project_memberships WHERE tenant_id = ? AND project_id = ? AND user_id = ?`).get(tenantId, projectId, userId) as Row | undefined; return row ? mapRow(row) : null; }
|
|
26
|
+
async create(input: Omit<ProjectMembership, "joinedAt" | "updatedAt">): Promise<ProjectMembership> {
|
|
27
|
+
return transaction(this.db, () => {
|
|
28
|
+
if (this.byId(input.tenantId, input.id)) throw new ProjectMembershipIdConflictError();
|
|
29
|
+
const now = timestamp();
|
|
30
|
+
try { write(this.db, "INSERT INTO lt_project_memberships (id, tenant_id, project_id, user_id, role, status, joined_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", [input.id, input.tenantId, input.projectId, input.userId, input.role, input.status, now, now]); }
|
|
31
|
+
catch (error) { if (isUniqueError(error)) throw this.classifyUnique(input.tenantId, input.id, input.projectId, input.userId); throw error; }
|
|
32
|
+
return mapRow(this.byId(input.tenantId, input.id) as Row);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
async createInitialOwner(input: Omit<ProjectMembership, "role" | "status" | "joinedAt" | "updatedAt">): Promise<{ kind: "created" | "existing"; membership: ProjectMembership } | { kind: "already_initialized" }> {
|
|
36
|
+
return transaction(this.db, () => {
|
|
37
|
+
const rows = this.db.prepare(`SELECT ${columns} FROM lt_project_memberships WHERE tenant_id = ? AND project_id = ?`).all(input.tenantId, input.projectId) as Row[];
|
|
38
|
+
if (rows.length > 0) { const existing = rows.map(mapRow).find((item) => item.userId === input.userId && item.role === "owner" && item.status === "active"); return existing ? { kind: "existing", membership: existing } : { kind: "already_initialized" }; }
|
|
39
|
+
if (this.byId(input.tenantId, input.id)) throw new ProjectMembershipIdConflictError();
|
|
40
|
+
const now = timestamp();
|
|
41
|
+
try { write(this.db, "INSERT INTO lt_project_memberships (id, tenant_id, project_id, user_id, role, status, joined_at, updated_at) VALUES (?, ?, ?, ?, 'owner', 'active', ?, ?)", [input.id, input.tenantId, input.projectId, input.userId, now, now]); }
|
|
42
|
+
catch (error) { if (isUniqueError(error)) throw this.classifyUnique(input.tenantId, input.id, input.projectId, input.userId); throw error; }
|
|
43
|
+
return { kind: "created", membership: mapRow(this.byId(input.tenantId, input.id) as Row) };
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
async updateRole(input: { tenantId: string; id: string; role: ProjectHumanRole; expectedUpdatedAt: Date }): Promise<ProjectMembershipMutationResult> { return this.mutate(input.tenantId, input.id, input.expectedUpdatedAt, input.role, false); }
|
|
47
|
+
async remove(input: { tenantId: string; id: string; expectedUpdatedAt: Date }): Promise<ProjectMembershipMutationResult> { return this.mutate(input.tenantId, input.id, input.expectedUpdatedAt, undefined, true); }
|
|
48
|
+
private mutate(tenantId: string, id: string, expected: Date, nextRole: ProjectHumanRole, remove: false): ProjectMembershipMutationResult;
|
|
49
|
+
private mutate(tenantId: string, id: string, expected: Date, nextRole: undefined, remove: true): ProjectMembershipMutationResult;
|
|
50
|
+
private mutate(tenantId: string, id: string, expected: Date, nextRole: ProjectHumanRole | undefined, remove: boolean): ProjectMembershipMutationResult {
|
|
51
|
+
return transaction(this.db, () => {
|
|
52
|
+
const row = this.byId(tenantId, id); if (!row) return { kind: "not_found" };
|
|
53
|
+
const current = mapRow(row); if (!validDate(expected) || current.updatedAt.getTime() !== expected.getTime()) return { kind: "conflict" };
|
|
54
|
+
if (current.role === "owner" && current.status === "active" && (remove || nextRole !== "owner")) {
|
|
55
|
+
const other = this.db.prepare("SELECT 1 FROM lt_project_memberships WHERE tenant_id = ? AND project_id = ? AND id <> ? AND role = 'owner' AND status = 'active'").get(tenantId, current.projectId, id);
|
|
56
|
+
if (!other) return { kind: "last_owner" };
|
|
57
|
+
}
|
|
58
|
+
const updatedAt = timestamp(row.updated_at as string);
|
|
59
|
+
const changes = remove
|
|
60
|
+
? write(this.db, "UPDATE lt_project_memberships SET status = 'removed', updated_at = ? WHERE tenant_id = ? AND id = ? AND updated_at = ?", [updatedAt, tenantId, id, row.updated_at as string])
|
|
61
|
+
: write(this.db, "UPDATE lt_project_memberships SET role = ?, updated_at = ? WHERE tenant_id = ? AND id = ? AND updated_at = ?", [nextRole as ProjectHumanRole, updatedAt, tenantId, id, row.updated_at as string]);
|
|
62
|
+
if (changes === 0) return { kind: "conflict" };
|
|
63
|
+
return { kind: remove ? "removed" : "updated", membership: mapRow(this.byId(tenantId, id) as Row) };
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
private byId(tenantId: string, id: string): Row | undefined { return this.db.prepare(`SELECT ${columns} FROM lt_project_memberships WHERE tenant_id = ? AND id = ?`).get(tenantId, id) as Row | undefined; }
|
|
67
|
+
private classifyUnique(tenantId: string, id: string, projectId: string, userId: string): Error {
|
|
68
|
+
if (this.byId(tenantId, id)) return new ProjectMembershipIdConflictError();
|
|
69
|
+
if (this.db.prepare("SELECT 1 FROM lt_project_memberships WHERE tenant_id = ? AND project_id = ? AND user_id = ?").get(tenantId, projectId, userId)) return new DuplicateProjectMembershipError();
|
|
70
|
+
return new Error("Unclassified project membership uniqueness conflict");
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { ProjectRoomMention, ProjectRoomMessage, ProjectRoomMessageAuthor, ProjectRoomMessageSource, ProjectRoomMessageStore } from "@axiom-lattice/protocols";
|
|
2
|
+
import { DatabaseWrapper } from "../database";
|
|
3
|
+
import { ensureProjectRoomSchema, isUniqueError, timestamp, transaction, validDate, write } from "./project-room-schema";
|
|
4
|
+
|
|
5
|
+
type Row = { id: unknown; tenant_id: unknown; workspace_id: unknown; project_id: unknown; room_id: unknown; author: unknown; content: unknown; mentions: unknown; reply_to_message_id: unknown; source: unknown; source_id: unknown; idempotency_key: unknown; created_at: unknown };
|
|
6
|
+
const columns = "id, tenant_id, workspace_id, project_id, room_id, author, content, mentions, reply_to_message_id, source, source_id, idempotency_key, created_at";
|
|
7
|
+
export class ProjectRoomMessageIdConflictError extends Error { constructor(tenantId: string, id: string) { super(`Project room message ID '${id}' already exists in tenant '${tenantId}'`); this.name = "ProjectRoomMessageIdConflictError"; } }
|
|
8
|
+
export class DuplicateProjectRoomMessageIdempotencyKeyError extends Error { constructor(tenantId: string, roomId: string, key: string) { super(`Project room message idempotency key '${key}' already exists in tenant '${tenantId}' room '${roomId}'`); this.name = "DuplicateProjectRoomMessageIdempotencyKeyError"; } }
|
|
9
|
+
function record(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }
|
|
10
|
+
function only(value: Record<string, unknown>, keys: string[]): boolean { return Object.keys(value).every((key) => keys.includes(key)); }
|
|
11
|
+
function parseJson(value: unknown): unknown { if (typeof value !== "string") return undefined; try { return JSON.parse(value) as unknown; } catch { return undefined; } }
|
|
12
|
+
function author(value: unknown): ProjectRoomMessageAuthor | undefined { if (!record(value)) return undefined; if (value.type === "human" && only(value, ["type", "userId"]) && typeof value.userId === "string") return { type: "human", userId: value.userId }; if (value.type === "bot" && only(value, ["type", "membershipId", "assistantId"]) && typeof value.membershipId === "string" && typeof value.assistantId === "string") return { type: "bot", membershipId: value.membershipId, assistantId: value.assistantId }; if (value.type === "system" && only(value, ["type"])) return { type: "system" }; return undefined; }
|
|
13
|
+
function mention(value: unknown): ProjectRoomMention | undefined { if (!record(value)) return undefined; if (value.type === "bot" && only(value, ["type", "membershipId"]) && typeof value.membershipId === "string") return { type: "bot", membershipId: value.membershipId }; if (value.type === "team" && only(value, ["type"])) return { type: "team" }; return undefined; }
|
|
14
|
+
function source(value: unknown): value is ProjectRoomMessageSource { return value === "user" || value === "agent" || value === "task" || value === "routine" || value === "system"; }
|
|
15
|
+
function mapRow(row: Row): ProjectRoomMessage {
|
|
16
|
+
const mappedAuthor = author(parseJson(row.author)); const content = parseJson(row.content); const rawMentions = parseJson(row.mentions);
|
|
17
|
+
const mappedMentions = Array.isArray(rawMentions) ? rawMentions.map(mention) : undefined;
|
|
18
|
+
const createdAt = typeof row.created_at === "string" ? new Date(row.created_at) : undefined;
|
|
19
|
+
if (typeof row.id !== "string" || typeof row.tenant_id !== "string" || typeof row.workspace_id !== "string" || typeof row.project_id !== "string" || typeof row.room_id !== "string" || !mappedAuthor
|
|
20
|
+
|| !record(content) || content.type !== "text" || typeof content.text !== "string" || !only(content, ["type", "text"])
|
|
21
|
+
|| !mappedMentions || mappedMentions.some((item) => item === undefined) || (row.reply_to_message_id !== null && typeof row.reply_to_message_id !== "string")
|
|
22
|
+
|| !source(row.source) || (row.source_id !== null && typeof row.source_id !== "string") || (row.idempotency_key !== null && typeof row.idempotency_key !== "string") || !validDate(createdAt)) throw new Error("Invalid project room message row");
|
|
23
|
+
return { id: row.id, tenantId: row.tenant_id, workspaceId: row.workspace_id, projectId: row.project_id, roomId: row.room_id,
|
|
24
|
+
author: mappedAuthor, content: { type: "text", text: content.text }, mentions: mappedMentions as ProjectRoomMention[],
|
|
25
|
+
...(row.reply_to_message_id === null ? {} : { replyToMessageId: row.reply_to_message_id }), source: row.source,
|
|
26
|
+
...(row.source_id === null ? {} : { sourceId: row.source_id }), ...(row.idempotency_key === null ? {} : { idempotencyKey: row.idempotency_key }), createdAt };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Persists validated project room messages in local SQLite. */
|
|
30
|
+
export class LocalProjectRoomMessageStore implements ProjectRoomMessageStore {
|
|
31
|
+
constructor(private readonly db: DatabaseWrapper) { ensureProjectRoomSchema(db); }
|
|
32
|
+
async create(input: Omit<ProjectRoomMessage, "createdAt">): Promise<ProjectRoomMessage> { return this.insert(input, false); }
|
|
33
|
+
async createIdempotent(input: Omit<ProjectRoomMessage, "createdAt"> & { idempotencyKey: string }): Promise<ProjectRoomMessage> { return this.insert(input, true); }
|
|
34
|
+
async list(input: { tenantId: string; roomId: string; before?: { createdAt: Date; id: string }; limit: number }): Promise<ProjectRoomMessage[]> {
|
|
35
|
+
if (input.before && !validDate(input.before.createdAt)) throw new RangeError("Project room message cursor date is invalid");
|
|
36
|
+
const limit = Math.min(100, Math.max(1, Math.trunc(Number.isFinite(input.limit) ? input.limit : 1)));
|
|
37
|
+
const rows = input.before
|
|
38
|
+
? this.db.prepare(`SELECT ${columns} FROM lt_project_room_messages WHERE tenant_id = ? AND room_id = ? AND (created_at < ? OR (created_at = ? AND id < ?)) ORDER BY created_at DESC, id DESC LIMIT ?`).all(input.tenantId, input.roomId, input.before.createdAt.toISOString(), input.before.createdAt.toISOString(), input.before.id, limit)
|
|
39
|
+
: this.db.prepare(`SELECT ${columns} FROM lt_project_room_messages WHERE tenant_id = ? AND room_id = ? ORDER BY created_at DESC, id DESC LIMIT ?`).all(input.tenantId, input.roomId, limit);
|
|
40
|
+
return (rows as Row[]).map(mapRow);
|
|
41
|
+
}
|
|
42
|
+
async findById(tenantId: string, id: string): Promise<ProjectRoomMessage | null> { const row = this.byId(tenantId, id); return row ? mapRow(row) : null; }
|
|
43
|
+
private insert(input: Omit<ProjectRoomMessage, "createdAt">, idempotent: boolean): ProjectRoomMessage {
|
|
44
|
+
return transaction(this.db, () => {
|
|
45
|
+
if (idempotent && input.idempotencyKey) { const canonical = this.byKey(input.tenantId, input.roomId, input.idempotencyKey); if (canonical) return mapRow(canonical); }
|
|
46
|
+
if (this.byId(input.tenantId, input.id)) throw new ProjectRoomMessageIdConflictError(input.tenantId, input.id);
|
|
47
|
+
try { write(this.db, "INSERT INTO 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) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [input.id, input.tenantId, input.workspaceId, input.projectId, input.roomId, JSON.stringify(input.author), JSON.stringify(input.content), JSON.stringify(input.mentions), input.replyToMessageId ?? null, input.source, input.sourceId ?? null, input.idempotencyKey ?? null, timestamp()]); }
|
|
48
|
+
catch (error) {
|
|
49
|
+
if (!isUniqueError(error)) throw error;
|
|
50
|
+
if (this.byId(input.tenantId, input.id)) throw new ProjectRoomMessageIdConflictError(input.tenantId, input.id);
|
|
51
|
+
if (input.idempotencyKey) {
|
|
52
|
+
const canonical = this.byKey(input.tenantId, input.roomId, input.idempotencyKey);
|
|
53
|
+
if (canonical && idempotent) return mapRow(canonical);
|
|
54
|
+
if (canonical) throw new DuplicateProjectRoomMessageIdempotencyKeyError(input.tenantId, input.roomId, input.idempotencyKey);
|
|
55
|
+
}
|
|
56
|
+
throw new Error("Unclassified project room message uniqueness conflict");
|
|
57
|
+
}
|
|
58
|
+
return mapRow(this.byId(input.tenantId, input.id) as Row);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
private byId(tenantId: string, id: string): Row | undefined { return this.db.prepare(`SELECT ${columns} FROM lt_project_room_messages WHERE tenant_id = ? AND id = ?`).get(tenantId, id) as Row | undefined; }
|
|
62
|
+
private byKey(tenantId: string, roomId: string, key: string): Row | undefined { return this.db.prepare(`SELECT ${columns} FROM lt_project_room_messages WHERE tenant_id = ? AND room_id = ? AND idempotency_key = ?`).get(tenantId, roomId, key) as Row | undefined; }
|
|
63
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ProjectRoom, ProjectRoomStore } from "@axiom-lattice/protocols";
|
|
2
|
+
import { DatabaseWrapper } from "../database";
|
|
3
|
+
import { ensureProjectRoomSchema, timestamp, transaction, validDate, write } from "./project-room-schema";
|
|
4
|
+
|
|
5
|
+
type Row = { id: unknown; tenant_id: unknown; workspace_id: unknown; project_id: unknown; type: unknown; name: unknown; created_at: unknown; updated_at: unknown };
|
|
6
|
+
const columns = "id, tenant_id, workspace_id, project_id, type, name, created_at, updated_at";
|
|
7
|
+
|
|
8
|
+
function mapRow(row: Row): ProjectRoom {
|
|
9
|
+
const createdAt = typeof row.created_at === "string" ? new Date(row.created_at) : undefined;
|
|
10
|
+
const updatedAt = typeof row.updated_at === "string" ? new Date(row.updated_at) : undefined;
|
|
11
|
+
if (typeof row.id !== "string" || typeof row.tenant_id !== "string" || typeof row.workspace_id !== "string"
|
|
12
|
+
|| typeof row.project_id !== "string" || row.type !== "main" || typeof row.name !== "string"
|
|
13
|
+
|| !validDate(createdAt) || !validDate(updatedAt)) throw new Error("Invalid project room row");
|
|
14
|
+
return { id: row.id, tenantId: row.tenant_id, workspaceId: row.workspace_id, projectId: row.project_id,
|
|
15
|
+
type: "main", name: row.name, createdAt, updatedAt };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Persists canonical project main rooms in local SQLite. */
|
|
19
|
+
export class LocalProjectRoomStore implements ProjectRoomStore {
|
|
20
|
+
constructor(private readonly db: DatabaseWrapper) { ensureProjectRoomSchema(db); }
|
|
21
|
+
async ensureMainRoom(input: { id: string; tenantId: string; workspaceId: string; projectId: string; name: string }): Promise<ProjectRoom> {
|
|
22
|
+
return transaction(this.db, () => {
|
|
23
|
+
const existing = this.getRow(input.tenantId, input.projectId);
|
|
24
|
+
if (existing) return mapRow(existing);
|
|
25
|
+
const now = timestamp();
|
|
26
|
+
write(this.db, "INSERT INTO lt_project_rooms (id, tenant_id, workspace_id, project_id, type, name, created_at, updated_at) VALUES (?, ?, ?, ?, 'main', ?, ?, ?)", [input.id, input.tenantId, input.workspaceId, input.projectId, input.name, now, now]);
|
|
27
|
+
return mapRow(this.getRow(input.tenantId, input.projectId) as Row);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
async getMainRoom(tenantId: string, projectId: string): Promise<ProjectRoom | null> {
|
|
31
|
+
const row = this.getRow(tenantId, projectId);
|
|
32
|
+
return row ? mapRow(row) : null;
|
|
33
|
+
}
|
|
34
|
+
private getRow(tenantId: string, projectId: string): Row | undefined {
|
|
35
|
+
return this.db.prepare(`SELECT ${columns} FROM lt_project_rooms WHERE tenant_id = ? AND project_id = ? AND type = 'main'`).get(tenantId, projectId) as Row | undefined;
|
|
36
|
+
}
|
|
37
|
+
}
|