@axiom-lattice/pg-stores 3.1.16 → 3.1.18
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 +49 -0
- package/dist/index.d.mts +9 -1
- package/dist/index.d.ts +9 -1
- package/dist/index.js +57 -12
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +56 -12
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/ChannelIdentityMappingStore.test.ts +9 -0
- package/src/__tests__/MenuStore.mcpApp.integration.test.ts +36 -0
- package/src/__tests__/MenuStore.migrations.test.ts +38 -0
- package/src/__tests__/PostgreSQLCapabilityBundleStore.test.ts +24 -5
- package/src/createPgStoreConfig.ts +2 -0
- package/src/index.ts +1 -0
- package/src/migrations/__tests__/menu_items_add_mcp_app_type.test.ts +30 -0
- package/src/migrations/menu_items_add_mcp_app_type.ts +29 -0
- package/src/stores/MenuStore.ts +2 -0
- package/src/stores/PostgreSQLCapabilityBundleStore.ts +32 -15
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@axiom-lattice/pg-stores",
|
|
3
|
-
"version": "3.1.
|
|
3
|
+
"version": "3.1.18",
|
|
4
4
|
"description": "PG stores implementation for Axiom Lattice framework",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/index.mjs",
|
|
@@ -25,8 +25,8 @@
|
|
|
25
25
|
"@langchain/core": "1.1.30",
|
|
26
26
|
"pg": "^8.16.3",
|
|
27
27
|
"uuid": "^9.0.1",
|
|
28
|
-
"@axiom-lattice/core": "4.
|
|
29
|
-
"@axiom-lattice/protocols": "4.
|
|
28
|
+
"@axiom-lattice/core": "4.3.0",
|
|
29
|
+
"@axiom-lattice/protocols": "4.4.0"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
32
|
"@types/jest": "^29.5.14",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ChannelIdentityMappingStore } from "../stores/ChannelIdentityMappingStore";
|
|
2
2
|
|
|
3
3
|
const mockQuery = jest.fn();
|
|
4
|
+
const mockMigrate = jest.fn();
|
|
4
5
|
|
|
5
6
|
jest.mock("pg", () => ({
|
|
6
7
|
Pool: jest.fn().mockImplementation(() => ({
|
|
@@ -8,9 +9,17 @@ jest.mock("pg", () => ({
|
|
|
8
9
|
})),
|
|
9
10
|
}));
|
|
10
11
|
|
|
12
|
+
jest.mock("../migrations/migration", () => ({
|
|
13
|
+
MigrationManager: jest.fn().mockImplementation(() => ({
|
|
14
|
+
register: jest.fn(),
|
|
15
|
+
migrate: mockMigrate,
|
|
16
|
+
})),
|
|
17
|
+
}));
|
|
18
|
+
|
|
11
19
|
describe("ChannelIdentityMappingStore", () => {
|
|
12
20
|
beforeEach(() => {
|
|
13
21
|
jest.clearAllMocks();
|
|
22
|
+
mockMigrate.mockResolvedValue(undefined);
|
|
14
23
|
});
|
|
15
24
|
|
|
16
25
|
it("creates and retrieves a mapping by external subject key", async () => {
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, it } from "@jest/globals";
|
|
2
|
+
import { MenuStore } from "../stores/MenuStore";
|
|
3
|
+
|
|
4
|
+
const databaseUrl = process.env.TEST_DATABASE_URL;
|
|
5
|
+
const describeIfDatabase = databaseUrl ? describe : describe.skip;
|
|
6
|
+
|
|
7
|
+
describeIfDatabase("MenuStore mcpApp content type (live PG)", () => {
|
|
8
|
+
it("persists an mcpApp menu item after the constraint is broadened", async () => {
|
|
9
|
+
const store = new MenuStore({ poolConfig: databaseUrl as string });
|
|
10
|
+
await store.initialize();
|
|
11
|
+
const tenantId = `menu-mcpapp-it-${Date.now()}`;
|
|
12
|
+
let createdId: string | undefined;
|
|
13
|
+
try {
|
|
14
|
+
const item = await store.create({
|
|
15
|
+
tenantId,
|
|
16
|
+
menuTarget: "workspace",
|
|
17
|
+
name: "Monitor",
|
|
18
|
+
sortOrder: 0,
|
|
19
|
+
contentType: "mcpApp",
|
|
20
|
+
contentConfig: { kind: "mcp", serverKey: "srv", resourceUri: "ui://srv/a.html" },
|
|
21
|
+
});
|
|
22
|
+
createdId = item.id;
|
|
23
|
+
expect(item.contentType).toBe("mcpApp");
|
|
24
|
+
expect(item.contentConfig).toEqual({ kind: "mcp", serverKey: "srv", resourceUri: "ui://srv/a.html" });
|
|
25
|
+
} finally {
|
|
26
|
+
if (createdId) {
|
|
27
|
+
try {
|
|
28
|
+
await store.delete(createdId);
|
|
29
|
+
} catch {
|
|
30
|
+
// best-effort cleanup
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
await store.dispose();
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, expect, it, jest } from "@jest/globals";
|
|
2
|
+
|
|
3
|
+
const registeredMigrationNames: string[] = [];
|
|
4
|
+
|
|
5
|
+
jest.mock("@axiom-lattice/core", () => ({}));
|
|
6
|
+
|
|
7
|
+
jest.mock("pg", () => ({
|
|
8
|
+
Pool: class Pool {},
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
jest.mock("../migrations/migration", () => ({
|
|
12
|
+
MigrationManager: class MigrationManager {
|
|
13
|
+
register(migration: { name: string }): void {
|
|
14
|
+
registeredMigrationNames.push(migration.name);
|
|
15
|
+
}
|
|
16
|
+
async migrate(): Promise<void> {
|
|
17
|
+
throw new Error("stop after migrations");
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
import { createPgStoreConfig } from "../createPgStoreConfig";
|
|
23
|
+
import { addMcpAppContentType } from "../migrations/menu_items_add_mcp_app_type";
|
|
24
|
+
|
|
25
|
+
describe("menu mcpApp content-type migration", () => {
|
|
26
|
+
it("has a free version and is centrally registered exactly once", async () => {
|
|
27
|
+
expect(addMcpAppContentType.version).toBe(181);
|
|
28
|
+
expect(addMcpAppContentType.name).toBe("add_mcp_app_content_type_to_menu_items");
|
|
29
|
+
|
|
30
|
+
registeredMigrationNames.length = 0;
|
|
31
|
+
await expect(createPgStoreConfig("postgres://unused")).rejects.toThrow("stop after migrations");
|
|
32
|
+
|
|
33
|
+
expect(registeredMigrationNames).toContain(addMcpAppContentType.name);
|
|
34
|
+
expect(
|
|
35
|
+
registeredMigrationNames.filter((n) => n === addMcpAppContentType.name)
|
|
36
|
+
).toHaveLength(1);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -58,7 +58,7 @@ describe("PostgreSQLCapabilityBundleStore", () => {
|
|
|
58
58
|
await expect(store.listByTenant("tenant-1")).resolves.toHaveLength(1);
|
|
59
59
|
await expect(store.getById("tenant-1", row.id)).resolves.toMatchObject({ id: row.id });
|
|
60
60
|
await expect(store.getManyByIds("tenant-1", [row.id])).resolves.toHaveLength(1);
|
|
61
|
-
const updated = await store.update("tenant-1", row.id, { name: "Updated" });
|
|
61
|
+
const updated = await store.update("tenant-1", row.id, { name: "Updated", expectedUpdatedAt: now.toISOString() });
|
|
62
62
|
expect(updated).toMatchObject({ id: row.id, createdAt: now.toISOString(), name: "Updated" });
|
|
63
63
|
expect("delete" in store).toBe(false);
|
|
64
64
|
|
|
@@ -73,17 +73,17 @@ describe("PostgreSQLCapabilityBundleStore", () => {
|
|
|
73
73
|
})).rejects.toThrow("Capability bundle key already exists for tenant");
|
|
74
74
|
});
|
|
75
75
|
|
|
76
|
-
it("normalizes PostgreSQL unique violations raised while updating a
|
|
76
|
+
it("normalizes PostgreSQL unique violations raised while updating a bundle", async () => {
|
|
77
77
|
mockQuery.mockRejectedValueOnce(Object.assign(new Error("duplicate"), { code: "23505" }));
|
|
78
78
|
const store = new PostgreSQLCapabilityBundleStore({ pool });
|
|
79
79
|
|
|
80
|
-
await expect(store.update("tenant-1", row.id, {
|
|
80
|
+
await expect(store.update("tenant-1", row.id, { name: "Renamed", expectedUpdatedAt: now.toISOString() })).rejects.toThrow(
|
|
81
81
|
"Capability bundle key already exists for tenant",
|
|
82
82
|
);
|
|
83
83
|
});
|
|
84
84
|
|
|
85
85
|
it("uses exact updated_at CAS and distinguishes a stale update", async () => {
|
|
86
|
-
mockQuery.mockResolvedValueOnce({ rows: [] });
|
|
86
|
+
mockQuery.mockResolvedValueOnce({ rows: [] }).mockResolvedValueOnce({ rows: [{ id: row.id }] });
|
|
87
87
|
const store = new PostgreSQLCapabilityBundleStore({ pool });
|
|
88
88
|
await expect(store.update("tenant-1", row.id, { name: "Stale", expectedUpdatedAt: "2026-08-27T12:00:00.000Z" }))
|
|
89
89
|
.resolves.toEqual({ status: "conflict" });
|
|
@@ -94,7 +94,10 @@ describe("PostgreSQLCapabilityBundleStore", () => {
|
|
|
94
94
|
});
|
|
95
95
|
|
|
96
96
|
it("checks CAS tokens for no-op updates", async () => {
|
|
97
|
-
mockQuery
|
|
97
|
+
mockQuery
|
|
98
|
+
.mockResolvedValueOnce({ rows: [] })
|
|
99
|
+
.mockResolvedValueOnce({ rows: [{ id: row.id }] })
|
|
100
|
+
.mockResolvedValueOnce({ rows: [row] });
|
|
98
101
|
const store = new PostgreSQLCapabilityBundleStore({ pool });
|
|
99
102
|
|
|
100
103
|
await expect(store.update("tenant-1", row.id, { expectedUpdatedAt: "stale" }))
|
|
@@ -104,6 +107,22 @@ describe("PostgreSQLCapabilityBundleStore", () => {
|
|
|
104
107
|
expect(mockQuery.mock.calls[0][0]).toContain("updated_at::text");
|
|
105
108
|
});
|
|
106
109
|
|
|
110
|
+
it("refuses an update that omits the required expected revision", async () => {
|
|
111
|
+
mockQuery.mockResolvedValueOnce({ rows: [{ id: row.id }] });
|
|
112
|
+
const store = new PostgreSQLCapabilityBundleStore({ pool });
|
|
113
|
+
|
|
114
|
+
await expect(store.update("tenant-1", row.id, { name: "Unsaved" } as never))
|
|
115
|
+
.resolves.toEqual({ status: "conflict" });
|
|
116
|
+
expect(mockQuery.mock.calls[0][0]).toContain("SELECT id FROM lattice_capability_bundles");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("returns null for a missing bundle when the expected revision is omitted", async () => {
|
|
120
|
+
mockQuery.mockResolvedValueOnce({ rows: [] });
|
|
121
|
+
const store = new PostgreSQLCapabilityBundleStore({ pool });
|
|
122
|
+
|
|
123
|
+
await expect(store.update("tenant-1", "missing", { name: "Unsaved" } as never)).resolves.toBeNull();
|
|
124
|
+
});
|
|
125
|
+
|
|
107
126
|
it.each([
|
|
108
127
|
[[], [], "not_found"],
|
|
109
128
|
[[{ exists: 1 }], [], "in_use"],
|
|
@@ -74,6 +74,7 @@ import { addShareOpenGrants } from "./migrations/add_share_open_grants";
|
|
|
74
74
|
import { taskMigrations } from "./migrations/task_migration";
|
|
75
75
|
import { createMenuItemsTable } from "./migrations/menu_items_migration";
|
|
76
76
|
import { addFileContentType } from "./migrations/menu_items_add_file_type";
|
|
77
|
+
import { addMcpAppContentType } from "./migrations/menu_items_add_mcp_app_type";
|
|
77
78
|
import { createSharedResourcesTable } from "./migrations/shared_resources_migration";
|
|
78
79
|
import { createCollectionsTable } from "./migrations/collection_migrations";
|
|
79
80
|
import {
|
|
@@ -148,6 +149,7 @@ export async function createPgStoreConfig(connectionString: string) {
|
|
|
148
149
|
mm.register(createMenuItemsTable); // v134
|
|
149
150
|
mm.register(createSharedResourcesTable); // v135
|
|
150
151
|
mm.register(addFileContentType); // v136
|
|
152
|
+
mm.register(addMcpAppContentType); // v181
|
|
151
153
|
mm.register(createCollectionsTable); // v137
|
|
152
154
|
mm.register(createTaskWorkItemsMigration); // v138
|
|
153
155
|
mm.register(addWorkItemProjectFieldsMigration); // v140
|
package/src/index.ts
CHANGED
|
@@ -67,6 +67,7 @@ export * from "./stores/PostgreSQLEvalStore";
|
|
|
67
67
|
export * from "./migrations/eval_migrations";
|
|
68
68
|
export * from "./migrations/menu_items_migration";
|
|
69
69
|
export * from "./migrations/menu_items_add_file_type";
|
|
70
|
+
export * from "./migrations/menu_items_add_mcp_app_type";
|
|
70
71
|
export * from "./migrations/collection_migrations";
|
|
71
72
|
export * from "./stores/MenuStore";
|
|
72
73
|
export * from "./stores/SharedResourceStore";
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { PoolClient } from "pg";
|
|
2
|
+
import { addMcpAppContentType } from "../menu_items_add_mcp_app_type";
|
|
3
|
+
|
|
4
|
+
function recordingClient(): { client: PoolClient; queries: string[] } {
|
|
5
|
+
const queries: string[] = [];
|
|
6
|
+
const client = {
|
|
7
|
+
query: async (sql: string) => {
|
|
8
|
+
queries.push(sql);
|
|
9
|
+
},
|
|
10
|
+
} as unknown as PoolClient;
|
|
11
|
+
return { client, queries };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("addMcpAppContentType migration", () => {
|
|
15
|
+
it("broadens the constraint to include 'mcpApp'", async () => {
|
|
16
|
+
const { client, queries } = recordingClient();
|
|
17
|
+
await addMcpAppContentType.up(client);
|
|
18
|
+
const sql = queries.join("\n");
|
|
19
|
+
expect(sql).toContain("lattice_menu_items_content_type_check");
|
|
20
|
+
expect(sql).toContain("'mcpApp'");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("down re-adds the previous set without 'mcpApp'", async () => {
|
|
24
|
+
const { client, queries } = recordingClient();
|
|
25
|
+
await addMcpAppContentType.down!(client);
|
|
26
|
+
const sql = queries.join("\n");
|
|
27
|
+
expect(sql).toContain("'file'");
|
|
28
|
+
expect(sql).not.toContain("'mcpApp'");
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { PoolClient } from "pg";
|
|
2
|
+
import { Migration } from "./migration";
|
|
3
|
+
|
|
4
|
+
export const addMcpAppContentType: Migration = {
|
|
5
|
+
version: 181,
|
|
6
|
+
name: "add_mcp_app_content_type_to_menu_items",
|
|
7
|
+
up: async (client: PoolClient) => {
|
|
8
|
+
await client.query(`
|
|
9
|
+
ALTER TABLE lattice_menu_items
|
|
10
|
+
DROP CONSTRAINT IF EXISTS lattice_menu_items_content_type_check
|
|
11
|
+
`);
|
|
12
|
+
await client.query(`
|
|
13
|
+
ALTER TABLE lattice_menu_items
|
|
14
|
+
ADD CONSTRAINT lattice_menu_items_content_type_check
|
|
15
|
+
CHECK (content_type IN ('agent', 'html', 'custom', 'file', 'mcpApp'))
|
|
16
|
+
`);
|
|
17
|
+
},
|
|
18
|
+
down: async (client: PoolClient) => {
|
|
19
|
+
await client.query(`
|
|
20
|
+
ALTER TABLE lattice_menu_items
|
|
21
|
+
DROP CONSTRAINT IF EXISTS lattice_menu_items_content_type_check
|
|
22
|
+
`);
|
|
23
|
+
await client.query(`
|
|
24
|
+
ALTER TABLE lattice_menu_items
|
|
25
|
+
ADD CONSTRAINT lattice_menu_items_content_type_check
|
|
26
|
+
CHECK (content_type IN ('agent', 'html', 'custom', 'file'))
|
|
27
|
+
`);
|
|
28
|
+
},
|
|
29
|
+
};
|
package/src/stores/MenuStore.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
import { MigrationManager } from "../migrations/migration";
|
|
11
11
|
import { createMenuItemsTable } from "../migrations/menu_items_migration";
|
|
12
12
|
import { addFileContentType } from "../migrations/menu_items_add_file_type";
|
|
13
|
+
import { addMcpAppContentType } from "../migrations/menu_items_add_mcp_app_type";
|
|
13
14
|
|
|
14
15
|
export interface MenuStoreOptions {
|
|
15
16
|
pool?: Pool;
|
|
@@ -58,6 +59,7 @@ export class MenuStore implements MenuRegistry {
|
|
|
58
59
|
this.migrationManager = new MigrationManager(this.pool);
|
|
59
60
|
this.migrationManager.register(createMenuItemsTable);
|
|
60
61
|
this.migrationManager.register(addFileContentType);
|
|
62
|
+
this.migrationManager.register(addMcpAppContentType);
|
|
61
63
|
|
|
62
64
|
if (options.autoMigrate !== false) {
|
|
63
65
|
this.initialize().catch((error) => {
|
|
@@ -201,6 +201,10 @@ export class PostgreSQLCapabilityBundleStore implements CapabilityBundleStore {
|
|
|
201
201
|
input: InternalUpdateCapabilityBundleInput,
|
|
202
202
|
): Promise<CapabilityBundle | { status: "conflict" } | null> {
|
|
203
203
|
await this.ready();
|
|
204
|
+
const expectedUpdatedAt = input.expectedUpdatedAt;
|
|
205
|
+
if (typeof expectedUpdatedAt !== "string" || expectedUpdatedAt.trim().length === 0) {
|
|
206
|
+
return this.resolveCasMiss(tenantId, id);
|
|
207
|
+
}
|
|
204
208
|
const fields: string[] = [];
|
|
205
209
|
const values: unknown[] = [];
|
|
206
210
|
const add = (field: string, value: unknown): void => {
|
|
@@ -215,39 +219,52 @@ export class PostgreSQLCapabilityBundleStore implements CapabilityBundleStore {
|
|
|
215
219
|
add("capabilities", JSON.stringify(input.capabilities));
|
|
216
220
|
}
|
|
217
221
|
if (fields.length === 0) {
|
|
218
|
-
const expectedUpdatedAt = input.expectedUpdatedAt;
|
|
219
222
|
const result = await this.pool.query<Row>(
|
|
220
223
|
`SELECT id, tenant_id, bundle_key, name, description, capabilities, created_at, updated_at::text AS updated_at
|
|
221
224
|
FROM lattice_capability_bundles
|
|
222
|
-
WHERE tenant_id = $1 AND id = $2
|
|
223
|
-
|
|
225
|
+
WHERE tenant_id = $1 AND id = $2 AND updated_at::text = $3`,
|
|
226
|
+
[tenantId, id, expectedUpdatedAt],
|
|
224
227
|
);
|
|
225
228
|
if (result.rows[0]) return map(result.rows[0]);
|
|
226
|
-
return
|
|
229
|
+
return this.resolveCasMiss(tenantId, id);
|
|
227
230
|
}
|
|
228
231
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
const idParam = tenantParam + 1;
|
|
234
|
-
const revisionPredicate = expectedUpdatedAt === undefined ? "" : ` AND updated_at::text = $${values.length}`;
|
|
232
|
+
values.push(tenantId, id, expectedUpdatedAt);
|
|
233
|
+
const tenantParam = values.length - 2;
|
|
234
|
+
const idParam = tenantParam + 1;
|
|
235
|
+
const revisionParam = values.length;
|
|
235
236
|
try {
|
|
236
237
|
const result = await this.pool.query<Row>(
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
238
|
+
`UPDATE lattice_capability_bundles
|
|
239
|
+
SET ${fields.join(", ")}, updated_at = GREATEST(updated_at + interval '1 microsecond', clock_timestamp())
|
|
240
|
+
WHERE tenant_id = $${tenantParam} AND id = $${idParam} AND updated_at::text = $${revisionParam}
|
|
240
241
|
RETURNING id, tenant_id, bundle_key, name, description, capabilities, created_at, updated_at::text AS updated_at`,
|
|
241
242
|
values,
|
|
242
243
|
);
|
|
243
|
-
|
|
244
|
-
|
|
244
|
+
if (result.rows[0]) return map(result.rows[0]);
|
|
245
|
+
return this.resolveCasMiss(tenantId, id);
|
|
245
246
|
} catch (error) {
|
|
246
247
|
if (isDuplicate(error)) throw new Error(duplicateMessage);
|
|
247
248
|
throw error;
|
|
248
249
|
}
|
|
249
250
|
}
|
|
250
251
|
|
|
252
|
+
/**
|
|
253
|
+
* Distinguishes a stale-revision CAS miss from a missing bundle.
|
|
254
|
+
*
|
|
255
|
+
* @returns `null` when no such bundle exists, otherwise a conflict marker.
|
|
256
|
+
*/
|
|
257
|
+
private async resolveCasMiss(
|
|
258
|
+
tenantId: string,
|
|
259
|
+
id: string,
|
|
260
|
+
): Promise<{ status: "conflict" } | null> {
|
|
261
|
+
const result = await this.pool.query<{ id: string }>(
|
|
262
|
+
"SELECT id FROM lattice_capability_bundles WHERE tenant_id = $1 AND id = $2",
|
|
263
|
+
[tenantId, id],
|
|
264
|
+
);
|
|
265
|
+
return result.rows[0] ? { status: "conflict" } : null;
|
|
266
|
+
}
|
|
267
|
+
|
|
251
268
|
async deleteIfUnreferenced(tenantId: string, id: string): Promise<CapabilityBundleDeleteResult> {
|
|
252
269
|
await this.ready();
|
|
253
270
|
const client = await this.pool.connect();
|