@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
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, jest } from "@jest/globals";
|
|
2
|
+
import { existsSync, unlinkSync } from "fs";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import { closeDatabase, initDatabase } from "../database";
|
|
6
|
+
import {
|
|
7
|
+
DuplicateProjectMembershipError,
|
|
8
|
+
ProjectMembershipIdConflictError,
|
|
9
|
+
LocalProjectMembershipStore,
|
|
10
|
+
} from "../stores/LocalProjectMembershipStore";
|
|
11
|
+
import {
|
|
12
|
+
LocalProjectBotMembershipStore,
|
|
13
|
+
ProjectBotMembershipIdConflictError,
|
|
14
|
+
} from "../stores/LocalProjectBotMembershipStore";
|
|
15
|
+
import {
|
|
16
|
+
DuplicateProjectRoomMessageIdempotencyKeyError,
|
|
17
|
+
LocalProjectRoomMessageStore,
|
|
18
|
+
ProjectRoomMessageIdConflictError,
|
|
19
|
+
} from "../stores/LocalProjectRoomMessageStore";
|
|
20
|
+
import { LocalProjectRoomStore } from "../stores/LocalProjectRoomStore";
|
|
21
|
+
import type { DatabaseWrapper } from "../database";
|
|
22
|
+
import { transaction, write } from "../stores/project-room-schema";
|
|
23
|
+
|
|
24
|
+
const nextDbPath = (): string => path.join(tmpdir(), `lt-project-room-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
|
|
25
|
+
const storesFor = (db: DatabaseWrapper) => ({
|
|
26
|
+
rooms: new LocalProjectRoomStore(db),
|
|
27
|
+
memberships: new LocalProjectMembershipStore(db),
|
|
28
|
+
bots: new LocalProjectBotMembershipStore(db),
|
|
29
|
+
messages: new LocalProjectRoomMessageStore(db),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("local project room stores", () => {
|
|
33
|
+
let dbPath: string;
|
|
34
|
+
|
|
35
|
+
beforeEach(() => { dbPath = nextDbPath(); });
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
closeDatabase();
|
|
38
|
+
if (existsSync(dbPath)) unlinkSync(dbPath);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("shares one versioned schema and persists a canonical main room across reopen", async () => {
|
|
42
|
+
const db = await initDatabase({ dbPath });
|
|
43
|
+
const save = jest.spyOn(db, "save");
|
|
44
|
+
const { rooms } = storesFor(db);
|
|
45
|
+
expect(save).toHaveBeenCalledTimes(1);
|
|
46
|
+
const first = await rooms.ensureMainRoom({ id: "room-1", tenantId: "t1", workspaceId: "w1", projectId: "p1", name: "Main" });
|
|
47
|
+
const canonical = await rooms.ensureMainRoom({ id: "other", tenantId: "t1", workspaceId: "w2", projectId: "p1", name: "Ignored" });
|
|
48
|
+
expect(canonical).toEqual(first);
|
|
49
|
+
expect(db.prepare("SELECT COUNT(*) AS count FROM lattice_schema_migrations WHERE store_name = ?").get("project_room")).toEqual({ count: 1 });
|
|
50
|
+
|
|
51
|
+
closeDatabase();
|
|
52
|
+
const reopened = storesFor(await initDatabase({ dbPath }));
|
|
53
|
+
expect(await reopened.rooms.getMainRoom("t1", "p1")).toEqual(first);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("rejects an incompatible preexisting table without recording or partially applying the migration", async () => {
|
|
57
|
+
const db = await initDatabase({ dbPath });
|
|
58
|
+
db.exec("CREATE TABLE lt_project_rooms (id TEXT NOT NULL)");
|
|
59
|
+
|
|
60
|
+
expect(() => new LocalProjectRoomStore(db)).toThrow(/Invalid project room schema.*lt_project_rooms/);
|
|
61
|
+
expect(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'lt_project_memberships'").get()).toBeUndefined();
|
|
62
|
+
expect(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'lattice_schema_migrations'").get()).toBeUndefined();
|
|
63
|
+
expect(db.prepare("PRAGMA table_info(lt_project_rooms)").all()).toHaveLength(1);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("rejects a structurally valid project room table missing its required check", async () => {
|
|
67
|
+
const db = await initDatabase({ dbPath });
|
|
68
|
+
db.exec(`CREATE TABLE lt_project_rooms (
|
|
69
|
+
"id" TEXT NOT NULL, "tenant_id" TEXT NOT NULL, "workspace_id" TEXT NOT NULL,
|
|
70
|
+
"project_id" TEXT NOT NULL, "type" TEXT NOT NULL, "name" TEXT NOT NULL,
|
|
71
|
+
"created_at" TEXT NOT NULL, "updated_at" TEXT NOT NULL,
|
|
72
|
+
PRIMARY KEY ("tenant_id", "id"), UNIQUE ("tenant_id", "project_id", "type")
|
|
73
|
+
)`);
|
|
74
|
+
|
|
75
|
+
expect(() => new LocalProjectRoomStore(db)).toThrow("Invalid project room schema: table 'lt_project_rooms' is missing required CHECK constraint 'type'");
|
|
76
|
+
expect(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'lt_project_memberships'").get()).toBeUndefined();
|
|
77
|
+
expect(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'lattice_schema_migrations'").get()).toBeUndefined();
|
|
78
|
+
expect(db.prepare("PRAGMA table_info(lt_project_rooms)").all()).toHaveLength(8);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("rejects a broadened project room type check", async () => {
|
|
82
|
+
const db = await initDatabase({ dbPath });
|
|
83
|
+
db.exec(`CREATE TABLE lt_project_rooms (
|
|
84
|
+
id TEXT NOT NULL, tenant_id TEXT NOT NULL, workspace_id TEXT NOT NULL,
|
|
85
|
+
project_id TEXT NOT NULL, type TEXT NOT NULL CHECK (type = 'main' OR type = 'other'),
|
|
86
|
+
name TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
|
|
87
|
+
PRIMARY KEY (tenant_id, id), UNIQUE (tenant_id, project_id, type)
|
|
88
|
+
)`);
|
|
89
|
+
|
|
90
|
+
expect(() => new LocalProjectRoomStore(db)).toThrow("Invalid project room schema: table 'lt_project_rooms' is missing required CHECK constraint 'type'");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("rejects a broadened project membership role check", async () => {
|
|
94
|
+
const db = await initDatabase({ dbPath });
|
|
95
|
+
db.exec(`CREATE TABLE lt_project_memberships (
|
|
96
|
+
id TEXT NOT NULL, tenant_id TEXT NOT NULL, project_id TEXT NOT NULL, user_id TEXT NOT NULL,
|
|
97
|
+
role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member', 'viewer') OR 1 = 1),
|
|
98
|
+
status TEXT NOT NULL CHECK (status IN ('active', 'removed')),
|
|
99
|
+
joined_at TEXT NOT NULL, updated_at TEXT NOT NULL,
|
|
100
|
+
PRIMARY KEY (tenant_id, id), UNIQUE (tenant_id, project_id, user_id)
|
|
101
|
+
)`);
|
|
102
|
+
|
|
103
|
+
expect(() => new LocalProjectMembershipStore(db)).toThrow("Invalid project room schema: table 'lt_project_memberships' is missing required CHECK constraint 'role'");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("accepts valid checks with quoted identifiers and parenthesized formatting", async () => {
|
|
107
|
+
const db = await initDatabase({ dbPath });
|
|
108
|
+
db.exec(`CREATE TABLE lt_project_rooms (
|
|
109
|
+
"id" TEXT NOT NULL, "tenant_id" TEXT NOT NULL, "workspace_id" TEXT NOT NULL,
|
|
110
|
+
"project_id" TEXT NOT NULL, "type" TEXT NOT NULL CHECK ((("type") = ('main'))),
|
|
111
|
+
"name" TEXT NOT NULL, "created_at" TEXT NOT NULL, "updated_at" TEXT NOT NULL,
|
|
112
|
+
PRIMARY KEY ("tenant_id", "id"), UNIQUE ("tenant_id", "project_id", "type"),
|
|
113
|
+
CHECK ('value) with ''quote' = 'value) with ''quote')
|
|
114
|
+
)`);
|
|
115
|
+
|
|
116
|
+
expect(() => new LocalProjectRoomStore(db)).not.toThrow();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("validates an existing migration marker before accepting the schema", async () => {
|
|
120
|
+
const db = await initDatabase({ dbPath });
|
|
121
|
+
db.exec("CREATE TABLE lattice_schema_migrations (store_name TEXT NOT NULL, name TEXT NOT NULL, version INTEGER NOT NULL, applied_at TEXT NOT NULL, PRIMARY KEY (store_name, name)); CREATE TABLE lt_project_rooms (id TEXT NOT NULL)");
|
|
122
|
+
db.getRawDb().run("INSERT INTO lattice_schema_migrations VALUES (?, ?, ?, ?)", ["project_room", "create_project_room_tables", 1, new Date().toISOString()]);
|
|
123
|
+
|
|
124
|
+
expect(() => new LocalProjectRoomStore(db)).toThrow(/Invalid project room schema.*lt_project_rooms/);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("rejects nested transactions without rolling back the outer transaction", async () => {
|
|
128
|
+
const db = await initDatabase({ dbPath });
|
|
129
|
+
db.exec("CREATE TABLE transaction_probe (value TEXT NOT NULL)");
|
|
130
|
+
|
|
131
|
+
transaction(db, () => {
|
|
132
|
+
expect(() => transaction(db, () => undefined)).toThrow("Nested local store transactions are not supported");
|
|
133
|
+
write(db, "INSERT INTO transaction_probe (value) VALUES (?)", ["committed"]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
expect(db.prepare("SELECT value FROM transaction_probe").all()).toEqual([{ value: "committed" }]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("rolls back the outer transaction when a nested transaction error escapes", async () => {
|
|
140
|
+
const db = await initDatabase({ dbPath });
|
|
141
|
+
db.exec("CREATE TABLE transaction_probe (value TEXT NOT NULL)");
|
|
142
|
+
|
|
143
|
+
expect(() => transaction(db, () => {
|
|
144
|
+
write(db, "INSERT INTO transaction_probe (value) VALUES (?)", ["rolled-back"]);
|
|
145
|
+
transaction(db, () => undefined);
|
|
146
|
+
})).toThrow("Nested local store transactions are not supported");
|
|
147
|
+
|
|
148
|
+
expect(db.prepare("SELECT value FROM transaction_probe").all()).toEqual([]);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("clears the transaction guard when beginning a transaction fails", async () => {
|
|
152
|
+
const db = await initDatabase({ dbPath });
|
|
153
|
+
db.exec("CREATE TABLE transaction_probe (value TEXT NOT NULL)");
|
|
154
|
+
const originalExec = db.exec.bind(db);
|
|
155
|
+
jest.spyOn(db, "exec").mockImplementationOnce(() => { throw new Error("begin failed"); }).mockImplementation(originalExec);
|
|
156
|
+
|
|
157
|
+
expect(() => transaction(db, () => undefined)).toThrow("begin failed");
|
|
158
|
+
transaction(db, () => write(db, "INSERT INTO transaction_probe (value) VALUES (?)", ["recovered"]));
|
|
159
|
+
|
|
160
|
+
expect(db.prepare("SELECT value FROM transaction_probe").all()).toEqual([{ value: "recovered" }]);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("enforces deterministic human membership errors, initial owner, final owner, and CAS", async () => {
|
|
164
|
+
const store = new LocalProjectMembershipStore(await initDatabase({ dbPath }));
|
|
165
|
+
const owner = await store.createInitialOwner({ id: "m1", tenantId: "t1", projectId: "p1", userId: "u1" });
|
|
166
|
+
expect(owner.kind).toBe("created");
|
|
167
|
+
expect((await store.createInitialOwner({ id: "ignored", tenantId: "t1", projectId: "p1", userId: "u1" })).kind).toBe("existing");
|
|
168
|
+
expect((await store.createInitialOwner({ id: "m2", tenantId: "t1", projectId: "p1", userId: "u2" })).kind).toBe("already_initialized");
|
|
169
|
+
const membership = owner.kind === "created" ? owner.membership : (() => { throw new Error("expected owner"); })();
|
|
170
|
+
expect(await store.remove({ tenantId: "t1", id: "m1", expectedUpdatedAt: membership.updatedAt })).toEqual({ kind: "last_owner" });
|
|
171
|
+
|
|
172
|
+
const second = await store.create({ id: "m2", tenantId: "t1", projectId: "p1", userId: "u2", role: "owner", status: "active" });
|
|
173
|
+
await expect(store.create({ id: "m3", tenantId: "t1", projectId: "p1", userId: "u2", role: "member", status: "active" })).rejects.toBeInstanceOf(DuplicateProjectMembershipError);
|
|
174
|
+
await expect(store.create({ id: "m2", tenantId: "t1", projectId: "p2", userId: "u3", role: "member", status: "active" })).rejects.toBeInstanceOf(ProjectMembershipIdConflictError);
|
|
175
|
+
expect(await store.updateRole({ tenantId: "t1", id: "m2", role: "admin", expectedUpdatedAt: new Date(0) })).toEqual({ kind: "conflict" });
|
|
176
|
+
expect(await store.remove({ tenantId: "t1", id: "m1", expectedUpdatedAt: membership.updatedAt })).toMatchObject({ kind: "removed", membership: { status: "removed" } });
|
|
177
|
+
expect((await store.findByUser("t1", "p1", "u1"))?.status).toBe("removed");
|
|
178
|
+
expect((await store.list("t1", "p1")).map((item) => item.id)).toEqual(["m1", "m2"]);
|
|
179
|
+
expect(second.updatedAt).toBeInstanceOf(Date);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("classifies a human membership ID collision that occurs after preflight", async () => {
|
|
183
|
+
const db = await initDatabase({ dbPath });
|
|
184
|
+
const store = new LocalProjectMembershipStore(db);
|
|
185
|
+
const raw = db.getRawDb();
|
|
186
|
+
const originalRun = raw.run.bind(raw);
|
|
187
|
+
let injected = false;
|
|
188
|
+
jest.spyOn(raw, "run").mockImplementation((sql, params) => {
|
|
189
|
+
if (!injected && typeof sql === "string" && sql.startsWith("INSERT INTO lt_project_memberships")) {
|
|
190
|
+
injected = true;
|
|
191
|
+
originalRun("INSERT INTO lt_project_memberships VALUES (?, ?, ?, ?, ?, ?, ?, ?)", ["race-id", "t1", "other", "other-user", "member", "active", new Date().toISOString(), new Date().toISOString()]);
|
|
192
|
+
}
|
|
193
|
+
return originalRun(sql, params);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
await expect(store.create({ id: "race-id", tenantId: "t1", projectId: "p1", userId: "u1", role: "member", status: "active" })).rejects.toBeInstanceOf(ProjectMembershipIdConflictError);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("reactivates durable bot membership and preserves identity while enforcing active and paused uniqueness", async () => {
|
|
200
|
+
const store = new LocalProjectBotMembershipStore(await initDatabase({ dbPath }));
|
|
201
|
+
const base = { id: "b1", tenantId: "t1", workspaceId: "w1", projectId: "p1", roomId: "r1", assistantId: "a1", role: "coordinator" as const, title: "Lead", mentionName: "lead", status: "active" as const, roomThreadId: "th1" };
|
|
202
|
+
const saved = await store.save(base);
|
|
203
|
+
expect(saved.kind).toBe("created");
|
|
204
|
+
if (saved.kind !== "created") throw new Error("expected saved membership");
|
|
205
|
+
expect((await store.save({ ...base, id: "b2", assistantId: "a2", mentionName: "other", roomThreadId: "th2" })).kind).toBe("coordinator_conflict");
|
|
206
|
+
const paused = await store.update({ tenantId: "t1", id: "b1", patch: { status: "paused" }, expectedUpdatedAt: saved.membership.updatedAt });
|
|
207
|
+
expect(paused.kind).toBe("updated");
|
|
208
|
+
expect((await store.save({ ...base, id: "b3", assistantId: "a3", role: "specialist", roomThreadId: "th3" })).kind).toBe("mention_conflict");
|
|
209
|
+
if (paused.kind !== "updated") throw new Error("expected update");
|
|
210
|
+
const removed = await store.update({ tenantId: "t1", id: "b1", patch: { status: "removed" }, expectedUpdatedAt: paused.membership.updatedAt });
|
|
211
|
+
if (removed.kind !== "updated") throw new Error("expected removal");
|
|
212
|
+
const reactivated = await store.save({ ...base, id: "replacement", roomThreadId: "replacement-thread", title: "Lead again" });
|
|
213
|
+
expect(reactivated.kind).toBe("reactivated");
|
|
214
|
+
if (reactivated.kind !== "reactivated") throw new Error("expected reactivation");
|
|
215
|
+
expect(reactivated.membership).toMatchObject({ id: "b1", roomThreadId: "th1", title: "Lead again", status: "active" });
|
|
216
|
+
expect(reactivated.membership.joinedAt).toEqual(saved.membership.joinedAt);
|
|
217
|
+
expect(reactivated.membership.updatedAt.getTime()).toBeGreaterThan(removed.membership.updatedAt.getTime());
|
|
218
|
+
await expect(store.save({ ...base, id: "b1", projectId: "p2", assistantId: "a9" })).rejects.toBeInstanceOf(ProjectBotMembershipIdConflictError);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("persists a new bot's requested status and applies uniqueness only to active and paused rows", async () => {
|
|
222
|
+
const store = new LocalProjectBotMembershipStore(await initDatabase({ dbPath }));
|
|
223
|
+
const base = { tenantId: "t1", workspaceId: "w1", projectId: "p1", roomId: "r1", role: "coordinator" as const, title: "Lead", mentionName: "lead", roomThreadId: "thread" };
|
|
224
|
+
const paused = await store.save({ ...base, id: "paused", assistantId: "a1", status: "paused" });
|
|
225
|
+
expect(paused.kind).toBe("created");
|
|
226
|
+
if (paused.kind !== "created") throw new Error("expected paused membership");
|
|
227
|
+
expect(paused.membership.status).toBe("paused");
|
|
228
|
+
expect((await store.save({ ...base, id: "blocked", assistantId: "a2", mentionName: "other", status: "active" })).kind).toBe("coordinator_conflict");
|
|
229
|
+
|
|
230
|
+
const removed = await store.save({ ...base, id: "removed", projectId: "p2", assistantId: "a3", status: "removed" });
|
|
231
|
+
expect(removed.kind).toBe("created");
|
|
232
|
+
if (removed.kind !== "created") throw new Error("expected removed membership");
|
|
233
|
+
expect(removed.membership.status).toBe("removed");
|
|
234
|
+
expect((await store.save({ ...base, id: "available", projectId: "p2", roomId: "r2", assistantId: "a4", status: "active" })).kind).toBe("created");
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("returns coordinator conflict first when a bot candidate also conflicts on mention", async () => {
|
|
238
|
+
const store = new LocalProjectBotMembershipStore(await initDatabase({ dbPath }));
|
|
239
|
+
const base = { tenantId: "t1", workspaceId: "w1", projectId: "p1", roomId: "r1", title: "Bot", status: "active" as const };
|
|
240
|
+
await store.save({ ...base, id: "coordinator", assistantId: "a1", role: "coordinator", mentionName: "lead", roomThreadId: "th1" });
|
|
241
|
+
await store.save({ ...base, id: "mention", assistantId: "a2", role: "specialist", mentionName: "taken", roomThreadId: "th2" });
|
|
242
|
+
|
|
243
|
+
expect((await store.save({ ...base, id: "candidate", assistantId: "a3", role: "coordinator", mentionName: "taken", roomThreadId: "th3" })).kind).toBe("coordinator_conflict");
|
|
244
|
+
const removed = await store.save({ ...base, id: "reactivate", assistantId: "a4", role: "specialist", mentionName: "free", status: "removed", roomThreadId: "th4" });
|
|
245
|
+
if (removed.kind !== "created") throw new Error("expected removed membership");
|
|
246
|
+
expect((await store.save({ ...base, id: "ignored", assistantId: "a4", role: "coordinator", mentionName: "taken", roomThreadId: "ignored" })).kind).toBe("coordinator_conflict");
|
|
247
|
+
const updateCandidate = await store.save({ ...base, id: "update", assistantId: "a5", role: "specialist", mentionName: "update-free", status: "removed", roomThreadId: "th5" });
|
|
248
|
+
if (updateCandidate.kind !== "created") throw new Error("expected update candidate");
|
|
249
|
+
expect((await store.update({ tenantId: "t1", id: "update", patch: { role: "coordinator", mentionName: "taken", status: "active" }, expectedUpdatedAt: updateCandidate.membership.updatedAt })).kind).toBe("coordinator_conflict");
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("classifies a bot membership ID collision that occurs after preflight", async () => {
|
|
253
|
+
const db = await initDatabase({ dbPath });
|
|
254
|
+
const store = new LocalProjectBotMembershipStore(db);
|
|
255
|
+
const raw = db.getRawDb();
|
|
256
|
+
const originalRun = raw.run.bind(raw);
|
|
257
|
+
let injected = false;
|
|
258
|
+
jest.spyOn(raw, "run").mockImplementation((sql, params) => {
|
|
259
|
+
if (!injected && typeof sql === "string" && sql.startsWith("INSERT INTO lt_project_bot_memberships")) {
|
|
260
|
+
injected = true;
|
|
261
|
+
const now = new Date().toISOString();
|
|
262
|
+
originalRun("INSERT INTO lt_project_bot_memberships VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ["race-id", "t1", "w1", "other", "other-room", "other-assistant", "specialist", "Other", null, "other", "removed", "other-thread", now, now]);
|
|
263
|
+
}
|
|
264
|
+
return originalRun(sql, params);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
await expect(store.save({ id: "race-id", tenantId: "t1", workspaceId: "w1", projectId: "p1", roomId: "r1", assistantId: "a1", role: "specialist", title: "Bot", mentionName: "bot", status: "active", roomThreadId: "thread" })).rejects.toBeInstanceOf(ProjectBotMembershipIdConflictError);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("reopens human memberships with dates and preserves final-owner and CAS behavior", async () => {
|
|
271
|
+
const initial = storesFor(await initDatabase({ dbPath }));
|
|
272
|
+
const owner = await initial.memberships.createInitialOwner({ id: "owner", tenantId: "t1", projectId: "p1", userId: "u1" });
|
|
273
|
+
if (owner.kind !== "created") throw new Error("expected owner");
|
|
274
|
+
const admin = await initial.memberships.create({ id: "admin", tenantId: "t1", projectId: "p1", userId: "u2", role: "admin", status: "active" });
|
|
275
|
+
closeDatabase();
|
|
276
|
+
|
|
277
|
+
const reopened = storesFor(await initDatabase({ dbPath }));
|
|
278
|
+
const persisted = await reopened.memberships.list("t1", "p1");
|
|
279
|
+
const expected = [owner.membership, admin].sort((left, right) => left.joinedAt.getTime() - right.joinedAt.getTime() || left.id.localeCompare(right.id));
|
|
280
|
+
expect(persisted).toEqual(expected);
|
|
281
|
+
expect(persisted[0].joinedAt).toBeInstanceOf(Date);
|
|
282
|
+
expect(await reopened.memberships.remove({ tenantId: "t1", id: "owner", expectedUpdatedAt: new Date(0) })).toEqual({ kind: "conflict" });
|
|
283
|
+
expect(await reopened.memberships.remove({ tenantId: "t1", id: "owner", expectedUpdatedAt: owner.membership.updatedAt })).toEqual({ kind: "last_owner" });
|
|
284
|
+
expect(await reopened.memberships.updateRole({ tenantId: "t1", id: "admin", role: "owner", expectedUpdatedAt: admin.updatedAt })).toMatchObject({ kind: "updated", membership: { role: "owner" } });
|
|
285
|
+
expect(await reopened.memberships.remove({ tenantId: "t1", id: "owner", expectedUpdatedAt: owner.membership.updatedAt })).toMatchObject({ kind: "removed", membership: { status: "removed" } });
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("reopens bot roster state and reactivates while preserving durable identity", async () => {
|
|
289
|
+
const initial = storesFor(await initDatabase({ dbPath }));
|
|
290
|
+
const saved = await initial.bots.save({ id: "bot", tenantId: "t1", workspaceId: "w1", projectId: "p1", roomId: "r1", assistantId: "a1", role: "specialist", title: "Research", responsibility: "Deep work", mentionName: "research", status: "removed", roomThreadId: "thread-1" });
|
|
291
|
+
if (saved.kind !== "created") throw new Error("expected saved bot");
|
|
292
|
+
closeDatabase();
|
|
293
|
+
|
|
294
|
+
const reopened = storesFor(await initDatabase({ dbPath }));
|
|
295
|
+
const persisted = await reopened.bots.findById("t1", "bot");
|
|
296
|
+
expect(persisted).toEqual(saved.membership);
|
|
297
|
+
expect(persisted?.joinedAt).toBeInstanceOf(Date);
|
|
298
|
+
const reactivated = await reopened.bots.save({ id: "replacement", tenantId: "t1", workspaceId: "w1", projectId: "p1", roomId: "r1", assistantId: "a1", role: "specialist", title: "Research again", mentionName: "research", status: "paused", roomThreadId: "replacement-thread" });
|
|
299
|
+
expect(reactivated.kind).toBe("reactivated");
|
|
300
|
+
if (reactivated.kind !== "reactivated") throw new Error("expected reactivated bot");
|
|
301
|
+
expect(reactivated.membership).toMatchObject({ id: "bot", status: "active", roomThreadId: "thread-1", joinedAt: saved.membership.joinedAt });
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("reopens nested room messages with dates, cursor ordering, and canonical idempotency", async () => {
|
|
305
|
+
const initial = storesFor(await initDatabase({ dbPath }));
|
|
306
|
+
const first = await initial.messages.createIdempotent({ id: "m1", tenantId: "t1", workspaceId: "w1", projectId: "p1", roomId: "r1", author: { type: "bot", membershipId: "bot", assistantId: "a1" }, content: { type: "text", text: "nested" }, mentions: [{ type: "bot", membershipId: "other" }, { type: "team" }], replyToMessageId: "parent", source: "task", sourceId: "task-1", idempotencyKey: "durable-key" });
|
|
307
|
+
const second = await initial.messages.create({ id: "m2", tenantId: "t1", workspaceId: "w1", projectId: "p1", roomId: "r1", author: { type: "system" }, content: { type: "text", text: "second" }, mentions: [], source: "system" });
|
|
308
|
+
closeDatabase();
|
|
309
|
+
|
|
310
|
+
const reopened = storesFor(await initDatabase({ dbPath }));
|
|
311
|
+
expect(await reopened.messages.findById("t1", "m1")).toEqual(first);
|
|
312
|
+
expect((await reopened.messages.findById("t1", "m1"))?.createdAt).toBeInstanceOf(Date);
|
|
313
|
+
expect(await reopened.messages.createIdempotent({ id: "ignored", tenantId: "t1", workspaceId: "w1", projectId: "p1", roomId: "r1", author: { type: "system" }, content: { type: "text", text: "ignored" }, mentions: [], source: "system", idempotencyKey: "durable-key" })).toEqual(first);
|
|
314
|
+
const page = await reopened.messages.list({ tenantId: "t1", roomId: "r1", limit: 2 });
|
|
315
|
+
expect(page).toEqual([second, first]);
|
|
316
|
+
expect(await reopened.messages.list({ tenantId: "t1", roomId: "r1", before: { createdAt: second.createdAt, id: second.id }, limit: 10 })).toEqual([first]);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("round-trips cloned message JSON, maps uniqueness, idempotency, and exact descending cursors", async () => {
|
|
320
|
+
const store = new LocalProjectRoomMessageStore(await initDatabase({ dbPath }));
|
|
321
|
+
const message = { id: "m1", tenantId: "t1", workspaceId: "w1", projectId: "p1", roomId: "r1", author: { type: "human" as const, userId: "u1" }, content: { type: "text" as const, text: "hello" }, mentions: [{ type: "team" as const }], source: "user" as const, idempotencyKey: "key-1" };
|
|
322
|
+
const first = await store.createIdempotent(message);
|
|
323
|
+
message.content.text = "mutated";
|
|
324
|
+
message.mentions.length = 0;
|
|
325
|
+
const canonical = await store.createIdempotent({ ...message, id: "m2" });
|
|
326
|
+
expect(canonical).toEqual(first);
|
|
327
|
+
expect(canonical.content.text).toBe("hello");
|
|
328
|
+
expect(canonical.mentions).toEqual([{ type: "team" }]);
|
|
329
|
+
await expect(store.create({ ...message, id: "m1", idempotencyKey: undefined })).rejects.toBeInstanceOf(ProjectRoomMessageIdConflictError);
|
|
330
|
+
await expect(store.create({ ...message, id: "m3", idempotencyKey: "key-1" })).rejects.toBeInstanceOf(DuplicateProjectRoomMessageIdempotencyKeyError);
|
|
331
|
+
|
|
332
|
+
await store.create({ ...message, id: "z", idempotencyKey: undefined });
|
|
333
|
+
await store.create({ ...message, id: "a", idempotencyKey: undefined });
|
|
334
|
+
const page = await store.list({ tenantId: "t1", roomId: "r1", limit: 1000 });
|
|
335
|
+
expect(page).toHaveLength(3);
|
|
336
|
+
expect(await store.list({ tenantId: "t1", roomId: "r1", before: { createdAt: page[1].createdAt, id: page[1].id }, limit: 0 })).toEqual([page[2]]);
|
|
337
|
+
await expect(store.list({ tenantId: "t1", roomId: "r1", before: { createdAt: new Date("invalid"), id: "x" }, limit: 1 })).rejects.toBeInstanceOf(RangeError);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it("classifies a room message ID collision that occurs after preflight", async () => {
|
|
341
|
+
const db = await initDatabase({ dbPath });
|
|
342
|
+
const store = new LocalProjectRoomMessageStore(db);
|
|
343
|
+
const raw = db.getRawDb();
|
|
344
|
+
const originalRun = raw.run.bind(raw);
|
|
345
|
+
let injected = false;
|
|
346
|
+
jest.spyOn(raw, "run").mockImplementation((sql, params) => {
|
|
347
|
+
if (!injected && typeof sql === "string" && sql.startsWith("INSERT INTO lt_project_room_messages")) {
|
|
348
|
+
injected = true;
|
|
349
|
+
originalRun("INSERT INTO lt_project_room_messages VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ["race-id", "t1", "w1", "other", "other-room", '{"type":"system"}', '{"type":"text","text":"other"}', "[]", null, "system", null, null, new Date().toISOString()]);
|
|
350
|
+
}
|
|
351
|
+
return originalRun(sql, params);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
await expect(store.create({ id: "race-id", tenantId: "t1", workspaceId: "w1", projectId: "p1", roomId: "r1", author: { type: "system" }, content: { type: "text", text: "message" }, mentions: [], source: "system" })).rejects.toBeInstanceOf(ProjectRoomMessageIdConflictError);
|
|
355
|
+
});
|
|
356
|
+
});
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* getProjectsByWorkspace supports an optional kind filter.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { describe, expect, it, beforeEach, afterEach } from "@jest/globals";
|
|
9
|
+
import { describe, expect, it, beforeEach, afterEach, jest } from "@jest/globals";
|
|
10
10
|
import { tmpdir } from "os";
|
|
11
11
|
import path from "path";
|
|
12
12
|
import { initDatabase, closeDatabase } from "../database";
|
|
@@ -19,11 +19,12 @@ const nextDbPath = () =>
|
|
|
19
19
|
|
|
20
20
|
describe("LocalProjectStore project kind", () => {
|
|
21
21
|
let store: LocalProjectStore;
|
|
22
|
+
let db: Awaited<ReturnType<typeof initDatabase>>;
|
|
22
23
|
const tenantId = "t1";
|
|
23
24
|
const workspaceId = "ws1";
|
|
24
25
|
|
|
25
26
|
beforeEach(async () => {
|
|
26
|
-
|
|
27
|
+
db = await initDatabase({ dbPath: nextDbPath() });
|
|
27
28
|
store = new LocalProjectStore(db);
|
|
28
29
|
});
|
|
29
30
|
|
|
@@ -81,6 +82,42 @@ describe("LocalProjectStore project kind", () => {
|
|
|
81
82
|
expect(all).toHaveLength(2);
|
|
82
83
|
});
|
|
83
84
|
|
|
85
|
+
it("preserves capability bundle IDs during generic config replacement", async () => {
|
|
86
|
+
await store.createProject(tenantId, workspaceId, "reserved", { name: "Reserved", config: { keep: true } });
|
|
87
|
+
db.exec("UPDATE lt_projects SET config = '{\"keep\":true,\"capabilityBundleIds\":[\"bundle-1\"]}' WHERE tenant_id = 't1' AND id = 'reserved'");
|
|
88
|
+
|
|
89
|
+
const updated = await store.updateProject(tenantId, "reserved", { config: { replacement: true } });
|
|
90
|
+
|
|
91
|
+
expect(updated?.config).toEqual({ replacement: true, capabilityBundleIds: ["bundle-1"] });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("preserves an assignment made between the generic read and write", async () => {
|
|
95
|
+
await store.createProject(tenantId, workspaceId, "interleaved", {
|
|
96
|
+
name: "Interleaved",
|
|
97
|
+
config: { keep: true },
|
|
98
|
+
});
|
|
99
|
+
const originalPrepare = db.prepare.bind(db);
|
|
100
|
+
let injected = false;
|
|
101
|
+
jest.spyOn(db, "prepare").mockImplementation((sql) => {
|
|
102
|
+
if (!injected && sql.startsWith("UPDATE lt_projects SET config =")) {
|
|
103
|
+
injected = true;
|
|
104
|
+
originalPrepare(
|
|
105
|
+
"UPDATE lt_projects SET config = json_set(COALESCE(config, '{}'), '$.capabilityBundleIds', json(?)) WHERE tenant_id = ? AND id = ?",
|
|
106
|
+
).run('["bundle-latest"]', tenantId, "interleaved");
|
|
107
|
+
}
|
|
108
|
+
return originalPrepare(sql);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const updated = await store.updateProject(tenantId, "interleaved", {
|
|
112
|
+
config: { replacement: true },
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
expect(updated?.config).toEqual({
|
|
116
|
+
replacement: true,
|
|
117
|
+
capabilityBundleIds: ["bundle-latest"],
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
84
121
|
describe("multiple training projects per tenant/workspace", () => {
|
|
85
122
|
it("allows multiple training projects in the same tenant/workspace", async () => {
|
|
86
123
|
await store.createProject(tenantId, workspaceId, "t1", {
|
|
@@ -153,6 +153,34 @@ describe("LocalTaskStore metadata filter", () => {
|
|
|
153
153
|
expect(await store.getById("t1", "a2a-task-1")).not.toBeNull();
|
|
154
154
|
});
|
|
155
155
|
|
|
156
|
+
it("lists guarded JSON dependents and nonproject rows in deterministic order", async () => {
|
|
157
|
+
const base = { tenantId: "t1", ownerType: "agent", ownerId: "a1", workspaceId: "workspace-1", projectId: "project-1" } as const;
|
|
158
|
+
for (const [id, status, dependencies] of [
|
|
159
|
+
["dependent-a", "pending", ["dependency-1"]],
|
|
160
|
+
["dependent-b", "interrupted", ["dependency-1", "dependency-1"]],
|
|
161
|
+
["dependent-c", "pending", ["dependency-1"]],
|
|
162
|
+
] as const) await store.create({ ...base, id, title: id, status, dependencies });
|
|
163
|
+
db.prepare("UPDATE lt_tasks SET created_at = ? WHERE tenant_id = ?").run("2026-01-01T00:00:00.000Z", "t1");
|
|
164
|
+
for (const [id, raw] of [["null-json", null], ["empty-json", ""], ["malformed-json", "{"], ["object-json", "{}"]] as const) {
|
|
165
|
+
await store.create({ ...base, id, title: id, status: "pending" });
|
|
166
|
+
db.prepare("UPDATE lt_tasks SET dependencies = ? WHERE tenant_id = ? AND id = ?").run(raw, "t1", id);
|
|
167
|
+
}
|
|
168
|
+
await store.create({ ...base, id: "wrong-project", title: "wrong", status: "pending", dependencies: ["dependency-1"], projectId: "project-2" });
|
|
169
|
+
const query = { tenantId: "t1", workspaceId: "workspace-1", projectId: "project-1", dependencyTaskId: "dependency-1", statuses: ["pending", "interrupted"], limit: 2, offset: 0 } as const;
|
|
170
|
+
|
|
171
|
+
expect((await store.listDependents(query)).map(({ id }) => id)).toEqual(["dependent-c", "dependent-b"]);
|
|
172
|
+
await expect(store.listDependents({ ...query, statuses: [] })).rejects.toThrow(RangeError);
|
|
173
|
+
await expect(store.listDependents({ ...query, statuses: ["unknown"] as never })).rejects.toThrow(RangeError);
|
|
174
|
+
await expect(store.listDependents({ ...query, limit: 101 })).rejects.toThrow(RangeError);
|
|
175
|
+
|
|
176
|
+
for (const [id, projectId] of [["nonproject-d", undefined], ["nonproject-c", ""], ["nonproject-b", "default"]] as const) {
|
|
177
|
+
await store.create({ id, tenantId: "t1", ownerType: "user", ownerId: "u1", title: id, projectId });
|
|
178
|
+
}
|
|
179
|
+
db.prepare("UPDATE lt_tasks SET created_at = ? WHERE id LIKE 'nonproject-%'").run("2026-01-02T00:00:00.000Z");
|
|
180
|
+
expect((await store.list({ tenantId: "t1", projectId: null, limit: 2, offset: 1 })).map(({ id }) => id))
|
|
181
|
+
.toEqual(["nonproject-c", "nonproject-b"]);
|
|
182
|
+
});
|
|
183
|
+
|
|
156
184
|
it("conditionally updates through independent store instances", async () => {
|
|
157
185
|
const secondStore = new LocalTaskStore(db);
|
|
158
186
|
await store.create({
|
|
@@ -242,6 +270,36 @@ describe("LocalTaskStore metadata filter", () => {
|
|
|
242
270
|
expect((await store.getById("t1", created.id))?.status).toBe("in_progress");
|
|
243
271
|
});
|
|
244
272
|
|
|
273
|
+
it("conditionally updates and deletes only an exact owner and project snapshot", async () => {
|
|
274
|
+
const created = await store.create({
|
|
275
|
+
id: "task-exact-snapshot", tenantId: "t1", workspaceId: "workspace-1", projectId: "project-1",
|
|
276
|
+
ownerType: "agent", ownerId: "agent-1", title: "Working", status: "pending",
|
|
277
|
+
});
|
|
278
|
+
const snapshot = {
|
|
279
|
+
status: created.status, updatedAt: created.updatedAt, ownerType: created.ownerType,
|
|
280
|
+
ownerId: created.ownerId, workspaceId: "workspace-1", projectId: "project-1",
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
await expect(store.updateIfSnapshot(
|
|
284
|
+
"t1", created.id, { title: "Wrong" }, { ...snapshot, projectId: "project-2" },
|
|
285
|
+
)).resolves.toBeNull();
|
|
286
|
+
const updated = await store.updateIfSnapshot("t1", created.id, { title: "Updated" }, snapshot);
|
|
287
|
+
expect(updated?.title).toBe("Updated");
|
|
288
|
+
await expect(store.deleteIfSnapshot("t1", created.id, snapshot)).resolves.toBe(false);
|
|
289
|
+
await expect(store.deleteIfSnapshot("t1", created.id, {
|
|
290
|
+
...snapshot, updatedAt: updated!.updatedAt,
|
|
291
|
+
})).resolves.toBe(true);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("conditionally mutates an exact nonproject snapshot", async () => {
|
|
295
|
+
const created = await store.create({ id: "nonproject-cas", tenantId: "t1", ownerType: "user", ownerId: "user-1", title: "Personal" });
|
|
296
|
+
const snapshot = { status: created.status, updatedAt: created.updatedAt, ownerType: created.ownerType,
|
|
297
|
+
ownerId: created.ownerId, workspaceId: null, projectId: null };
|
|
298
|
+
const updated = await store.updateIfSnapshot("t1", created.id, { title: "Updated" }, snapshot);
|
|
299
|
+
expect(updated?.title).toBe("Updated");
|
|
300
|
+
await expect(store.deleteIfSnapshot("t1", created.id, { ...snapshot, updatedAt: updated!.updatedAt })).resolves.toBe(true);
|
|
301
|
+
});
|
|
302
|
+
|
|
245
303
|
it("atomically matches both child and parent snapshots", async () => {
|
|
246
304
|
const parent = await store.create({
|
|
247
305
|
id: "parent-snapshot", tenantId: "t1", ownerType: "agent", ownerId: "a1", title: "Parent",
|