@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axiom-lattice/pg-stores",
3
- "version": "3.1.16",
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.2.16",
29
- "@axiom-lattice/protocols": "4.3.1"
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 key", async () => {
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, { key: "existing-key" })).rejects.toThrow(
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.mockResolvedValueOnce({ rows: [] }).mockResolvedValueOnce({ rows: [row] });
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
+ };
@@ -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${expectedUpdatedAt === undefined ? "" : " AND updated_at::text = $3"}`,
223
- expectedUpdatedAt === undefined ? [tenantId, id] : [tenantId, id, expectedUpdatedAt],
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 expectedUpdatedAt === undefined ? null : { status: "conflict" };
229
+ return this.resolveCasMiss(tenantId, id);
227
230
  }
228
231
 
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}`;
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
- `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}
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
- if (result.rows[0]) return map(result.rows[0]);
244
- return expectedUpdatedAt === undefined ? null : { status: "conflict" };
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();