@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.
Files changed (33) hide show
  1. package/.turbo/turbo-build.log +10 -10
  2. package/CHANGELOG.md +18 -0
  3. package/dist/index.d.mts +193 -22
  4. package/dist/index.d.ts +193 -22
  5. package/dist/index.js +1482 -242
  6. package/dist/index.js.map +1 -1
  7. package/dist/index.mjs +1479 -242
  8. package/dist/index.mjs.map +1 -1
  9. package/package.json +3 -3
  10. package/src/__tests__/LocalCapabilityBundleStore.test.ts +216 -0
  11. package/src/__tests__/LocalChannelBindingStore.test.ts +183 -0
  12. package/src/__tests__/LocalChannelInstallationStore.test.ts +23 -0
  13. package/src/__tests__/LocalProjectRoomStores.test.ts +356 -0
  14. package/src/__tests__/LocalProjectStore.test.ts +39 -2
  15. package/src/__tests__/LocalTaskStore.test.ts +58 -0
  16. package/src/__tests__/LocalTaskWorkItemStore.test.ts +199 -0
  17. package/src/__tests__/LocalThreadMessageQueueStore.test.ts +201 -0
  18. package/src/createLocalStoreConfig.ts +10 -0
  19. package/src/index.ts +5 -0
  20. package/src/migrations/migration.ts +1 -1
  21. package/src/stores/LocalCapabilityBundleStore.ts +53 -0
  22. package/src/stores/LocalChannelBindingStore.ts +75 -41
  23. package/src/stores/LocalChannelInstallationStore.ts +6 -5
  24. package/src/stores/LocalProjectBotMembershipStore.ts +105 -0
  25. package/src/stores/LocalProjectMembershipStore.ts +72 -0
  26. package/src/stores/LocalProjectRoomMessageStore.ts +63 -0
  27. package/src/stores/LocalProjectRoomStore.ts +37 -0
  28. package/src/stores/LocalProjectStore.ts +67 -14
  29. package/src/stores/LocalTaskStore.ts +82 -2
  30. package/src/stores/LocalTaskWorkItemStore.ts +136 -1
  31. package/src/stores/LocalThreadMessageQueueStore.ts +136 -28
  32. package/src/stores/project-room-schema.ts +243 -0
  33. 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.0",
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.0",
29
- "@axiom-lattice/protocols": "4.1.0"
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",
@@ -0,0 +1,216 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "@jest/globals";
2
+ import { tmpdir } from "os";
3
+ import path from "path";
4
+
5
+ import { closeDatabase, initDatabase } from "../database";
6
+ import { LocalCapabilityBundleStore } from "../stores/LocalCapabilityBundleStore";
7
+ import { LocalProjectStore } from "../stores/LocalProjectStore";
8
+
9
+ const nextDbPath = (): string => path.join(
10
+ tmpdir(), `lt-capability-bundle-${Date.now()}-${Math.random().toString(36).slice(2)}.db`,
11
+ );
12
+ const input = {
13
+ key: "research",
14
+ name: "Research",
15
+ description: "Research tools",
16
+ capabilities: [{
17
+ id: "filesystem", type: "filesystem", name: "Filesystem", enabled: true,
18
+ config: { roots: ["/workspace"], policy: { write: false } },
19
+ }],
20
+ };
21
+
22
+ describe("LocalCapabilityBundleStore", () => {
23
+ let store: LocalCapabilityBundleStore;
24
+ let projectStore: LocalProjectStore;
25
+ let db: Awaited<ReturnType<typeof initDatabase>>;
26
+
27
+ beforeEach(async () => {
28
+ db = await initDatabase({ dbPath: nextDbPath() });
29
+ store = new LocalCapabilityBundleStore(db);
30
+ projectStore = new LocalProjectStore(db);
31
+ });
32
+ afterEach(() => closeDatabase());
33
+
34
+ it("supports tenant-scoped CRUD, bulk reads, and nested JSON round-trips", async () => {
35
+ const created = await store.create("tenant-1", input);
36
+ await store.create("tenant-2", input);
37
+ expect(created.id).toMatch(/^[0-9a-f-]{36}$/);
38
+ expect(await store.listByTenant("tenant-1")).toHaveLength(1);
39
+ expect(await store.getById("tenant-2", created.id)).toBeNull();
40
+ expect(await store.getManyByIds("tenant-1", [created.id, "missing"])).toEqual([created]);
41
+ expect((await store.getById("tenant-1", created.id))?.capabilities).toEqual(input.capabilities);
42
+
43
+ const updated = await store.update("tenant-1", created.id, {
44
+ key: "analysis", name: "Analysis", capabilities: [{ ...input.capabilities[0], config: { depth: 2 } }],
45
+ });
46
+ expect(updated?.id).toBe(created.id);
47
+ expect(updated?.createdAt).toBe(created.createdAt);
48
+ expect(Number.isNaN(Date.parse(created.createdAt))).toBe(false);
49
+ expect(Number.isNaN(Date.parse(updated?.updatedAt ?? ""))).toBe(false);
50
+ expect(Date.parse(updated?.updatedAt ?? "")).toBeGreaterThanOrEqual(Date.parse(created.createdAt));
51
+ expect(await store.update("tenant-2", created.id, { name: "Nope" })).toBeNull();
52
+ expect("delete" in store).toBe(false);
53
+ await expect(store.deleteIfUnreferenced("tenant-1", created.id)).resolves.toBe("deleted");
54
+ });
55
+
56
+ it("returns defensive nested capability configuration values", async () => {
57
+ const created = await store.create("tenant-1", input);
58
+ const fetched = await store.getById("tenant-1", created.id);
59
+ const config = fetched?.capabilities[0].config as {
60
+ roots: string[];
61
+ policy: { write: boolean };
62
+ };
63
+
64
+ config.roots.push("/mutated");
65
+ config.policy.write = true;
66
+
67
+ expect((await store.getById("tenant-1", created.id))?.capabilities).toEqual(input.capabilities);
68
+ });
69
+
70
+ it("enforces tenant-local unique keys with a stable error", async () => {
71
+ await store.create("tenant-1", input);
72
+ await expect(store.create("tenant-1", input)).rejects.toThrow(
73
+ "Capability bundle key already exists for tenant",
74
+ );
75
+ await expect(store.create("tenant-2", input)).resolves.toMatchObject({ key: input.key });
76
+ });
77
+
78
+ it("advances the revision on rapid content updates", async () => {
79
+ jest.useFakeTimers().setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
80
+ try {
81
+ const created = await store.create("tenant-1", input);
82
+ const first = await store.update("tenant-1", created.id, { name: "First" });
83
+ const second = await store.update("tenant-1", created.id, { name: "Second" });
84
+
85
+ expect(Date.parse(first!.updatedAt)).toBeGreaterThan(Date.parse(created.updatedAt));
86
+ expect(Date.parse(second!.updatedAt)).toBeGreaterThan(Date.parse(first!.updatedAt));
87
+ await expect(store.update("tenant-1", created.id, { name: "Rejected" })).resolves.toMatchObject({ name: "Rejected" });
88
+ await projectStore.createProject("tenant-1", "workspace", "project", { name: "Project" });
89
+ await expect(projectStore.updateCapabilityBundleIds("tenant-1", "project", [created.id], {
90
+ [created.id]: first!.updatedAt,
91
+ })).resolves.toEqual({ status: "bundle_conflict" });
92
+ } finally {
93
+ jest.useRealTimers();
94
+ }
95
+ });
96
+
97
+ it("rejects an update when its expected revision is stale", async () => {
98
+ const created = await store.create("tenant-1", input);
99
+ await store.update("tenant-1", created.id, { name: "First" });
100
+ await expect(store.update("tenant-1", created.id, { name: "Stale", expectedUpdatedAt: created.updatedAt }))
101
+ .resolves.toEqual({ status: "conflict" });
102
+ await expect(store.getById("tenant-1", created.id)).resolves.toMatchObject({ name: "First" });
103
+ });
104
+
105
+ it("checks CAS tokens for no-op updates without changing the revision", async () => {
106
+ const created = await store.create("tenant-1", input);
107
+
108
+ await expect(store.update("tenant-1", created.id, { expectedUpdatedAt: "stale" }))
109
+ .resolves.toEqual({ status: "conflict" });
110
+ await expect(store.update("tenant-1", created.id, { expectedUpdatedAt: created.updatedAt }))
111
+ .resolves.toEqual(created);
112
+ });
113
+
114
+ it("conditionally deletes bundles only when no tenant project references them", async () => {
115
+ const created = await store.create("tenant-1", input);
116
+ await projectStore.createProject("tenant-1", "workspace", "project", { name: "Project" });
117
+ await projectStore.updateCapabilityBundleIds("tenant-1", "project", [created.id]);
118
+ await expect(store.deleteIfUnreferenced("tenant-1", created.id)).resolves.toBe("in_use");
119
+ await projectStore.updateCapabilityBundleIds("tenant-1", "project", []);
120
+ await expect(store.deleteIfUnreferenced("tenant-1", created.id)).resolves.toBe("deleted");
121
+ await expect(store.deleteIfUnreferenced("tenant-1", created.id)).resolves.toBe("not_found");
122
+ });
123
+
124
+ it("rejects a stale composition after its validated bundle is deleted", async () => {
125
+ const created = await store.create("tenant-1", input);
126
+ await projectStore.createProject("tenant-1", "workspace", "project", { name: "Project" });
127
+
128
+ await expect(store.getManyByIds("tenant-1", [created.id])).resolves.toHaveLength(1);
129
+ await expect(store.deleteIfUnreferenced("tenant-1", created.id)).resolves.toBe("deleted");
130
+ await expect(projectStore.updateCapabilityBundleIds("tenant-1", "project", [created.id]))
131
+ .resolves.toEqual({ status: "bundle_not_found" });
132
+ await expect(projectStore.getProjectById("tenant-1", "project"))
133
+ .resolves.toMatchObject({ config: undefined });
134
+ });
135
+
136
+ it("finds references only for the requested tenant", async () => {
137
+ const tenantOneBundle = await store.create("tenant-1", { ...input, key: "tenant-one" });
138
+ const tenantTwoBundle = await store.create("tenant-2", { ...input, key: "tenant-two" });
139
+ await projectStore.createProject("tenant-1", "workspace", "reference", { name: "Reference" });
140
+ await projectStore.updateCapabilityBundleIds("tenant-1", "reference", [tenantOneBundle.id]);
141
+ await projectStore.createProject("tenant-2", "workspace", "other", { name: "Other" });
142
+ await projectStore.updateCapabilityBundleIds("tenant-2", "other", [tenantTwoBundle.id]);
143
+ await expect(projectStore.isCapabilityBundleReferenced("tenant-1", tenantOneBundle.id)).resolves.toBe(true);
144
+ await expect(projectStore.isCapabilityBundleReferenced("tenant-1", tenantTwoBundle.id)).resolves.toBe(false);
145
+ await expect(projectStore.isCapabilityBundleReferenced("tenant-1", "missing")).resolves.toBe(false);
146
+ });
147
+
148
+ it("treats malformed project config JSON as having no bundle reference", async () => {
149
+ await projectStore.createProject("tenant-1", "workspace", "project", { name: "Project" });
150
+ db.prepare("UPDATE lt_projects SET config = ? WHERE tenant_id = ? AND id = ?")
151
+ .run("{malformed", "tenant-1", "project");
152
+
153
+ await expect(projectStore.isCapabilityBundleReferenced("tenant-1", "bundle-1")).resolves.toBe(false);
154
+ });
155
+
156
+ it.each(["null", "[]", "true", "7", '"config"'])(
157
+ "treats valid non-object project config JSON %s as having no bundle reference", async (config) => {
158
+ await projectStore.createProject("tenant-1", "workspace", "project", { name: "Project" });
159
+ db.prepare("UPDATE lt_projects SET config = ? WHERE tenant_id = ? AND id = ?")
160
+ .run(config, "tenant-1", "project");
161
+
162
+ await expect(projectStore.isCapabilityBundleReferenced("tenant-1", "bundle-1")).resolves.toBe(false);
163
+ },
164
+ );
165
+
166
+ it("updates only capability bundle IDs while preserving unrelated config", async () => {
167
+ await projectStore.createProject("tenant-1", "workspace", "project", { name: "Project", config: { keep: true } });
168
+ const first = await store.create("tenant-1", { ...input, key: "b1" });
169
+ const second = await store.create("tenant-1", { ...input, key: "b2" });
170
+ const updated = await projectStore.updateCapabilityBundleIds("tenant-1", "project", [first.id, second.id]);
171
+ expect(updated).toMatchObject({ status: "updated", project: { config: { keep: true, capabilityBundleIds: [first.id, second.id] } } });
172
+ await expect(projectStore.updateCapabilityBundleIds("tenant-2", "project", [])).resolves.toEqual({ status: "project_not_found" });
173
+ });
174
+
175
+ it("rejects assignment when the reviewed bundle revision is stale", async () => {
176
+ const bundle = await store.create("tenant-1", input);
177
+ await projectStore.createProject("tenant-1", "workspace", "conflict", { name: "Conflict" });
178
+
179
+ await expect(projectStore.updateCapabilityBundleIds("tenant-1", "conflict", [bundle.id], { [bundle.id]: "stale" }))
180
+ .resolves.toEqual({ status: "bundle_conflict" });
181
+ });
182
+
183
+ it("rejects capability bundle references through generic project writes", async () => {
184
+ await expect(projectStore.createProject("tenant-1", "workspace", "project", {
185
+ name: "Project", config: { capabilityBundleIds: [] },
186
+ })).rejects.toMatchObject({ code: "INVALID_BUNDLE_CONFIG" });
187
+ await projectStore.createProject("tenant-1", "workspace", "project", {
188
+ name: "Project", config: { keep: true },
189
+ });
190
+ await expect(projectStore.updateProject("tenant-1", "project", {
191
+ config: { capabilityBundleIds: [] },
192
+ })).rejects.toMatchObject({ code: "INVALID_BUNDLE_CONFIG" });
193
+ await expect(projectStore.getProjectById("tenant-1", "project"))
194
+ .resolves.toMatchObject({ config: { keep: true } });
195
+ });
196
+
197
+ it("preserves capability bundle IDs when generic create upserts an assigned project", async () => {
198
+ const bundle = await store.create("tenant-1", input);
199
+ await projectStore.createProject("tenant-1", "workspace", "project", {
200
+ name: "Original",
201
+ config: { original: true },
202
+ });
203
+ await projectStore.updateCapabilityBundleIds("tenant-1", "project", [bundle.id]);
204
+
205
+ const upserted = await projectStore.createProject("tenant-1", "workspace-2", "project", {
206
+ name: "Upserted",
207
+ config: { replacement: true },
208
+ });
209
+
210
+ expect(upserted).toMatchObject({
211
+ workspaceId: "workspace-2",
212
+ name: "Upserted",
213
+ config: { replacement: true, capabilityBundleIds: [bundle.id] },
214
+ });
215
+ });
216
+ });
@@ -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
  });