@axiom-lattice/pg-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.
Files changed (41) hide show
  1. package/.turbo/turbo-build.log +10 -10
  2. package/CHANGELOG.md +9 -0
  3. package/dist/index.d.mts +195 -15
  4. package/dist/index.d.ts +195 -15
  5. package/dist/index.js +1309 -199
  6. package/dist/index.js.map +1 -1
  7. package/dist/index.mjs +1295 -191
  8. package/dist/index.mjs.map +1 -1
  9. package/package.json +3 -3
  10. package/src/__tests__/ChannelBindingStore.test.ts +122 -0
  11. package/src/__tests__/PostgreSQLChannelInstallationStore.test.ts +16 -0
  12. package/src/__tests__/PostgreSQLProjectBotMembershipStore.test.ts +117 -0
  13. package/src/__tests__/PostgreSQLProjectMembershipStore.test.ts +235 -0
  14. package/src/__tests__/PostgreSQLProjectRoomMessageStore.test.ts +103 -0
  15. package/src/__tests__/PostgreSQLProjectRoomStore.migrations.test.ts +114 -0
  16. package/src/__tests__/PostgreSQLProjectRoomStore.test.ts +48 -0
  17. package/src/__tests__/PostgreSQLTaskStore.integration.test.ts +27 -0
  18. package/src/__tests__/PostgreSQLTaskStore.test.ts +57 -0
  19. package/src/__tests__/PostgreSQLTaskWorkItemStore.integration.test.ts +74 -0
  20. package/src/__tests__/PostgreSQLTaskWorkItemStore.migrations.test.ts +62 -0
  21. package/src/__tests__/PostgreSQLTaskWorkItemStore.test.ts +96 -0
  22. package/src/__tests__/ThreadMessageQueueStore.migrations.test.ts +63 -0
  23. package/src/__tests__/ThreadMessageQueueStore.test.ts +113 -0
  24. package/src/__tests__/migration-name-version-compatibility.test.ts +67 -0
  25. package/src/__tests__/task-files.test.ts +4 -3
  26. package/src/createPgStoreConfig.ts +14 -0
  27. package/src/index.ts +7 -0
  28. package/src/migrations/add_trusted_run_context_column.ts +18 -0
  29. package/src/migrations/migration.ts +2 -1
  30. package/src/migrations/project_room_migration.ts +128 -0
  31. package/src/migrations/task_migration.ts +15 -0
  32. package/src/migrations/task_work_items_migration.ts +29 -1
  33. package/src/stores/ChannelBindingStore.ts +99 -59
  34. package/src/stores/PostgreSQLChannelInstallationStore.ts +12 -30
  35. package/src/stores/PostgreSQLProjectBotMembershipStore.ts +199 -0
  36. package/src/stores/PostgreSQLProjectMembershipStore.ts +140 -0
  37. package/src/stores/PostgreSQLProjectRoomMessageStore.ts +177 -0
  38. package/src/stores/PostgreSQLProjectRoomStore.ts +57 -0
  39. package/src/stores/PostgreSQLTaskStore.ts +89 -3
  40. package/src/stores/PostgreSQLTaskWorkItemStore.ts +159 -8
  41. package/src/stores/ThreadMessageQueueStore.ts +38 -9
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import { ThreadMessageQueueStore } from '../stores/ThreadMessageQueueStore';
8
+ import type { AddMessageParams } from '@axiom-lattice/core';
8
9
 
9
10
  // Mock pg Pool
10
11
  const mockQuery = jest.fn();
