@axiom-lattice/local-stores 3.1.1 → 3.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +9 -0
- package/dist/index.d.mts +142 -14
- package/dist/index.d.ts +142 -14
- package/dist/index.js +1194 -206
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1189 -203
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/LocalCapabilityBundleStore.test.ts +1 -2
- package/src/__tests__/LocalChannelBindingStore.test.ts +183 -0
- package/src/__tests__/LocalChannelInstallationStore.test.ts +23 -0
- package/src/__tests__/LocalProjectRoomStores.test.ts +356 -0
- package/src/__tests__/LocalTaskStore.test.ts +58 -0
- package/src/__tests__/LocalTaskWorkItemStore.test.ts +92 -0
- package/src/__tests__/LocalThreadMessageQueueStore.test.ts +126 -3
- package/src/createLocalStoreConfig.ts +8 -0
- package/src/index.ts +4 -0
- package/src/migrations/migration.ts +1 -1
- package/src/stores/LocalChannelBindingStore.ts +75 -41
- package/src/stores/LocalChannelInstallationStore.ts +6 -5
- package/src/stores/LocalProjectBotMembershipStore.ts +105 -0
- package/src/stores/LocalProjectMembershipStore.ts +72 -0
- package/src/stores/LocalProjectRoomMessageStore.ts +63 -0
- package/src/stores/LocalProjectRoomStore.ts +37 -0
- package/src/stores/LocalTaskStore.ts +82 -2
- package/src/stores/LocalTaskWorkItemStore.ts +91 -1
- package/src/stores/LocalThreadMessageQueueStore.ts +52 -6
- package/src/stores/project-room-schema.ts +243 -0
- package/src/stores/room-channel-binding-index.ts +60 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@axiom-lattice/local-stores",
|
|
3
|
-
"version": "3.1.
|
|
3
|
+
"version": "3.1.2",
|
|
4
4
|
"description": "Local SQLite-based stores for Axiom Lattice framework",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/index.mjs",
|
|
@@ -25,8 +25,8 @@
|
|
|
25
25
|
"@types/sql.js": "^1.4.11",
|
|
26
26
|
"sql.js": "^1.14.1",
|
|
27
27
|
"uuid": "^14.0.1",
|
|
28
|
-
"@axiom-lattice/core": "4.2.
|
|
29
|
-
"@axiom-lattice/protocols": "4.1.
|
|
28
|
+
"@axiom-lattice/core": "4.2.2",
|
|
29
|
+
"@axiom-lattice/protocols": "4.1.2"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
32
|
"@types/jest": "^29.5.14",
|
|
@@ -154,8 +154,7 @@ describe("LocalCapabilityBundleStore", () => {
|
|
|
154
154
|
});
|
|
155
155
|
|
|
156
156
|
it.each(["null", "[]", "true", "7", '"config"'])(
|
|
157
|
-
"treats valid non-object project config JSON %s as having no bundle reference",
|
|
158
|
-
async (config) => {
|
|
157
|
+
"treats valid non-object project config JSON %s as having no bundle reference", async (config) => {
|
|
159
158
|
await projectStore.createProject("tenant-1", "workspace", "project", { name: "Project" });
|
|
160
159
|
db.prepare("UPDATE lt_projects SET config = ? WHERE tenant_id = ? AND id = ?")
|
|
161
160
|
.run(config, "tenant-1", "project");
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import { tmpdir } from "os";
|
|
3
|
+
import {
|
|
4
|
+
ChannelBindingMigrationConflictError,
|
|
5
|
+
DuplicateChannelBindingSubjectError,
|
|
6
|
+
type BindingMutablePatch,
|
|
7
|
+
} from "@axiom-lattice/protocols";
|
|
8
|
+
import { closeDatabase, initDatabase, type DatabaseWrapper } from "../database";
|
|
9
|
+
import { LocalChannelBindingStore } from "../stores/LocalChannelBindingStore";
|
|
10
|
+
import { ensureChannelBindingSubjectIndex } from "../stores/room-channel-binding-index";
|
|
11
|
+
|
|
12
|
+
const dbPath = (): string => path.join(tmpdir(), `binding-${Date.now()}-${Math.random()}.db`);
|
|
13
|
+
const input = (overrides: Record<string, unknown> = {}) => ({
|
|
14
|
+
channel: "room", channelInstallationId: "install-1", tenantId: "tenant-a",
|
|
15
|
+
senderId: "sender-1", agentId: "agent-1", threadId: "thread-1", ...overrides,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("LocalChannelBindingStore protected bindings", () => {
|
|
19
|
+
let db: DatabaseWrapper;
|
|
20
|
+
afterEach(() => closeDatabase());
|
|
21
|
+
|
|
22
|
+
it("preserves legacy duplicates and fails startup before creating the index", async () => {
|
|
23
|
+
db = await initDatabase({ dbPath: dbPath() });
|
|
24
|
+
db.exec(`CREATE TABLE lt_channel_bindings (
|
|
25
|
+
id TEXT PRIMARY KEY, channel TEXT NOT NULL, channel_installation_id TEXT NOT NULL,
|
|
26
|
+
tenant_id TEXT NOT NULL, sender_id TEXT NOT NULL, agent_id TEXT NOT NULL, thread_id TEXT,
|
|
27
|
+
workspace_id TEXT, project_id TEXT, thread_mode TEXT NOT NULL DEFAULT 'fixed',
|
|
28
|
+
sender_display_name TEXT, sender_metadata TEXT, enabled INTEGER NOT NULL DEFAULT 1,
|
|
29
|
+
created_at TEXT NOT NULL, updated_at TEXT NOT NULL)`);
|
|
30
|
+
db.exec(`INSERT INTO lt_channel_bindings
|
|
31
|
+
(id,channel,channel_installation_id,tenant_id,sender_id,agent_id,created_at,updated_at)
|
|
32
|
+
VALUES ('b','room','install-1','tenant-a','sender-1','agent-2','2026-01-02','2026-01-02'),
|
|
33
|
+
('a','room','install-1','tenant-a','sender-1','agent-1','2026-01-01','2026-01-01')`);
|
|
34
|
+
let migrationError: unknown;
|
|
35
|
+
try {
|
|
36
|
+
new LocalChannelBindingStore(db);
|
|
37
|
+
} catch (error) {
|
|
38
|
+
migrationError = error;
|
|
39
|
+
}
|
|
40
|
+
expect(migrationError).toBeInstanceOf(ChannelBindingMigrationConflictError);
|
|
41
|
+
expect((migrationError as ChannelBindingMigrationConflictError).conflicts).toEqual([{
|
|
42
|
+
tenantId: "tenant-a", channel: "room", channelInstallationId: "install-1",
|
|
43
|
+
senderId: "sender-1", count: 2,
|
|
44
|
+
}]);
|
|
45
|
+
const duplicates = db.prepare("SELECT id FROM lt_channel_bindings ORDER BY id").all();
|
|
46
|
+
expect(duplicates).toEqual([{ id: "a" }, { id: "b" }]);
|
|
47
|
+
const indexes = db.prepare(
|
|
48
|
+
"SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'idx_lt_cb_subject_unique'",
|
|
49
|
+
).all();
|
|
50
|
+
expect(indexes).toHaveLength(0);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("persists a disabled initial binding", async () => {
|
|
54
|
+
db = await initDatabase({ dbPath: dbPath() });
|
|
55
|
+
const store = new LocalChannelBindingStore(db);
|
|
56
|
+
await ensureChannelBindingSubjectIndex(db);
|
|
57
|
+
await expect(store.create(input({ enabled: false }))).resolves.toMatchObject({ enabled: false });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("supports safe lookup, exclusion, thread persistence, and atomic duplicate create", async () => {
|
|
61
|
+
db = await initDatabase({ dbPath: dbPath() });
|
|
62
|
+
const store = new LocalChannelBindingStore(db);
|
|
63
|
+
for (let index = 0; index < 60; index += 1) await store.create(input({ senderId: `room-${index}` }));
|
|
64
|
+
const first = await store.create(input({ channel: "lark", senderId: "public-1" }));
|
|
65
|
+
await store.create(input({ channel: "slack", senderId: "public-2" }));
|
|
66
|
+
await store.update("tenant-a", first.id, { enabled: false });
|
|
67
|
+
|
|
68
|
+
await expect(store.findById("tenant-b", first.id)).resolves.toBeNull();
|
|
69
|
+
await expect(store.findBySubject({ ...input({ channel: "lark", senderId: "public-1" }) }))
|
|
70
|
+
.resolves.toMatchObject({ enabled: false, threadId: "thread-1" });
|
|
71
|
+
await expect(store.list({ tenantId: "tenant-a", excludeChannels: ["room"], limit: 50 }))
|
|
72
|
+
.resolves.toHaveLength(2);
|
|
73
|
+
await expect(store.export({ tenantId: "tenant-a" })).resolves.toHaveLength(2);
|
|
74
|
+
const results = await Promise.allSettled([
|
|
75
|
+
store.create(input({ senderId: "duplicate" })), store.create(input({ senderId: "duplicate" })),
|
|
76
|
+
]);
|
|
77
|
+
expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1);
|
|
78
|
+
expect((results.find((result) => result.status === "rejected") as PromiseRejectedResult).reason)
|
|
79
|
+
.toBeInstanceOf(DuplicateChannelBindingSubjectError);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("excludes internal installation prefixes before SQLite pagination and export", async () => {
|
|
83
|
+
db = await initDatabase({ dbPath: dbPath() });
|
|
84
|
+
const store = new LocalChannelBindingStore(db);
|
|
85
|
+
for (let index = 0; index < 60; index += 1) {
|
|
86
|
+
await store.create(input({
|
|
87
|
+
channel: "lark", channelInstallationId: `room-internal:project-${index}`,
|
|
88
|
+
senderId: `mixed-${index}`,
|
|
89
|
+
}));
|
|
90
|
+
}
|
|
91
|
+
await store.create(input({ channel: "lark", channelInstallationId: "public-1", senderId: "public-1" }));
|
|
92
|
+
await store.create(input({ channel: "slack", channelInstallationId: "public-2", senderId: "public-2" }));
|
|
93
|
+
await expect(store.list({
|
|
94
|
+
tenantId: "tenant-a", excludeInstallationIdPrefixes: ["room-internal:"], limit: 50,
|
|
95
|
+
})).resolves.toHaveLength(2);
|
|
96
|
+
await expect(store.export({ tenantId: "tenant-a" })).resolves.toHaveLength(2);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it.each(["room", "lark"])("atomically enforces global %s subject uniqueness", async (channel) => {
|
|
100
|
+
db = await initDatabase({ dbPath: dbPath() });
|
|
101
|
+
const store = new LocalChannelBindingStore(db);
|
|
102
|
+
const results = await Promise.allSettled([
|
|
103
|
+
store.create(input({ channel })), store.create(input({ channel })),
|
|
104
|
+
]);
|
|
105
|
+
expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1);
|
|
106
|
+
expect((results.find((result) => result.status === "rejected") as PromiseRejectedResult).reason)
|
|
107
|
+
.toBeInstanceOf(DuplicateChannelBindingSubjectError);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("scopes update and delete by tenant and ignores runtime immutable fields", async () => {
|
|
111
|
+
db = await initDatabase({ dbPath: dbPath() });
|
|
112
|
+
const store = new LocalChannelBindingStore(db);
|
|
113
|
+
const created = await store.create(input({ channel: "lark" }));
|
|
114
|
+
await expect(store.update("tenant-b", created.id, { agentId: "agent-2" })).rejects.toThrow(/not found/i);
|
|
115
|
+
await store.update("tenant-a", created.id, {
|
|
116
|
+
agentId: "agent-2", channel: "room", tenantId: "tenant-b",
|
|
117
|
+
} as unknown as BindingMutablePatch);
|
|
118
|
+
await expect(store.findById("tenant-a", created.id)).resolves.toMatchObject({
|
|
119
|
+
channel: "lark", tenantId: "tenant-a", agentId: "agent-2",
|
|
120
|
+
});
|
|
121
|
+
await store.delete("tenant-b", created.id);
|
|
122
|
+
await expect(store.findById("tenant-a", created.id)).resolves.not.toBeNull();
|
|
123
|
+
await store.delete("tenant-a", created.id);
|
|
124
|
+
await expect(store.findById("tenant-a", created.id)).resolves.toBeNull();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("does not rollback after commit when persistence fails", () => {
|
|
128
|
+
const saveError = new Error("disk full");
|
|
129
|
+
const exec = jest.fn();
|
|
130
|
+
const wrapper = {
|
|
131
|
+
exec,
|
|
132
|
+
prepare: jest.fn().mockReturnValue({ all: jest.fn().mockReturnValue([]) }),
|
|
133
|
+
save: jest.fn(() => { throw saveError; }),
|
|
134
|
+
} as unknown as DatabaseWrapper;
|
|
135
|
+
|
|
136
|
+
expect(() => ensureChannelBindingSubjectIndex(wrapper)).toThrow(saveError);
|
|
137
|
+
expect(exec.mock.calls.map(([sql]) => sql)).toEqual([
|
|
138
|
+
"BEGIN", "DROP INDEX IF EXISTS idx_lt_cb_room_subject",
|
|
139
|
+
expect.stringContaining("CREATE UNIQUE INDEX"),
|
|
140
|
+
expect.stringContaining("CREATE INDEX"), "COMMIT",
|
|
141
|
+
]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("rolls back an uncommitted index failure and preserves the original error", () => {
|
|
145
|
+
const createError = new Error("index failure");
|
|
146
|
+
const exec = jest.fn((sql: string) => {
|
|
147
|
+
if (sql.includes("CREATE UNIQUE INDEX")) throw createError;
|
|
148
|
+
});
|
|
149
|
+
const wrapper = {
|
|
150
|
+
exec,
|
|
151
|
+
prepare: jest.fn().mockReturnValue({ all: jest.fn().mockReturnValue([]) }),
|
|
152
|
+
save: jest.fn(),
|
|
153
|
+
} as unknown as DatabaseWrapper;
|
|
154
|
+
|
|
155
|
+
expect(() => ensureChannelBindingSubjectIndex(wrapper)).toThrow(createError);
|
|
156
|
+
expect(exec).toHaveBeenLastCalledWith("ROLLBACK");
|
|
157
|
+
expect(wrapper.save).not.toHaveBeenCalled();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("rejects room imports before the first write", async () => {
|
|
161
|
+
db = await initDatabase({ dbPath: dbPath() });
|
|
162
|
+
const store = new LocalChannelBindingStore(db);
|
|
163
|
+
await expect(store.import("tenant-a", [input({ channel: "lark" }), input()])).rejects.toThrow(/room/i);
|
|
164
|
+
await expect(store.list({ tenantId: "tenant-a" })).resolves.toHaveLength(0);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("rejects internal installation imports before the first write", async () => {
|
|
168
|
+
db = await initDatabase({ dbPath: dbPath() });
|
|
169
|
+
const store = new LocalChannelBindingStore(db);
|
|
170
|
+
await expect(store.import("tenant-a", [input({
|
|
171
|
+
channel: "lark", channelInstallationId: "room-internal:project-1",
|
|
172
|
+
})])).rejects.toThrow(/internal/i);
|
|
173
|
+
await expect(store.list({ tenantId: "tenant-a" })).resolves.toHaveLength(0);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("rejects cross-tenant imports before the first write", async () => {
|
|
177
|
+
db = await initDatabase({ dbPath: dbPath() });
|
|
178
|
+
const store = new LocalChannelBindingStore(db);
|
|
179
|
+
await expect(store.import("tenant-a", [input({ channel: "lark", tenantId: "tenant-b" })]))
|
|
180
|
+
.rejects.toThrow(/tenant mismatch/i);
|
|
181
|
+
await expect(store.list({ tenantId: "tenant-b" })).resolves.toHaveLength(0);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
@@ -19,6 +19,16 @@ describe("LocalChannelInstallationStore", () => {
|
|
|
19
19
|
closeDatabase();
|
|
20
20
|
});
|
|
21
21
|
|
|
22
|
+
it("persists room installations with a plain record config", async () => {
|
|
23
|
+
await expect(store.createInstallation("tenant-a", "room-install", {
|
|
24
|
+
channel: "room",
|
|
25
|
+
config: {},
|
|
26
|
+
})).resolves.toMatchObject({
|
|
27
|
+
channel: "room",
|
|
28
|
+
config: {},
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
22
32
|
it("rejects a duplicate id without changing the original installation", async () => {
|
|
23
33
|
const original = await store.createInstallation("tenant-a", "install-1", {
|
|
24
34
|
channel: "lark",
|
|
@@ -35,4 +45,17 @@ describe("LocalChannelInstallationStore", () => {
|
|
|
35
45
|
).rejects.toThrow();
|
|
36
46
|
await expect(store.getInstallationById("install-1")).resolves.toEqual(original);
|
|
37
47
|
});
|
|
48
|
+
|
|
49
|
+
it("does not update an installation owned by another tenant", async () => {
|
|
50
|
+
const original = await store.createInstallation("tenant-a", "install-1", {
|
|
51
|
+
channel: "lark",
|
|
52
|
+
name: "Original",
|
|
53
|
+
config: { appId: "app-1", appSecret: "secret-1" },
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
await expect(store.updateInstallation("tenant-b", "install-1", {
|
|
57
|
+
name: "Cross-tenant update",
|
|
58
|
+
})).resolves.toBeNull();
|
|
59
|
+
await expect(store.getInstallationById("install-1")).resolves.toEqual(original);
|
|
60
|
+
});
|
|
38
61
|
});
|
|
@@ -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
|
+
});
|