@axiom-lattice/pg-stores 3.0.2 → 3.1.1
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 +21 -0
- package/dist/index.d.mts +91 -11
- package/dist/index.d.ts +91 -11
- package/dist/index.js +619 -86
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +614 -81
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/PostgreSQLCapabilityBundleStore.migrations.test.ts +67 -0
- package/src/__tests__/PostgreSQLCapabilityBundleStore.test.ts +383 -0
- package/src/__tests__/PostgreSQLTaskWorkItemStore.integration.test.ts +33 -0
- package/src/__tests__/PostgreSQLTaskWorkItemStore.test.ts +66 -1
- package/src/__tests__/ThreadMessageQueueStore.test.ts +96 -4
- package/src/__tests__/add_workspace_project_to_queue.test.ts +15 -0
- package/src/createPgStoreConfig.ts +11 -1
- package/src/index.ts +6 -0
- package/src/migrations/capability_bundle_migration.ts +20 -0
- package/src/migrations/task_work_items_migration.ts +16 -0
- package/src/stores/PostgreSQLCapabilityBundleStore.ts +306 -0
- package/src/stores/PostgreSQLProjectStore.ts +230 -50
- package/src/stores/PostgreSQLTaskWorkItemStore.ts +39 -0
- package/src/stores/ThreadMessageQueueStore.ts +97 -28
|
@@ -34,6 +34,33 @@ describe('ThreadMessageQueueStore', () => {
|
|
|
34
34
|
});
|
|
35
35
|
|
|
36
36
|
describe('addMessage', () => {
|
|
37
|
+
it('atomically rejects enqueue after the scoped pending capacity is reached', async () => {
|
|
38
|
+
const client = {
|
|
39
|
+
query: jest.fn()
|
|
40
|
+
.mockResolvedValueOnce({})
|
|
41
|
+
.mockResolvedValueOnce({})
|
|
42
|
+
.mockResolvedValueOnce({ rows: [{ count: '2' }] })
|
|
43
|
+
.mockResolvedValueOnce({}),
|
|
44
|
+
release: jest.fn(),
|
|
45
|
+
};
|
|
46
|
+
mockConnect.mockResolvedValueOnce(client);
|
|
47
|
+
|
|
48
|
+
await expect(store.addMessageIfCapacity({
|
|
49
|
+
threadId: mockThreadId,
|
|
50
|
+
tenantId: mockTenantId,
|
|
51
|
+
assistantId: mockAssistantId,
|
|
52
|
+
workspaceId: null,
|
|
53
|
+
projectId: null,
|
|
54
|
+
content: 'full',
|
|
55
|
+
}, 2)).resolves.toBe(false);
|
|
56
|
+
|
|
57
|
+
expect(client.query).toHaveBeenCalledWith(
|
|
58
|
+
'SELECT pg_advisory_xact_lock(hashtext($1))',
|
|
59
|
+
[`${mockTenantId}:${mockAssistantId}:${mockThreadId}::`],
|
|
60
|
+
);
|
|
61
|
+
expect(client.release).toHaveBeenCalled();
|
|
62
|
+
});
|
|
63
|
+
|
|
37
64
|
it('should add message to queue', async () => {
|
|
38
65
|
const mockMessage = {
|
|
39
66
|
id: 'msg-1',
|
|
@@ -131,7 +158,6 @@ describe('ThreadMessageQueueStore', () => {
|
|
|
131
158
|
it('should insert message with high priority', async () => {
|
|
132
159
|
// Mock thread info query
|
|
133
160
|
mockQuery
|
|
134
|
-
.mockResolvedValueOnce({ rows: [{ tenant_id: mockTenantId, assistant_id: mockAssistantId }] }) // Get thread info
|
|
135
161
|
.mockResolvedValueOnce({ rows: [{ next_seq: 1 }] }) // Get next sequence
|
|
136
162
|
.mockResolvedValueOnce({
|
|
137
163
|
rows: [{
|
|
@@ -158,7 +184,6 @@ describe('ThreadMessageQueueStore', () => {
|
|
|
158
184
|
|
|
159
185
|
it('should use default tenant/assistant when no existing messages', async () => {
|
|
160
186
|
mockQuery
|
|
161
|
-
.mockResolvedValueOnce({ rows: [] }) // No existing messages
|
|
162
187
|
.mockResolvedValueOnce({ rows: [{ next_seq: 0 }] }) // Get next sequence
|
|
163
188
|
.mockResolvedValueOnce({
|
|
164
189
|
rows: [{
|
|
@@ -259,6 +284,53 @@ describe('ThreadMessageQueueStore', () => {
|
|
|
259
284
|
expect(result[0].custom_run_config).toEqual(customRunConfig);
|
|
260
285
|
expect((result[0].content as { command?: unknown }).command).toBeUndefined();
|
|
261
286
|
});
|
|
287
|
+
|
|
288
|
+
it('matches nullish dimensions as NULL when a scope is supplied', async () => {
|
|
289
|
+
mockQuery.mockResolvedValueOnce({ rows: [] });
|
|
290
|
+
|
|
291
|
+
await store.getPendingMessages(mockThreadId, {
|
|
292
|
+
tenantId: mockTenantId,
|
|
293
|
+
assistantId: mockAssistantId,
|
|
294
|
+
workspaceId: undefined,
|
|
295
|
+
projectId: undefined,
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
expect(mockQuery).toHaveBeenCalledWith(
|
|
299
|
+
expect.stringMatching(/tenant_id = \$2.*assistant_id = \$3.*workspace_id IS NULL.*project_id IS NULL/s),
|
|
300
|
+
[mockThreadId, mockTenantId, mockAssistantId],
|
|
301
|
+
);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('applies null-sensitive full scope predicates to every scoped operation', async () => {
|
|
305
|
+
const scope = {
|
|
306
|
+
tenantId: mockTenantId,
|
|
307
|
+
assistantId: mockAssistantId,
|
|
308
|
+
workspaceId: undefined,
|
|
309
|
+
projectId: undefined,
|
|
310
|
+
};
|
|
311
|
+
mockQuery
|
|
312
|
+
.mockResolvedValueOnce({ rows: [] })
|
|
313
|
+
.mockResolvedValueOnce({ rows: [] })
|
|
314
|
+
.mockResolvedValueOnce({ rows: [{ count: '0' }] })
|
|
315
|
+
.mockResolvedValueOnce({ rowCount: 0 })
|
|
316
|
+
.mockResolvedValueOnce({ rowCount: 0 })
|
|
317
|
+
.mockResolvedValueOnce({ rowCount: 0 })
|
|
318
|
+
.mockResolvedValueOnce({ rowCount: 0 });
|
|
319
|
+
|
|
320
|
+
await store.getProcessingMessages(mockThreadId, scope);
|
|
321
|
+
await store.getPendingMessages(mockThreadId, scope);
|
|
322
|
+
await store.getQueueSize(mockThreadId, scope);
|
|
323
|
+
await store.markProcessing('msg-1', undefined, scope);
|
|
324
|
+
await store.resetProcessingToPending(mockThreadId, scope);
|
|
325
|
+
await store.removeMessage('msg-1', scope);
|
|
326
|
+
await store.clearMessages(mockThreadId, scope);
|
|
327
|
+
|
|
328
|
+
expect(mockQuery).toHaveBeenCalledTimes(7);
|
|
329
|
+
for (const [sql, params] of mockQuery.mock.calls as Array<[string, unknown[]]>) {
|
|
330
|
+
expect(sql).toMatch(/tenant_id = \$2.*assistant_id = \$3.*workspace_id IS NULL.*project_id IS NULL/s);
|
|
331
|
+
expect(params).toEqual(expect.arrayContaining([mockTenantId, mockAssistantId]));
|
|
332
|
+
}
|
|
333
|
+
});
|
|
262
334
|
});
|
|
263
335
|
|
|
264
336
|
describe('getQueueSize', () => {
|
|
@@ -296,6 +368,26 @@ describe('ThreadMessageQueueStore', () => {
|
|
|
296
368
|
expect(result[1].threadId).toBe('thread-2');
|
|
297
369
|
expect(result[2].threadId).toBe('thread-3');
|
|
298
370
|
});
|
|
371
|
+
|
|
372
|
+
it('preserves NULL workspace and project identity for legacy rows', async () => {
|
|
373
|
+
mockQuery.mockResolvedValueOnce({
|
|
374
|
+
rows: [{
|
|
375
|
+
tenant_id: mockTenantId,
|
|
376
|
+
assistant_id: mockAssistantId,
|
|
377
|
+
thread_id: mockThreadId,
|
|
378
|
+
workspace_id: null,
|
|
379
|
+
project_id: null,
|
|
380
|
+
}],
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
await expect(store.getThreadsWithPendingMessages()).resolves.toEqual([{
|
|
384
|
+
tenantId: mockTenantId,
|
|
385
|
+
assistantId: mockAssistantId,
|
|
386
|
+
threadId: mockThreadId,
|
|
387
|
+
workspaceId: null,
|
|
388
|
+
projectId: null,
|
|
389
|
+
}]);
|
|
390
|
+
});
|
|
299
391
|
});
|
|
300
392
|
|
|
301
393
|
describe('removeMessage', () => {
|
|
@@ -323,7 +415,7 @@ describe('ThreadMessageQueueStore', () => {
|
|
|
323
415
|
await store.markProcessing('msg-1');
|
|
324
416
|
|
|
325
417
|
expect(mockQuery).toHaveBeenCalledWith(
|
|
326
|
-
expect.stringContaining("UPDATE
|
|
418
|
+
expect.stringContaining("UPDATE lattice_thread_message_queue"),
|
|
327
419
|
['msg-1']
|
|
328
420
|
);
|
|
329
421
|
});
|
|
@@ -336,7 +428,7 @@ describe('ThreadMessageQueueStore', () => {
|
|
|
336
428
|
await store.clearMessages(mockThreadId);
|
|
337
429
|
|
|
338
430
|
expect(mockQuery).toHaveBeenCalledWith(
|
|
339
|
-
expect.stringContaining("DELETE FROM
|
|
431
|
+
expect.stringContaining("DELETE FROM lattice_thread_message_queue"),
|
|
340
432
|
[mockThreadId]
|
|
341
433
|
);
|
|
342
434
|
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { PoolClient } from "pg";
|
|
2
|
+
import { addWorkspaceProjectToQueue } from "../migrations/add_workspace_project_to_queue";
|
|
3
|
+
|
|
4
|
+
describe("addWorkspaceProjectToQueue", () => {
|
|
5
|
+
it("adds nullable scope columns so legacy rows retain NULL identity", async () => {
|
|
6
|
+
const query = jest.fn().mockResolvedValue({ rows: [] });
|
|
7
|
+
|
|
8
|
+
await addWorkspaceProjectToQueue.up({ query } as unknown as PoolClient);
|
|
9
|
+
|
|
10
|
+
const sql = query.mock.calls.map(([statement]) => statement as string).join("\n");
|
|
11
|
+
expect(sql).toContain("ADD COLUMN IF NOT EXISTS workspace_id VARCHAR(255)");
|
|
12
|
+
expect(sql).toContain("ADD COLUMN IF NOT EXISTS project_id VARCHAR(255)");
|
|
13
|
+
expect(sql).not.toMatch(/SET\s+DEFAULT|NOT\s+NULL|UPDATE\s+lattice_thread_message_queue/i);
|
|
14
|
+
});
|
|
15
|
+
});
|
|
@@ -71,8 +71,15 @@ import { createMenuItemsTable } from "./migrations/menu_items_migration";
|
|
|
71
71
|
import { addFileContentType } from "./migrations/menu_items_add_file_type";
|
|
72
72
|
import { createSharedResourcesTable } from "./migrations/shared_resources_migration";
|
|
73
73
|
import { createCollectionsTable } from "./migrations/collection_migrations";
|
|
74
|
-
import {
|
|
74
|
+
import {
|
|
75
|
+
createTaskWorkItemsMigration,
|
|
76
|
+
addWorkItemProjectFieldsMigration,
|
|
77
|
+
addTaskWorkItemEventKeyMigration,
|
|
78
|
+
addTaskWorkItemPendingIndexesMigration,
|
|
79
|
+
} from "./migrations/task_work_items_migration";
|
|
75
80
|
import { createAgentWebAppsTable } from "./migrations/agent_web_apps_migration";
|
|
81
|
+
import { createCapabilityBundlesTable } from "./migrations/capability_bundle_migration";
|
|
82
|
+
import { PostgreSQLCapabilityBundleStore } from "./stores/PostgreSQLCapabilityBundleStore";
|
|
76
83
|
|
|
77
84
|
export async function createPgStoreConfig(connectionString: string) {
|
|
78
85
|
const pool = new Pool({ connectionString });
|
|
@@ -134,6 +141,8 @@ export async function createPgStoreConfig(connectionString: string) {
|
|
|
134
141
|
mm.register(addA2AKeyAssistantIds); // v167
|
|
135
142
|
mm.register(addTaskWorkItemEventKeyMigration); // v168
|
|
136
143
|
mm.register(createAgentWebAppsTable); // v169
|
|
144
|
+
mm.register(createCapabilityBundlesTable); // v170
|
|
145
|
+
mm.register(addTaskWorkItemPendingIndexesMigration); // v171
|
|
137
146
|
|
|
138
147
|
await mm.migrate();
|
|
139
148
|
|
|
@@ -167,6 +176,7 @@ export async function createPgStoreConfig(connectionString: string) {
|
|
|
167
176
|
taskWorkItem: taskWorkItemStore,
|
|
168
177
|
a2aApiKey: new PostgreSQLA2AApiKeyStore(opts),
|
|
169
178
|
agentWebApp: new PostgreSQLAgentWebAppStore(opts),
|
|
179
|
+
capabilityBundle: new PostgreSQLCapabilityBundleStore(opts),
|
|
170
180
|
schedule: new PostgreSQLScheduleStorage(opts),
|
|
171
181
|
menu: new MenuStore(opts),
|
|
172
182
|
sharedResource: new PostgresSharedResourceStore(opts),
|
package/src/index.ts
CHANGED
|
@@ -58,6 +58,8 @@ 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/PostgreSQLCapabilityBundleStore";
|
|
62
|
+
export * from "./migrations/capability_bundle_migration";
|
|
61
63
|
export * from "./stores/PostgreSQLWorkflowTrackingStore";
|
|
62
64
|
export * from "./stores/PostgreSQLEvalStore";
|
|
63
65
|
export * from "./migrations/eval_migrations";
|
|
@@ -191,4 +193,8 @@ export type {
|
|
|
191
193
|
AgentWebAppAppearance,
|
|
192
194
|
CreateAgentWebAppInput,
|
|
193
195
|
UpdateAgentWebAppInput,
|
|
196
|
+
CapabilityBundleStore,
|
|
197
|
+
CapabilityBundle,
|
|
198
|
+
CreateCapabilityBundleInput,
|
|
199
|
+
UpdateCapabilityBundleInput,
|
|
194
200
|
} from "@axiom-lattice/protocols";
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Migration } from "./migration";
|
|
2
|
+
|
|
3
|
+
/** Creates the tenant-scoped capability bundle table. */
|
|
4
|
+
export const createCapabilityBundlesTable: Migration = {
|
|
5
|
+
version: 170,
|
|
6
|
+
name: "create_capability_bundles_table",
|
|
7
|
+
up: async (client) => {
|
|
8
|
+
await client.query(`CREATE TABLE IF NOT EXISTS lattice_capability_bundles (
|
|
9
|
+
id UUID PRIMARY KEY, tenant_id VARCHAR(255) NOT NULL, bundle_key VARCHAR(255) NOT NULL,
|
|
10
|
+
name VARCHAR(255) NOT NULL, description TEXT, capabilities JSONB NOT NULL DEFAULT '[]'::jsonb,
|
|
11
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
12
|
+
UNIQUE (tenant_id, bundle_key)
|
|
13
|
+
)`);
|
|
14
|
+
await client.query("CREATE INDEX IF NOT EXISTS idx_lattice_capability_bundles_tenant ON lattice_capability_bundles(tenant_id)");
|
|
15
|
+
},
|
|
16
|
+
down: async (client) => {
|
|
17
|
+
await client.query("DROP INDEX IF EXISTS idx_lattice_capability_bundles_tenant");
|
|
18
|
+
await client.query("DROP TABLE IF EXISTS lattice_capability_bundles");
|
|
19
|
+
},
|
|
20
|
+
};
|
|
@@ -64,3 +64,19 @@ export const addTaskWorkItemEventKeyMigration: Migration = {
|
|
|
64
64
|
`);
|
|
65
65
|
},
|
|
66
66
|
};
|
|
67
|
+
|
|
68
|
+
export const addTaskWorkItemPendingIndexesMigration: Migration = {
|
|
69
|
+
version: 171,
|
|
70
|
+
name: "add_task_work_item_pending_indexes",
|
|
71
|
+
up: async (client: PoolClient) => {
|
|
72
|
+
await client.query(`
|
|
73
|
+
CREATE INDEX IF NOT EXISTS idx_task_work_items_pending_order
|
|
74
|
+
ON lattice_task_work_items (tenant_id, task_id, action, created_at DESC, id DESC)
|
|
75
|
+
`);
|
|
76
|
+
await client.query(`
|
|
77
|
+
CREATE INDEX IF NOT EXISTS idx_task_work_items_reconciled_result
|
|
78
|
+
ON lattice_task_work_items (tenant_id, task_id, (detail ->> 'executionResultId'))
|
|
79
|
+
WHERE action = 'execution_reconciled'
|
|
80
|
+
`);
|
|
81
|
+
},
|
|
82
|
+
};
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import { randomUUID } from "crypto";
|
|
2
|
+
import { Pool } from "pg";
|
|
3
|
+
import type { PoolConfig } from "pg";
|
|
4
|
+
import type {
|
|
5
|
+
CapabilityBundle,
|
|
6
|
+
CapabilityBundleDeleteResult,
|
|
7
|
+
CapabilityBundleStore,
|
|
8
|
+
InternalCreateCapabilityBundleInput,
|
|
9
|
+
InternalUpdateCapabilityBundleInput,
|
|
10
|
+
} from "@axiom-lattice/protocols";
|
|
11
|
+
import { MigrationManager } from "../migrations/migration";
|
|
12
|
+
import { createCapabilityBundlesTable } from "../migrations/capability_bundle_migration";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Configures PostgreSQL connectivity and migration behavior for capability bundles.
|
|
16
|
+
*
|
|
17
|
+
* @remarks
|
|
18
|
+
* An injected `pool` is externally managed and assumed to have migrations applied.
|
|
19
|
+
* When `poolConfig` is used, the store owns the pool and runs migrations eagerly
|
|
20
|
+
* unless `autoMigrate` is false. Call `initialize()` before use in that case.
|
|
21
|
+
*/
|
|
22
|
+
export interface PostgreSQLCapabilityBundleStoreOptions {
|
|
23
|
+
/** Externally managed PostgreSQL pool. */
|
|
24
|
+
pool?: Pool;
|
|
25
|
+
/** Connection string or pool configuration used to create an owned pool. */
|
|
26
|
+
poolConfig?: string | PoolConfig;
|
|
27
|
+
/** Whether an owned pool should run migrations during construction. @default true */
|
|
28
|
+
autoMigrate?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type Row = {
|
|
32
|
+
id: string;
|
|
33
|
+
tenant_id: string;
|
|
34
|
+
bundle_key: string;
|
|
35
|
+
name: string;
|
|
36
|
+
description: string | null;
|
|
37
|
+
capabilities: unknown;
|
|
38
|
+
created_at: Date;
|
|
39
|
+
updated_at: Date | string;
|
|
40
|
+
};
|
|
41
|
+
const duplicateMessage = "Capability bundle key already exists for tenant";
|
|
42
|
+
function map(row: Row): CapabilityBundle {
|
|
43
|
+
return {
|
|
44
|
+
id: row.id,
|
|
45
|
+
tenantId: row.tenant_id,
|
|
46
|
+
key: row.bundle_key,
|
|
47
|
+
name: row.name,
|
|
48
|
+
description: row.description ?? undefined,
|
|
49
|
+
capabilities: row.capabilities as CapabilityBundle["capabilities"],
|
|
50
|
+
createdAt: row.created_at.toISOString(),
|
|
51
|
+
updatedAt: typeof row.updated_at === "string" ? row.updated_at : row.updated_at.toISOString(),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isDuplicate(error: unknown): boolean {
|
|
56
|
+
return typeof error === "object"
|
|
57
|
+
&& error !== null
|
|
58
|
+
&& "code" in error
|
|
59
|
+
&& error.code === "23505";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Persists tenant-scoped capability bundles in PostgreSQL.
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* ```ts
|
|
67
|
+
* const store = new PostgreSQLCapabilityBundleStore({ poolConfig: connectionString });
|
|
68
|
+
* await store.initialize();
|
|
69
|
+
* const bundles = await store.listByTenant("tenant-1");
|
|
70
|
+
* await store.dispose();
|
|
71
|
+
* ```
|
|
72
|
+
*
|
|
73
|
+
* @remarks
|
|
74
|
+
* Stores created from `poolConfig` own their pool and must be disposed. Stores
|
|
75
|
+
* created with an injected pool neither migrate nor close that pool.
|
|
76
|
+
*/
|
|
77
|
+
export class PostgreSQLCapabilityBundleStore implements CapabilityBundleStore {
|
|
78
|
+
private readonly pool: Pool;
|
|
79
|
+
private migrationManager!: MigrationManager;
|
|
80
|
+
private initialized = false;
|
|
81
|
+
private ownsPool = true;
|
|
82
|
+
private initPromise: Promise<void> | null = null;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Creates a PostgreSQL capability bundle store.
|
|
86
|
+
*
|
|
87
|
+
* @param options - Pool ownership, connection, and migration options.
|
|
88
|
+
*/
|
|
89
|
+
constructor(options: PostgreSQLCapabilityBundleStoreOptions) {
|
|
90
|
+
if (options.pool) {
|
|
91
|
+
this.pool = options.pool;
|
|
92
|
+
this.ownsPool = false;
|
|
93
|
+
this.initialized = true;
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
this.pool = typeof options.poolConfig === "string"
|
|
98
|
+
? new Pool({ connectionString: options.poolConfig })
|
|
99
|
+
: options.poolConfig
|
|
100
|
+
? new Pool(options.poolConfig)
|
|
101
|
+
: (() => { throw new Error("Either pool or poolConfig must be provided"); })();
|
|
102
|
+
|
|
103
|
+
this.migrationManager = new MigrationManager(this.pool);
|
|
104
|
+
this.migrationManager.register(createCapabilityBundlesTable);
|
|
105
|
+
|
|
106
|
+
if (options.autoMigrate !== false) {
|
|
107
|
+
this.startInitialization();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Applies pending migrations for an internally managed pool.
|
|
113
|
+
*
|
|
114
|
+
* @returns A shared promise that resolves when initialization completes.
|
|
115
|
+
*/
|
|
116
|
+
async initialize(): Promise<void> {
|
|
117
|
+
if (this.initialized) return;
|
|
118
|
+
if (this.initPromise) return this.initPromise;
|
|
119
|
+
return this.startInitialization();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private startInitialization(): Promise<void> {
|
|
123
|
+
this.initPromise = this.migrationManager.migrate().then(() => {
|
|
124
|
+
this.initialized = true;
|
|
125
|
+
});
|
|
126
|
+
void this.initPromise.catch(() => undefined);
|
|
127
|
+
return this.initPromise;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Closes the pool when it was created by this store.
|
|
132
|
+
*
|
|
133
|
+
* @returns A promise that resolves after owned resources are released.
|
|
134
|
+
*/
|
|
135
|
+
async dispose(): Promise<void> {
|
|
136
|
+
if (this.ownsPool) await this.pool.end();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private async ready(): Promise<void> {
|
|
140
|
+
if (!this.initialized) await this.initialize();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async listByTenant(tenantId: string): Promise<CapabilityBundle[]> {
|
|
144
|
+
await this.ready();
|
|
145
|
+
const result = await this.pool.query<Row>(
|
|
146
|
+
"SELECT id, tenant_id, bundle_key, name, description, capabilities, created_at, updated_at::text AS updated_at FROM lattice_capability_bundles WHERE tenant_id = $1 ORDER BY created_at",
|
|
147
|
+
[tenantId],
|
|
148
|
+
);
|
|
149
|
+
return result.rows.map(map);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async getById(tenantId: string, id: string): Promise<CapabilityBundle | null> {
|
|
153
|
+
await this.ready();
|
|
154
|
+
const result = await this.pool.query<Row>(
|
|
155
|
+
"SELECT id, tenant_id, bundle_key, name, description, capabilities, created_at, updated_at::text AS updated_at FROM lattice_capability_bundles WHERE tenant_id = $1 AND id = $2",
|
|
156
|
+
[tenantId, id],
|
|
157
|
+
);
|
|
158
|
+
return result.rows[0] ? map(result.rows[0]) : null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async getManyByIds(tenantId: string, ids: string[]): Promise<CapabilityBundle[]> {
|
|
162
|
+
await this.ready();
|
|
163
|
+
if (ids.length === 0) return [];
|
|
164
|
+
const result = await this.pool.query<Row>(
|
|
165
|
+
"SELECT id, tenant_id, bundle_key, name, description, capabilities, created_at, updated_at::text AS updated_at FROM lattice_capability_bundles WHERE tenant_id = $1 AND id = ANY($2::uuid[])",
|
|
166
|
+
[tenantId, ids],
|
|
167
|
+
);
|
|
168
|
+
return result.rows.map(map);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async create(
|
|
172
|
+
tenantId: string,
|
|
173
|
+
input: InternalCreateCapabilityBundleInput,
|
|
174
|
+
): Promise<CapabilityBundle> {
|
|
175
|
+
await this.ready();
|
|
176
|
+
try {
|
|
177
|
+
const result = await this.pool.query<Row>(
|
|
178
|
+
`INSERT INTO lattice_capability_bundles
|
|
179
|
+
(id, tenant_id, bundle_key, name, description, capabilities)
|
|
180
|
+
VALUES ($1, $2, $3, $4, $5, $6::jsonb)
|
|
181
|
+
RETURNING id, tenant_id, bundle_key, name, description, capabilities, created_at, updated_at::text AS updated_at`,
|
|
182
|
+
[
|
|
183
|
+
randomUUID(),
|
|
184
|
+
tenantId,
|
|
185
|
+
input.key,
|
|
186
|
+
input.name,
|
|
187
|
+
input.description ?? null,
|
|
188
|
+
JSON.stringify(input.capabilities),
|
|
189
|
+
],
|
|
190
|
+
);
|
|
191
|
+
return map(result.rows[0]);
|
|
192
|
+
} catch (error) {
|
|
193
|
+
if (isDuplicate(error)) throw new Error(duplicateMessage);
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async update(
|
|
199
|
+
tenantId: string,
|
|
200
|
+
id: string,
|
|
201
|
+
input: InternalUpdateCapabilityBundleInput,
|
|
202
|
+
): Promise<CapabilityBundle | { status: "conflict" } | null> {
|
|
203
|
+
await this.ready();
|
|
204
|
+
const fields: string[] = [];
|
|
205
|
+
const values: unknown[] = [];
|
|
206
|
+
const add = (field: string, value: unknown): void => {
|
|
207
|
+
values.push(value);
|
|
208
|
+
fields.push(`${field} = $${values.length}`);
|
|
209
|
+
};
|
|
210
|
+
if (input.name !== undefined) add("name", input.name);
|
|
211
|
+
if (Object.prototype.hasOwnProperty.call(input, "description")) {
|
|
212
|
+
add("description", input.description ?? null);
|
|
213
|
+
}
|
|
214
|
+
if (input.capabilities !== undefined) {
|
|
215
|
+
add("capabilities", JSON.stringify(input.capabilities));
|
|
216
|
+
}
|
|
217
|
+
if (fields.length === 0) {
|
|
218
|
+
const expectedUpdatedAt = input.expectedUpdatedAt;
|
|
219
|
+
const result = await this.pool.query<Row>(
|
|
220
|
+
`SELECT id, tenant_id, bundle_key, name, description, capabilities, created_at, updated_at::text AS updated_at
|
|
221
|
+
FROM lattice_capability_bundles
|
|
222
|
+
WHERE tenant_id = $1 AND id = $2${expectedUpdatedAt === undefined ? "" : " AND updated_at::text = $3"}`,
|
|
223
|
+
expectedUpdatedAt === undefined ? [tenantId, id] : [tenantId, id, expectedUpdatedAt],
|
|
224
|
+
);
|
|
225
|
+
if (result.rows[0]) return map(result.rows[0]);
|
|
226
|
+
return expectedUpdatedAt === undefined ? null : { status: "conflict" };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const expectedUpdatedAt = input.expectedUpdatedAt;
|
|
230
|
+
values.push(tenantId, id);
|
|
231
|
+
if (expectedUpdatedAt !== undefined) values.push(expectedUpdatedAt);
|
|
232
|
+
const tenantParam = values.length - (expectedUpdatedAt === undefined ? 1 : 2);
|
|
233
|
+
const idParam = tenantParam + 1;
|
|
234
|
+
const revisionPredicate = expectedUpdatedAt === undefined ? "" : ` AND updated_at::text = $${values.length}`;
|
|
235
|
+
try {
|
|
236
|
+
const result = await this.pool.query<Row>(
|
|
237
|
+
`UPDATE lattice_capability_bundles
|
|
238
|
+
SET ${fields.join(", ")}, updated_at = GREATEST(updated_at + interval '1 microsecond', clock_timestamp())
|
|
239
|
+
WHERE tenant_id = $${tenantParam} AND id = $${idParam}${revisionPredicate}
|
|
240
|
+
RETURNING id, tenant_id, bundle_key, name, description, capabilities, created_at, updated_at::text AS updated_at`,
|
|
241
|
+
values,
|
|
242
|
+
);
|
|
243
|
+
if (result.rows[0]) return map(result.rows[0]);
|
|
244
|
+
return expectedUpdatedAt === undefined ? null : { status: "conflict" };
|
|
245
|
+
} catch (error) {
|
|
246
|
+
if (isDuplicate(error)) throw new Error(duplicateMessage);
|
|
247
|
+
throw error;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async deleteIfUnreferenced(tenantId: string, id: string): Promise<CapabilityBundleDeleteResult> {
|
|
252
|
+
await this.ready();
|
|
253
|
+
const client = await this.pool.connect();
|
|
254
|
+
try {
|
|
255
|
+
await client.query("BEGIN");
|
|
256
|
+
await client.query(
|
|
257
|
+
"SELECT pg_advisory_xact_lock(hashtextextended($1 || ':project-mutations', 0))",
|
|
258
|
+
[tenantId],
|
|
259
|
+
);
|
|
260
|
+
const projectIds = await client.query<{ id: string }>(
|
|
261
|
+
`SELECT id FROM lattice_projects
|
|
262
|
+
WHERE tenant_id = $1
|
|
263
|
+
ORDER BY id`,
|
|
264
|
+
[tenantId],
|
|
265
|
+
);
|
|
266
|
+
if (projectIds.rows.length > 0) {
|
|
267
|
+
await client.query(
|
|
268
|
+
`SELECT pg_advisory_xact_lock(hashtextextended($1 || ':project:' || project_id, 0))
|
|
269
|
+
FROM unnest($2::text[]) AS project_id
|
|
270
|
+
ORDER BY project_id`,
|
|
271
|
+
[tenantId, projectIds.rows.map((project) => project.id)],
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
await client.query(
|
|
275
|
+
`SELECT id FROM lattice_projects
|
|
276
|
+
WHERE tenant_id = $1
|
|
277
|
+
FOR UPDATE`,
|
|
278
|
+
[tenantId],
|
|
279
|
+
);
|
|
280
|
+
await client.query("SELECT pg_advisory_xact_lock(hashtextextended($1 || ':' || $2, 0))", [tenantId, id]);
|
|
281
|
+
const referenced = await client.query(
|
|
282
|
+
`SELECT 1 FROM lattice_projects
|
|
283
|
+
WHERE tenant_id = $1
|
|
284
|
+
AND jsonb_typeof(config->'capabilityBundleIds') = 'array'
|
|
285
|
+
AND COALESCE(config->'capabilityBundleIds', '[]'::jsonb) ? $2
|
|
286
|
+
LIMIT 1`,
|
|
287
|
+
[tenantId, id],
|
|
288
|
+
);
|
|
289
|
+
if (referenced.rows.length > 0) {
|
|
290
|
+
await client.query("COMMIT");
|
|
291
|
+
return "in_use";
|
|
292
|
+
}
|
|
293
|
+
const deleted = await client.query(
|
|
294
|
+
"DELETE FROM lattice_capability_bundles WHERE tenant_id = $1 AND id = $2 RETURNING id",
|
|
295
|
+
[tenantId, id],
|
|
296
|
+
);
|
|
297
|
+
await client.query("COMMIT");
|
|
298
|
+
return deleted.rows.length > 0 ? "deleted" : "not_found";
|
|
299
|
+
} catch (error) {
|
|
300
|
+
await client.query("ROLLBACK");
|
|
301
|
+
throw error;
|
|
302
|
+
} finally {
|
|
303
|
+
client.release();
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|