@@ -23,6 +24,13 @@ describe('ThreadMessageQueueStore', () => {
23
24
  const mockThreadId = 'test-thread';
24
25
  const mockTenantId = 'test-tenant';
25
26
  const mockAssistantId = 'test-assistant';
27
+ const trusted = {
28
+ projectRoom: {
29
+ tenantId: mockTenantId, workspaceId: 'workspace', projectId: 'project', roomId: 'room',
30
+ sourceRoomMessageId: 'room-message', membershipId: 'membership', assistantId: mockAssistantId,
31
+ inputMessageId: 'input-1', role: 'coordinator' as const, title: 'Coordinator',
32
+ },
33
+ };
26
34
 
27
35
  beforeEach(() => {
28
36
  store = new ThreadMessageQueueStore({
@@ -34,6 +42,75 @@ describe('ThreadMessageQueueStore', () => {
34
42
  });
35
43
 
36
44
  describe('addMessage', () => {
45
+ it.each(['addMessage', 'addMessageIfCapacity', 'addMessageAtHead'] as const)(
46
+ 'persists trusted metadata through %s',
47
+ async (method) => {
48
+ const row = {
49
+ id: 'input-1', message_content: JSON.stringify('message'), message_type: 'human',
50
+ sequence_order: 1, created_at: new Date(), trusted_run_context: trusted,
51
+ execution_mode: 'followup',
52
+ };
53
+ const params = {
54
+ threadId: mockThreadId, tenantId: mockTenantId, assistantId: mockAssistantId,
55
+ workspaceId: 'workspace', projectId: 'project', id: 'input-1', content: 'message',
56
+ trusted_run_context: trusted, execution_mode: 'followup' as const,
57
+ };
58
+
59
+ if (method === 'addMessageIfCapacity') {
60
+ const client = {
61
+ query: jest.fn()
62
+ .mockResolvedValueOnce({}).mockResolvedValueOnce({})
63
+ .mockResolvedValueOnce({ rows: [{ count: '0' }] })
64
+ .mockResolvedValueOnce({ rows: [{ next_seq: 1 }] })
65
+ .mockResolvedValueOnce({ rows: [row] }).mockResolvedValueOnce({}),
66
+ release: jest.fn(),
67
+ };
68
+ mockConnect.mockResolvedValueOnce(client);
69
+ await store.addMessageIfCapacity(params, 10);
70
+ expect(client.query.mock.calls[4][0]).toEqual(expect.stringContaining('trusted_run_context'));
71
+ expect(client.query.mock.calls[4][1]).toEqual(expect.arrayContaining([JSON.stringify(trusted), 'followup']));
72
+ return;
73
+ }
74
+
75
+ mockQuery.mockResolvedValueOnce({ rows: [{ next_seq: 1 }] }).mockResolvedValueOnce({ rows: [row] });
76
+ await store[method](params);
77
+ expect(mockQuery).toHaveBeenNthCalledWith(2, expect.stringContaining('trusted_run_context'),
78
+ expect.arrayContaining([JSON.stringify(trusted), 'followup']));
79
+ },
80
+ );
81
+ it.each(['addMessage', 'addMessageIfCapacity', 'addMessageAtHead'] as const)(
82
+ 'rejects malformed trusted context before querying through %s',
83
+ async (method) => {
84
+ const params = {
85
+ threadId: mockThreadId, tenantId: mockTenantId, assistantId: mockAssistantId, content: 'message',
86
+ trusted_run_context: { projectRoom: { tenantId: mockTenantId } },
87
+ } as unknown as AddMessageParams;
88
+
89
+ const operation = method === 'addMessageIfCapacity'
90
+ ? store.addMessageIfCapacity(params, 10)
91
+ : store[method](params);
92
+ await expect(operation).rejects.toThrow('Invalid trusted agent run context');
93
+ expect(mockQuery).not.toHaveBeenCalled();
94
+ expect(mockConnect).not.toHaveBeenCalled();
95
+ },
96
+ );
97
+
98
+ it.each(['addMessage', 'addMessageIfCapacity', 'addMessageAtHead'] as const)(
99
+ 'rejects invalid execution mode before querying through %s',
100
+ async (method) => {
101
+ const params: AddMessageParams = {
102
+ threadId: mockThreadId, tenantId: mockTenantId, assistantId: mockAssistantId, content: 'message',
103
+ execution_mode: 'steer' as unknown as AddMessageParams['execution_mode'],
104
+ };
105
+
106
+ const operation = method === 'addMessageIfCapacity'
107
+ ? store.addMessageIfCapacity(params, 10)
108
+ : store[method](params);
109
+ await expect(operation).rejects.toThrow('Invalid queued execution mode');
110
+ expect(mockQuery).not.toHaveBeenCalled();
111
+ expect(mockConnect).not.toHaveBeenCalled();
112
+ },
113
+ );
37
114
  it('atomically rejects enqueue after the scoped pending capacity is reached', async () => {
38
115
  const client = {
39
116
  query: jest.fn()
@@ -285,6 +362,32 @@ describe('ThreadMessageQueueStore', () => {
285
362
  expect((result[0].content as { command?: unknown }).command).toBeUndefined();
286
363
  });
287
364
 
365
+ it('hydrates trusted metadata for pending and processing rows', async () => {
366
+ const row = {
367
+ id: 'input-1', message_content: JSON.stringify('message'), message_type: 'human',
368
+ sequence_order: 1, created_at: new Date(), trusted_run_context: JSON.stringify(trusted),
369
+ execution_mode: 'followup',
370
+ };
371
+ mockQuery.mockResolvedValueOnce({ rows: [row] }).mockResolvedValueOnce({ rows: [row] });
372
+
373
+ expect((await store.getPendingMessages(mockThreadId))[0]).toMatchObject({
374
+ trusted_run_context: trusted, execution_mode: 'followup',
375
+ });
376
+ expect((await store.getProcessingMessages(mockThreadId))[0]).toMatchObject({
377
+ trusted_run_context: trusted, execution_mode: 'followup',
378
+ });
379
+ });
380
+
381
+ it('rejects hostile trusted JSON read from PostgreSQL', async () => {
382
+ mockQuery.mockResolvedValueOnce({ rows: [{
383
+ id: 'hostile', message_content: JSON.stringify('message'), message_type: 'human',
384
+ sequence_order: 1, created_at: new Date(),
385
+ trusted_run_context: { projectRoom: { tenantId: mockTenantId } }, execution_mode: 'followup',
386
+ }] });
387
+
388
+ await expect(store.getPendingMessages(mockThreadId)).rejects.toThrow('Invalid trusted agent run context');
389
+ });
390
+
288
391
  it('matches nullish dimensions as NULL when a scope is supplied', async () => {
289
392
  mockQuery.mockResolvedValueOnce({ rows: [] });
290
393
 
@@ -419,6 +522,16 @@ describe('ThreadMessageQueueStore', () => {
419
522
  ['msg-1']
420
523
  );
421
524
  });
525
+
526
+ it('updates only status and custom config, preserving trusted columns', async () => {
527
+ mockQuery.mockResolvedValueOnce({ rowCount: 1 });
528
+ await store.markProcessing('input-1', { model: 'updated' });
529
+
530
+ const sql = mockQuery.mock.calls[0][0] as string;
531
+ expect(sql).toContain("status = 'processing', custom_run_config = $2");
532
+ expect(sql).not.toContain('trusted_run_context =');
533
+ expect(sql).not.toContain('execution_mode =');
534
+ });
422
535
  });
423
536
 
424
537
  describe('clearMessages', () => {
@@ -0,0 +1,67 @@
1
+ import { describe, expect, it, jest } from "@jest/globals";
2
+ import type { Pool, PoolClient } from "pg";
3
+ import { addTrustedRunContextColumn } from "../migrations/add_trusted_run_context_column";
4
+ import { MigrationManager, type Migration } from "../migrations/migration";
5
+ import { createProjectRoomTables } from "../migrations/project_room_migration";
6
+ import { addTaskDependenciesGinIndex } from "../migrations/task_migration";
7
+ import {
8
+ addProjectLifecycleEventIndex,
9
+ addTaskWorkItemPendingIndexesMigration,
10
+ } from "../migrations/task_work_items_migration";
11
+
12
+ describe("migration name and version compatibility", () => {
13
+ it("skips renumbered feature migrations already applied under their stable names", async () => {
14
+ const previouslyApplied = [
15
+ { name: "create_project_room_tables", version: 171, applied_at: new Date() },
16
+ { name: "add_thread_queue_trusted_run_context", version: 172, applied_at: new Date() },
17
+ { name: "add_task_dependencies_gin_index", version: 173, applied_at: new Date() },
18
+ { name: "add_project_lifecycle_event_index", version: 174, applied_at: new Date() },
19
+ ];
20
+ const executedUpNames: string[] = [];
21
+ const insertedNames: string[] = [];
22
+ const release = jest.fn();
23
+ const query = jest.fn(async (sql: string, params?: unknown[]) => {
24
+ if (sql.includes("information_schema.tables")) return { rows: [{ exists: true }] };
25
+ if (sql.includes("pg_index")) {
26
+ return { rows: [{ column_name: "name", constraint_name: "lattice_schema_migrations_pkey" }] };
27
+ }
28
+ if (sql === "SELECT name, version, applied_at FROM lattice_schema_migrations ORDER BY version") {
29
+ return { rows: previouslyApplied };
30
+ }
31
+ if (sql.includes("INSERT INTO lattice_schema_migrations")) {
32
+ insertedNames.push(params?.[0] as string);
33
+ }
34
+ return { rows: [] };
35
+ });
36
+ const client = { query, release } as unknown as PoolClient;
37
+ const pool = { connect: jest.fn(async () => client) } as unknown as Pool;
38
+ const manager = new MigrationManager(pool);
39
+ const migrations = [
40
+ addTaskWorkItemPendingIndexesMigration,
41
+ createProjectRoomTables,
42
+ addTrustedRunContextColumn,
43
+ addTaskDependenciesGinIndex,
44
+ addProjectLifecycleEventIndex,
45
+ ];
46
+ for (const migration of migrations) {
47
+ const wrapped: Migration = {
48
+ ...migration,
49
+ up: async (migrationClient) => {
50
+ executedUpNames.push(migration.name);
51
+ await migration.up(migrationClient);
52
+ },
53
+ };
54
+ manager.register(wrapped);
55
+ }
56
+
57
+ await manager.migrate();
58
+
59
+ expect(migrations.map(({ version }) => version).every(
60
+ (version, index, versions) => index === 0 || version > versions[index - 1],
61
+ )).toBe(true);
62
+ expect(executedUpNames).toEqual(["add_task_work_item_pending_indexes"]);
63
+ expect(insertedNames).toEqual(["add_task_work_item_pending_indexes"]);
64
+ expect(insertedNames).not.toEqual(expect.arrayContaining(previouslyApplied.map(({ name }) => name)));
65
+ expect(release).toHaveBeenCalledTimes(1);
66
+ });
67
+ });
@@ -13,9 +13,10 @@ describe("add_files_to_tasks migration", () => {
13
13
  expect(taskMigrations.map((m) => m.name)).toContain("add_files_to_tasks");
14
14
  });
15
15
 
16
- it("has a version greater than all existing task migrations", () => {
17
- const versions = taskMigrations.map((m) => m.version);
18
- expect(addFilesToTasks.version).toBe(Math.max(...versions));
16
+ it("has a version greater than the task migrations registered before it", () => {
17
+ const migrationIndex = taskMigrations.indexOf(addFilesToTasks);
18
+ const precedingVersions = taskMigrations.slice(0, migrationIndex).map((m) => m.version);
19
+ expect(addFilesToTasks.version).toBeGreaterThan(Math.max(...precedingVersions));
19
20
  });
20
21
 
21
22
  it("emits ADD COLUMN files JSONB in up()", async () => {
@@ -62,6 +62,7 @@ import { addPriorityAndCommandColumns } from "./migrations/add_priority_command_
62
62
  import { addCustomRunConfigColumn } from "./migrations/add_custom_run_config_column";
63
63
  import { alterMessageQueueIdColumn } from "./migrations/alter_message_queue_id_column";
64
64
  import { addWorkspaceProjectToQueue } from "./migrations/add_workspace_project_to_queue";
65
+ import { addTrustedRunContextColumn } from "./migrations/add_trusted_run_context_column";
65
66
  import { createWorkflowTrackingTables, addStepThreadId, addWorkflowRunsTenantStatusUpdatedIndex } from "./migrations/workflow_tracking_migrations";
66
67
  import { evalMigrations } from "./migrations/eval_migrations";
67
68
  import { createA2AApiKeysTable } from "./migrations/a2a_api_key_migration";
@@ -76,10 +77,16 @@ import {
76
77
  addWorkItemProjectFieldsMigration,
77
78
  addTaskWorkItemEventKeyMigration,
78
79
  addTaskWorkItemPendingIndexesMigration,
80
+ addProjectLifecycleEventIndex,
79
81
  } from "./migrations/task_work_items_migration";
80
82
  import { createAgentWebAppsTable } from "./migrations/agent_web_apps_migration";
81
83
  import { createCapabilityBundlesTable } from "./migrations/capability_bundle_migration";
84
+ import { createProjectRoomTables } from "./migrations/project_room_migration";
82
85
  import { PostgreSQLCapabilityBundleStore } from "./stores/PostgreSQLCapabilityBundleStore";
86
+ import { PostgreSQLProjectRoomStore } from "./stores/PostgreSQLProjectRoomStore";
87
+ import { PostgreSQLProjectMembershipStore } from "./stores/PostgreSQLProjectMembershipStore";
88
+ import { PostgreSQLProjectBotMembershipStore } from "./stores/PostgreSQLProjectBotMembershipStore";
89
+ import { PostgreSQLProjectRoomMessageStore } from "./stores/PostgreSQLProjectRoomMessageStore";
83
90
 
84
91
  export async function createPgStoreConfig(connectionString: string) {
85
92
  const pool = new Pool({ connectionString });
@@ -143,6 +150,9 @@ export async function createPgStoreConfig(connectionString: string) {
143
150
  mm.register(createAgentWebAppsTable); // v169
144
151
  mm.register(createCapabilityBundlesTable); // v170
145
152
  mm.register(addTaskWorkItemPendingIndexesMigration); // v171
153
+ mm.register(createProjectRoomTables); // v172
154
+ mm.register(addTrustedRunContextColumn); // v173
155
+ mm.register(addProjectLifecycleEventIndex); // v175
146
156
 
147
157
  await mm.migrate();
148
158
 
@@ -181,6 +191,10 @@ export async function createPgStoreConfig(connectionString: string) {
181
191
  menu: new MenuStore(opts),
182
192
  sharedResource: new PostgresSharedResourceStore(opts),
183
193
  collection: new PostgreSQLCollectionStore(opts),
194
+ projectRoom: new PostgreSQLProjectRoomStore(opts),
195
+ projectMembership: new PostgreSQLProjectMembershipStore(opts),
196
+ projectBotMembership: new PostgreSQLProjectBotMembershipStore(opts),
197
+ projectRoomMessage: new PostgreSQLProjectRoomMessageStore(opts),
184
198
  vectorStoreProvider: new PGVectorStoreProvider(pool, connectionString),
185
199
  checkpoint,
186
200
  };
package/src/index.ts CHANGED
@@ -58,6 +58,7 @@ export * from "./stores/ChannelIdentityMappingStore";
58
58
  export * from "./stores/PostgreSQLChannelInstallationStore";
59
59
  export * from "./stores/PostgreSQLA2AApiKeyStore";
60
60
  export * from "./stores/PostgreSQLAgentWebAppStore";
61
+ export * from "./stores/PostgreSQLTaskWorkItemStore";
61
62
  export * from "./stores/PostgreSQLCapabilityBundleStore";
62
63
  export * from "./migrations/capability_bundle_migration";
63
64
  export * from "./stores/PostgreSQLWorkflowTrackingStore";
@@ -70,6 +71,11 @@ export * from "./stores/MenuStore";
70
71
  export * from "./stores/SharedResourceStore";
71
72
  export * from "./stores/PostgresSharedResourceStore";
72
73
  export * from "./stores/PostgreSQLCollectionStore";
74
+ export * from "./stores/PostgreSQLProjectRoomStore";
75
+ export * from "./stores/PostgreSQLProjectMembershipStore";
76
+ export * from "./stores/PostgreSQLProjectBotMembershipStore";
77
+ export * from "./stores/PostgreSQLProjectRoomMessageStore";
78
+ export * from "./migrations/project_room_migration";
73
79
 
74
80
  export * from "./PGVectorStoreProvider";
75
81
 
@@ -127,6 +133,7 @@ export type {
127
133
  CreateMenuItemInput,
128
134
  ChannelInstallationStore,
129
135
  ChannelInstallation,
136
+ CreateChannelInstallationInput,
130
137
  CreateChannelInstallationRequest,
131
138
  UpdateChannelInstallationRequest,
132
139
  LarkChannelInstallationConfig,
@@ -0,0 +1,18 @@
1
+ import type { Migration } from "./migration";
2
+
3
+ /** Adds durable privileged context and execution mode to queued thread messages. */
4
+ export const addTrustedRunContextColumn: Migration = {
5
+ version: 173,
6
+ name: "add_thread_queue_trusted_run_context",
7
+ up: async (client): Promise<void> => {
8
+ await client.query(`ALTER TABLE lattice_thread_message_queue
9
+ ADD COLUMN IF NOT EXISTS trusted_run_context JSONB,
10
+ ADD COLUMN IF NOT EXISTS execution_mode VARCHAR(20)
11
+ CHECK (execution_mode IS NULL OR execution_mode = 'followup')`);
12
+ },
13
+ down: async (client): Promise<void> => {
14
+ await client.query(`ALTER TABLE lattice_thread_message_queue
15
+ DROP COLUMN IF EXISTS execution_mode,
16
+ DROP COLUMN IF EXISTS trusted_run_context`);
17
+ },
18
+ };
@@ -83,7 +83,8 @@ export class MigrationManager {
83
83
  const tableExists = await client.query(`
84
84
  SELECT EXISTS (
85
85
  SELECT FROM information_schema.tables
86
- WHERE table_name = 'lattice_schema_migrations'
86
+ WHERE table_schema = current_schema()
87
+ AND table_name = 'lattice_schema_migrations'
87
88
  )
88
89
  `);
89
90
 
@@ -0,0 +1,128 @@
1
+ import type { Migration } from "./migration";
2
+
3
+ /** Creates the tenant-scoped project room, roster, and message tables. */
4
+ export const createProjectRoomTables: Migration = {
5
+ version: 172,
6
+ name: "create_project_room_tables",
7
+ up: async (client) => {
8
+ await client.query(`
9
+ CREATE TABLE IF NOT EXISTS lattice_project_rooms (
10
+ id VARCHAR(255) NOT NULL,
11
+ tenant_id VARCHAR(255) NOT NULL,
12
+ workspace_id VARCHAR(255) NOT NULL,
13
+ project_id VARCHAR(255) NOT NULL,
14
+ name VARCHAR(255) NOT NULL,
15
+ type VARCHAR(32) NOT NULL CONSTRAINT chk_lattice_project_rooms_type
16
+ CHECK (type IN ('main')),
17
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
18
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
19
+ PRIMARY KEY (tenant_id, id),
20
+ UNIQUE (tenant_id, project_id, type)
21
+ )
22
+ `);
23
+
24
+ await client.query(`
25
+ CREATE TABLE IF NOT EXISTS lattice_project_memberships (
26
+ id VARCHAR(255) NOT NULL,
27
+ tenant_id VARCHAR(255) NOT NULL,
28
+ project_id VARCHAR(255) NOT NULL,
29
+ user_id VARCHAR(255) NOT NULL,
30
+ role VARCHAR(32) NOT NULL CONSTRAINT chk_lattice_project_memberships_role
31
+ CHECK (role IN ('owner', 'admin', 'member', 'viewer')),
32
+ status VARCHAR(32) NOT NULL CONSTRAINT chk_lattice_project_memberships_status
33
+ CHECK (status IN ('active', 'removed')),
34
+ joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
35
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
36
+ PRIMARY KEY (tenant_id, id),
37
+ UNIQUE (tenant_id, project_id, user_id)
38
+ )
39
+ `);
40
+ await client.query(`
41
+ CREATE INDEX IF NOT EXISTS idx_lattice_project_memberships_project
42
+ ON lattice_project_memberships (tenant_id, project_id)
43
+ `);
44
+
45
+ await client.query(`
46
+ CREATE TABLE IF NOT EXISTS lattice_project_bot_memberships (
47
+ id VARCHAR(255) NOT NULL,
48
+ tenant_id VARCHAR(255) NOT NULL,
49
+ workspace_id VARCHAR(255) NOT NULL,
50
+ project_id VARCHAR(255) NOT NULL,
51
+ room_id VARCHAR(255) NOT NULL,
52
+ assistant_id VARCHAR(255) NOT NULL,
53
+ role VARCHAR(32) NOT NULL CONSTRAINT chk_lattice_project_bot_memberships_role
54
+ CHECK (role IN ('coordinator', 'specialist')),
55
+ title VARCHAR(255) NOT NULL,
56
+ responsibility TEXT,
57
+ mention_name VARCHAR(255) NOT NULL,
58
+ status VARCHAR(32) NOT NULL CONSTRAINT chk_lattice_project_bot_memberships_status
59
+ CHECK (status IN ('active', 'paused', 'removed')),
60
+ room_thread_id VARCHAR(255) NOT NULL,
61
+ joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
62
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
63
+ PRIMARY KEY (tenant_id, id),
64
+ UNIQUE (tenant_id, project_id, assistant_id)
65
+ )
66
+ `);
67
+ await client.query(`
68
+ CREATE UNIQUE INDEX IF NOT EXISTS uq_lattice_project_bot_memberships_coordinator
69
+ ON lattice_project_bot_memberships (tenant_id, project_id)
70
+ WHERE role = 'coordinator' AND status IN ('active', 'paused')
71
+ `);
72
+ await client.query(`
73
+ CREATE UNIQUE INDEX IF NOT EXISTS uq_lattice_project_bot_memberships_mention
74
+ ON lattice_project_bot_memberships (tenant_id, room_id, mention_name)
75
+ WHERE status IN ('active', 'paused')
76
+ `);
77
+ await client.query(`
78
+ CREATE INDEX IF NOT EXISTS idx_lattice_project_bot_memberships_project
79
+ ON lattice_project_bot_memberships (tenant_id, project_id)
80
+ `);
81
+ await client.query(`
82
+ CREATE INDEX IF NOT EXISTS idx_lattice_project_bot_memberships_room
83
+ ON lattice_project_bot_memberships (tenant_id, room_id)
84
+ `);
85
+
86
+ await client.query(`
87
+ CREATE TABLE IF NOT EXISTS lattice_project_room_messages (
88
+ id VARCHAR(255) NOT NULL,
89
+ tenant_id VARCHAR(255) NOT NULL,
90
+ workspace_id VARCHAR(255) NOT NULL,
91
+ project_id VARCHAR(255) NOT NULL,
92
+ room_id VARCHAR(255) NOT NULL,
93
+ author JSONB NOT NULL,
94
+ content JSONB NOT NULL,
95
+ mentions JSONB NOT NULL DEFAULT '[]'::jsonb,
96
+ reply_to_message_id VARCHAR(255),
97
+ source VARCHAR(32) NOT NULL CONSTRAINT chk_lattice_project_room_messages_source
98
+ CHECK (source IN ('user', 'agent', 'task', 'routine', 'system')),
99
+ source_id VARCHAR(255),
100
+ idempotency_key VARCHAR(255),
101
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
102
+ PRIMARY KEY (tenant_id, id)
103
+ )
104
+ `);
105
+ await client.query(`
106
+ CREATE UNIQUE INDEX IF NOT EXISTS uq_lattice_project_room_messages_idempotency
107
+ ON lattice_project_room_messages (tenant_id, room_id, idempotency_key)
108
+ WHERE idempotency_key IS NOT NULL
109
+ `);
110
+ await client.query(`
111
+ CREATE INDEX IF NOT EXISTS idx_lattice_project_room_messages_room_created
112
+ ON lattice_project_room_messages (tenant_id, room_id, created_at DESC, id DESC)
113
+ `);
114
+ },
115
+ down: async (client) => {
116
+ await client.query("DROP INDEX IF EXISTS idx_lattice_project_room_messages_room_created");
117
+ await client.query("DROP INDEX IF EXISTS uq_lattice_project_room_messages_idempotency");
118
+ await client.query("DROP TABLE IF EXISTS lattice_project_room_messages");
119
+ await client.query("DROP INDEX IF EXISTS idx_lattice_project_bot_memberships_room");
120
+ await client.query("DROP INDEX IF EXISTS idx_lattice_project_bot_memberships_project");
121
+ await client.query("DROP INDEX IF EXISTS uq_lattice_project_bot_memberships_mention");
122
+ await client.query("DROP INDEX IF EXISTS uq_lattice_project_bot_memberships_coordinator");
123
+ await client.query("DROP TABLE IF EXISTS lattice_project_bot_memberships");
124
+ await client.query("DROP INDEX IF EXISTS idx_lattice_project_memberships_project");
125
+ await client.query("DROP TABLE IF EXISTS lattice_project_memberships");
126
+ await client.query("DROP TABLE IF EXISTS lattice_project_rooms");
127
+ },
128
+ };
@@ -98,10 +98,25 @@ export const addFilesToTasks: Migration = {
98
98
  },
99
99
  };
100
100
 
101
+ /** Add JSONB containment support for dependency recovery queries. */
102
+ export const addTaskDependenciesGinIndex: Migration = {
103
+ version: 174,
104
+ name: "add_task_dependencies_gin_index",
105
+ up: async (client) => {
106
+ await client.query(`CREATE INDEX IF NOT EXISTS idx_lattice_tasks_dependencies_gin
107
+ ON lattice_tasks USING GIN (dependencies jsonb_path_ops)
108
+ WHERE dependencies IS NOT NULL`);
109
+ },
110
+ down: async (client) => {
111
+ await client.query("DROP INDEX IF EXISTS idx_lattice_tasks_dependencies_gin");
112
+ },
113
+ };
114
+
101
115
  /** All task migrations in version order */
102
116
  export const taskMigrations: Migration[] = [
103
117
  createTasksTable,
104
118
  addTaskFieldsMigration,
105
119
  addTaskProjectFieldsMigration,
106
120
  addFilesToTasks,
121
+ addTaskDependenciesGinIndex,
107
122
  ];
@@ -16,7 +16,7 @@ export const createTaskWorkItemsMigration: Migration = {
16
16
  summary TEXT,
17
17
  detail JSONB,
18
18
  attempt INTEGER,
19
- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
19
+ created_at TIMESTAMPTZ NOT NULL DEFAULT date_trunc('milliseconds', clock_timestamp()),
20
20
  PRIMARY KEY (tenant_id, id)
21
21
  )
22
22
  `);
@@ -80,3 +80,31 @@ export const addTaskWorkItemPendingIndexesMigration: Migration = {
80
80
  `);
81
81
  },
82
82
  };
83
+
84
+ /** Add deterministic project lifecycle event scan support. */
85
+ export const addProjectLifecycleEventIndex: Migration = {
86
+ version: 175,
87
+ name: "add_project_lifecycle_event_index",
88
+ up: async (client) => {
89
+ await client.query(`UPDATE lattice_task_work_items
90
+ SET created_at = date_trunc('milliseconds', created_at)
91
+ WHERE created_at <> date_trunc('milliseconds', created_at)`);
92
+ await client.query(`ALTER TABLE lattice_task_work_items
93
+ ALTER COLUMN created_at SET DEFAULT date_trunc('milliseconds', clock_timestamp())`);
94
+ await client.query(`CREATE INDEX IF NOT EXISTS idx_task_work_items_project_lifecycle
95
+ ON lattice_task_work_items (tenant_id, workspace_id, project_id, created_at DESC, id DESC)
96
+ WHERE event_key IS NOT NULL`);
97
+ },
98
+ down: async (client) => {
99
+ await client.query("DROP INDEX IF EXISTS idx_task_work_items_project_lifecycle");
100
+ },
101
+ };
102
+
103
+ /** Complete standalone TaskWorkItem migration chain in dependency order. */
104
+ export const taskWorkItemMigrations: Migration[] = [
105
+ createTaskWorkItemsMigration,
106
+ addWorkItemProjectFieldsMigration,
107
+ addTaskWorkItemEventKeyMigration,
108
+ addTaskWorkItemPendingIndexesMigration,
109
+ addProjectLifecycleEventIndex,
110
+ ];