@axiom-lattice/pg-stores 3.0.0 → 3.0.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axiom-lattice/pg-stores",
3
- "version": "3.0.0",
3
+ "version": "3.0.2",
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.0.0",
29
- "@axiom-lattice/protocols": "4.0.0"
28
+ "@axiom-lattice/core": "4.1.0",
29
+ "@axiom-lattice/protocols": "4.0.1"
30
30
  },
31
31
  "devDependencies": {
32
32
  "@types/jest": "^29.5.14",
@@ -0,0 +1,77 @@
1
+ import { beforeEach, describe, expect, it } from "@jest/globals";
2
+
3
+ const recordedMigrationNames: string[] = [];
4
+ const clientSql: string[] = [];
5
+ let migrationError: Error | null = null;
6
+
7
+ jest.mock("pg", () => {
8
+ const client = {
9
+ query: async (sql: string, params?: unknown[]) => {
10
+ clientSql.push(sql);
11
+ if (migrationError && sql.includes("CREATE TABLE IF NOT EXISTS lattice_agent_web_apps")) {
12
+ throw migrationError;
13
+ }
14
+ if (sql.includes("information_schema.tables")) return { rows: [{ exists: true }] };
15
+ if (sql.includes("pg_index")) return { rows: [] };
16
+ if (sql.includes("INSERT INTO lattice_schema_migrations")) {
17
+ recordedMigrationNames.push(params?.[0] as string);
18
+ }
19
+ return { rows: [] };
20
+ },
21
+ release: () => undefined,
22
+ };
23
+ const pool = {
24
+ connect: async () => client,
25
+ query: async () => ({ rows: [] }),
26
+ end: async () => undefined,
27
+ };
28
+ return { Pool: function Pool() { return pool; } };
29
+ });
30
+
31
+ import { PostgreSQLAgentWebAppStore } from "../stores/PostgreSQLAgentWebAppStore";
32
+
33
+ describe("PostgreSQLAgentWebAppStore standalone migrations", () => {
34
+ beforeEach(() => {
35
+ recordedMigrationNames.length = 0;
36
+ clientSql.length = 0;
37
+ migrationError = null;
38
+ });
39
+
40
+ it("registers and applies the agent web apps table migration", async () => {
41
+ const store = new PostgreSQLAgentWebAppStore({
42
+ poolConfig: "postgres://unused",
43
+ autoMigrate: false,
44
+ });
45
+
46
+ await store.initialize();
47
+
48
+ expect(recordedMigrationNames).toEqual(["create_agent_web_apps_table"]);
49
+ expect(clientSql.some((sql) => sql.includes("CREATE TABLE IF NOT EXISTS lattice_agent_web_apps"))).toBe(true);
50
+ expect(clientSql.some((sql) => sql.includes("status VARCHAR(32) NOT NULL DEFAULT 'draft'"))).toBe(true);
51
+ expect(clientSql.some((sql) => sql.includes(
52
+ "CONSTRAINT chk_lattice_agent_web_apps_status CHECK (status IN ('draft', 'active', 'disabled'))",
53
+ ))).toBe(true);
54
+ expect(clientSql.some((sql) => sql.includes(
55
+ "created_at TIMESTAMPTZ NOT NULL DEFAULT date_trunc('milliseconds', NOW())",
56
+ ))).toBe(true);
57
+ expect(clientSql.some((sql) => sql.includes(
58
+ "updated_at TIMESTAMPTZ NOT NULL DEFAULT date_trunc('milliseconds', NOW())",
59
+ ))).toBe(true);
60
+ expect(clientSql.some((sql) => sql.includes("idx_lattice_agent_web_apps_tenant_assistant"))).toBe(true);
61
+
62
+ await store.dispose();
63
+ });
64
+
65
+ it("surfaces an eager auto-migration rejection through initialize without retrying", async () => {
66
+ migrationError = new Error("migration failed");
67
+ const store = new PostgreSQLAgentWebAppStore({ poolConfig: "postgres://unused" });
68
+
69
+ await expect(store.initialize()).rejects.toThrow("migration failed");
70
+ await expect(store.list("tenant-1")).rejects.toThrow("migration failed");
71
+
72
+ expect(clientSql.filter((sql) =>
73
+ sql.includes("CREATE TABLE IF NOT EXISTS lattice_agent_web_apps")
74
+ )).toHaveLength(1);
75
+ await store.dispose();
76
+ });
77
+ });
@@ -0,0 +1,290 @@
1
+ import { beforeEach, describe, expect, it, jest } from "@jest/globals";
2
+ import type {
3
+ AgentWebAppStorePatch,
4
+ CreateAgentWebAppInput,
5
+ UpdateAgentWebAppInput,
6
+ } from "@axiom-lattice/protocols";
7
+ import type { Pool } from "pg";
8
+
9
+ import { PostgreSQLAgentWebAppStore } from "../stores/PostgreSQLAgentWebAppStore";
10
+
11
+ const mockQuery = jest.fn();
12
+ const now = new Date("2026-08-24T12:00:00.000Z");
13
+
14
+ const input: CreateAgentWebAppInput = {
15
+ assistantId: "assistant-1",
16
+ name: "Support",
17
+ description: "Customer support",
18
+ integration: { type: "react_sdk" },
19
+ scope: {
20
+ defaultProjectId: "project-1",
21
+ allowedProjectIds: ["project-1", "project-2"],
22
+ defaultModelKey: "model-1",
23
+ allowedModelKeys: ["model-1"],
24
+ },
25
+ features: {
26
+ projectSelector: true,
27
+ modelSelector: true,
28
+ threadManagement: true,
29
+ attachments: false,
30
+ hitl: true,
31
+ genUI: false,
32
+ },
33
+ appearance: { title: "Help", primaryColor: "#123456" },
34
+ };
35
+
36
+ const row = {
37
+ id: "webapp_123",
38
+ tenant_id: "tenant-1",
39
+ assistant_id: "assistant-1",
40
+ name: "Support",
41
+ description: "Customer support",
42
+ status: "draft",
43
+ integration: input.integration,
44
+ scope: input.scope,
45
+ features: input.features,
46
+ appearance: input.appearance,
47
+ created_at: now,
48
+ updated_at: now,
49
+ };
50
+
51
+ describe("PostgreSQLAgentWebAppStore", () => {
52
+ const pool = { query: mockQuery } as unknown as Pool;
53
+
54
+ beforeEach(() => {
55
+ jest.clearAllMocks();
56
+ });
57
+
58
+ it("creates a draft web app with a generated webapp id and JSONB values", async () => {
59
+ mockQuery.mockResolvedValueOnce({ rows: [row] });
60
+ const store = new PostgreSQLAgentWebAppStore({ pool });
61
+
62
+ const result = await store.create("tenant-1", input);
63
+
64
+ const [sql, params] = mockQuery.mock.calls[0] as [string, unknown[]];
65
+ expect(sql).toContain("INSERT INTO lattice_agent_web_apps");
66
+ expect(sql).toContain("status");
67
+ expect(params[0]).toEqual(expect.stringMatching(/^webapp_[0-9a-f]{32}$/));
68
+ expect(params).toEqual([
69
+ expect.stringMatching(/^webapp_[0-9a-f]{32}$/),
70
+ "tenant-1",
71
+ "assistant-1",
72
+ "Support",
73
+ "Customer support",
74
+ "draft",
75
+ JSON.stringify(input.integration),
76
+ JSON.stringify(input.scope),
77
+ JSON.stringify(input.features),
78
+ JSON.stringify(input.appearance),
79
+ ]);
80
+ expect(result).toEqual({
81
+ id: "webapp_123",
82
+ tenantId: "tenant-1",
83
+ assistantId: "assistant-1",
84
+ name: "Support",
85
+ description: "Customer support",
86
+ status: "draft",
87
+ integration: input.integration,
88
+ scope: input.scope,
89
+ features: input.features,
90
+ appearance: input.appearance,
91
+ createdAt: now,
92
+ updatedAt: now,
93
+ });
94
+ });
95
+
96
+ it("lists tenant web apps in deterministic newest-first order", async () => {
97
+ mockQuery.mockResolvedValueOnce({ rows: [row] });
98
+ const store = new PostgreSQLAgentWebAppStore({ pool });
99
+
100
+ await store.list("tenant-1");
101
+
102
+ expect(mockQuery).toHaveBeenCalledWith(
103
+ expect.stringMatching(/WHERE tenant_id = \$1\s+ORDER BY created_at DESC, id DESC/),
104
+ ["tenant-1"],
105
+ );
106
+ });
107
+
108
+ it("optionally filters list by assistant", async () => {
109
+ mockQuery.mockResolvedValueOnce({ rows: [row] });
110
+ const store = new PostgreSQLAgentWebAppStore({ pool });
111
+
112
+ await store.list("tenant-1", "assistant-1");
113
+
114
+ expect(mockQuery).toHaveBeenCalledWith(
115
+ expect.stringMatching(/WHERE tenant_id = \$1 AND assistant_id = \$2/),
116
+ ["tenant-1", "assistant-1"],
117
+ );
118
+ });
119
+
120
+ it("gets by tenant and id and returns null when absent", async () => {
121
+ mockQuery.mockResolvedValueOnce({ rows: [] });
122
+ const store = new PostgreSQLAgentWebAppStore({ pool });
123
+
124
+ await expect(store.getById("tenant-1", "webapp_missing")).resolves.toBeNull();
125
+ expect(mockQuery).toHaveBeenCalledWith(
126
+ expect.stringMatching(/WHERE tenant_id = \$1 AND id = \$2/),
127
+ ["tenant-1", "webapp_missing"],
128
+ );
129
+ });
130
+
131
+ it("finds a globally identifiable publication by primary key", async () => {
132
+ mockQuery.mockResolvedValueOnce({ rows: [row] });
133
+ const store = new PostgreSQLAgentWebAppStore({ pool });
134
+
135
+ await expect(store.findById("webapp_123")).resolves.toMatchObject({
136
+ id: "webapp_123",
137
+ tenantId: "tenant-1",
138
+ });
139
+ expect(mockQuery).toHaveBeenCalledWith(
140
+ expect.stringMatching(/WHERE id = \$1/),
141
+ ["webapp_123"],
142
+ );
143
+ });
144
+
145
+ it("updates only known mutable fields and scopes by tenant and id", async () => {
146
+ mockQuery.mockResolvedValueOnce({ rows: [{ ...row, name: "Renamed", description: null }] });
147
+ const store = new PostgreSQLAgentWebAppStore({ pool });
148
+ const patch = {
149
+ name: "Renamed",
150
+ description: undefined,
151
+ assistantId: "assistant-evil",
152
+ } as unknown as UpdateAgentWebAppInput;
153
+
154
+ const result = await store.update("tenant-1", "webapp_123", patch);
155
+
156
+ const [sql, params] = mockQuery.mock.calls[0] as [string, unknown[]];
157
+ expect(sql).toContain("name = $1");
158
+ expect(sql).toContain("description = $2");
159
+ expect(sql).toContain("updated_at = GREATEST(");
160
+ expect(sql).toContain("WHERE tenant_id = $3 AND id = $4");
161
+ expect(sql).not.toContain("assistant_id =");
162
+ expect(params).toEqual(["Renamed", null, "tenant-1", "webapp_123"]);
163
+ expect(result?.description).toBeUndefined();
164
+ });
165
+
166
+ it("updates a defined status and returns the persisted row", async () => {
167
+ mockQuery.mockResolvedValueOnce({ rows: [{ ...row, status: "active" }] });
168
+ const store = new PostgreSQLAgentWebAppStore({ pool });
169
+ const patch: AgentWebAppStorePatch = { status: "active" };
170
+
171
+ const result = await store.update("tenant-1", "webapp_123", patch);
172
+
173
+ expect(mockQuery).toHaveBeenCalledWith(
174
+ expect.stringMatching(
175
+ /SET status = \$1, updated_at = GREATEST\(\s*date_trunc\('milliseconds', clock_timestamp\(\)\),\s*updated_at \+ interval '1 millisecond'\s*\)\s+WHERE tenant_id = \$2 AND id = \$3/,
176
+ ),
177
+ ["active", "tenant-1", "webapp_123"],
178
+ );
179
+ expect(result?.status).toBe("active");
180
+ });
181
+
182
+ it("monotonically advances a same-millisecond version so a stale writer conflicts", async () => {
183
+ const advanced = new Date(now.getTime() + 1);
184
+ mockQuery
185
+ .mockResolvedValueOnce({ rowCount: 1, rows: [{ ...row, status: "active", updated_at: advanced }] })
186
+ .mockResolvedValueOnce({ rowCount: 0, rows: [] });
187
+ const store = new PostgreSQLAgentWebAppStore({ pool });
188
+
189
+ await expect(store.update(
190
+ "tenant-1",
191
+ "webapp_123",
192
+ { status: "active" },
193
+ { expectedUpdatedAt: now },
194
+ )).resolves.toMatchObject({ status: "active", updatedAt: advanced });
195
+ await expect(store.update(
196
+ "tenant-1",
197
+ "webapp_123",
198
+ { status: "disabled" },
199
+ { expectedUpdatedAt: now },
200
+ )).resolves.toBeNull();
201
+
202
+ const monotonicCasSql = expect.stringMatching(
203
+ /SET status = \$1, updated_at = GREATEST\(\s*date_trunc\('milliseconds', clock_timestamp\(\)\),\s*updated_at \+ interval '1 millisecond'\s*\)\s+WHERE tenant_id = \$2 AND id = \$3 AND date_trunc\('milliseconds', updated_at\) = \$4/,
204
+ );
205
+ expect(mockQuery).toHaveBeenNthCalledWith(
206
+ 1,
207
+ monotonicCasSql,
208
+ ["active", "tenant-1", "webapp_123", now],
209
+ );
210
+ expect(mockQuery).toHaveBeenNthCalledWith(
211
+ 2,
212
+ monotonicCasSql,
213
+ ["disabled", "tenant-1", "webapp_123", now],
214
+ );
215
+ });
216
+
217
+ it("serializes JSONB update fields", async () => {
218
+ mockQuery.mockResolvedValueOnce({ rows: [row] });
219
+ const store = new PostgreSQLAgentWebAppStore({ pool });
220
+ const patch: UpdateAgentWebAppInput = {
221
+ scope: input.scope,
222
+ features: input.features,
223
+ appearance: input.appearance,
224
+ };
225
+
226
+ await store.update("tenant-1", "webapp_123", patch);
227
+
228
+ const [, params] = mockQuery.mock.calls[0] as [string, unknown[]];
229
+ expect(params).toEqual([
230
+ JSON.stringify(input.scope),
231
+ JSON.stringify(input.features),
232
+ JSON.stringify(input.appearance),
233
+ "tenant-1",
234
+ "webapp_123",
235
+ ]);
236
+ });
237
+
238
+ it("ignores undefined non-description fields as a read-only no-op", async () => {
239
+ mockQuery.mockResolvedValueOnce({ rows: [row] });
240
+ const store = new PostgreSQLAgentWebAppStore({ pool });
241
+ const patch: AgentWebAppStorePatch = {
242
+ name: undefined,
243
+ scope: undefined,
244
+ features: undefined,
245
+ appearance: undefined,
246
+ status: undefined,
247
+ };
248
+
249
+ const result = await store.update("tenant-1", "webapp_123", patch);
250
+
251
+ expect(mockQuery).toHaveBeenCalledTimes(1);
252
+ expect(mockQuery).toHaveBeenCalledWith(
253
+ expect.stringMatching(/^SELECT[\s\S]+WHERE tenant_id = \$1 AND id = \$2$/),
254
+ ["tenant-1", "webapp_123"],
255
+ );
256
+ expect(result?.updatedAt).toBe(now);
257
+ });
258
+
259
+ it("clears description when it is explicitly present as undefined", async () => {
260
+ mockQuery.mockResolvedValueOnce({ rows: [{ ...row, description: null }] });
261
+ const store = new PostgreSQLAgentWebAppStore({ pool });
262
+
263
+ await store.update("tenant-1", "webapp_123", { description: undefined });
264
+
265
+ expect(mockQuery).toHaveBeenCalledWith(
266
+ expect.stringContaining("SET description = $1, updated_at = GREATEST("),
267
+ [null, "tenant-1", "webapp_123"],
268
+ );
269
+ });
270
+
271
+ it("deletes by tenant and id and reports whether a row was removed", async () => {
272
+ mockQuery.mockResolvedValueOnce({ rowCount: 1, rows: [] });
273
+ const store = new PostgreSQLAgentWebAppStore({ pool });
274
+
275
+ await expect(store.delete("tenant-1", "webapp_123")).resolves.toBe(true);
276
+ expect(mockQuery).toHaveBeenCalledWith(
277
+ expect.stringMatching(/DELETE FROM lattice_agent_web_apps\s+WHERE tenant_id = \$1 AND id = \$2/),
278
+ ["tenant-1", "webapp_123"],
279
+ );
280
+ });
281
+
282
+ it("rejects rows that violate the stored JSON contract", async () => {
283
+ mockQuery.mockResolvedValueOnce({ rows: [{ ...row, integration: { type: "iframe" } }] });
284
+ const store = new PostgreSQLAgentWebAppStore({ pool });
285
+
286
+ await expect(store.getById("tenant-1", "webapp_123")).rejects.toThrow(
287
+ "Invalid agent web app row",
288
+ );
289
+ });
290
+ });
@@ -17,6 +17,7 @@ import { ThreadMessageQueueStore } from "./stores/ThreadMessageQueueStore";
17
17
  import { ChannelBindingStore } from "./stores/ChannelBindingStore";
18
18
  import { PostgreSQLChannelInstallationStore } from "./stores/PostgreSQLChannelInstallationStore";
19
19
  import { PostgreSQLA2AApiKeyStore } from "./stores/PostgreSQLA2AApiKeyStore";
20
+ import { PostgreSQLAgentWebAppStore } from "./stores/PostgreSQLAgentWebAppStore";
20
21
  import { PostgreSQLScheduleStorage } from "./stores/PostgreSQLScheduleStorage";
21
22
  import { PostgreSQLTaskStore } from "./stores/PostgreSQLTaskStore";
22
23
  import { PostgreSQLTaskWorkItemStore } from "./stores/PostgreSQLTaskWorkItemStore";
@@ -71,6 +72,7 @@ import { addFileContentType } from "./migrations/menu_items_add_file_type";
71
72
  import { createSharedResourcesTable } from "./migrations/shared_resources_migration";
72
73
  import { createCollectionsTable } from "./migrations/collection_migrations";
73
74
  import { createTaskWorkItemsMigration, addWorkItemProjectFieldsMigration, addTaskWorkItemEventKeyMigration } from "./migrations/task_work_items_migration";
75
+ import { createAgentWebAppsTable } from "./migrations/agent_web_apps_migration";
74
76
 
75
77
  export async function createPgStoreConfig(connectionString: string) {
76
78
  const pool = new Pool({ connectionString });
@@ -131,6 +133,7 @@ export async function createPgStoreConfig(connectionString: string) {
131
133
  mm.register(addProjectKindColumn); // v165
132
134
  mm.register(addA2AKeyAssistantIds); // v167
133
135
  mm.register(addTaskWorkItemEventKeyMigration); // v168
136
+ mm.register(createAgentWebAppsTable); // v169
134
137
 
135
138
  await mm.migrate();
136
139
 
@@ -163,6 +166,7 @@ export async function createPgStoreConfig(connectionString: string) {
163
166
  task: new PostgreSQLTaskStore(opts),
164
167
  taskWorkItem: taskWorkItemStore,
165
168
  a2aApiKey: new PostgreSQLA2AApiKeyStore(opts),
169
+ agentWebApp: new PostgreSQLAgentWebAppStore(opts),
166
170
  schedule: new PostgreSQLScheduleStorage(opts),
167
171
  menu: new MenuStore(opts),
168
172
  sharedResource: new PostgresSharedResourceStore(opts),
package/src/index.ts CHANGED
@@ -50,12 +50,14 @@ export * from "./migrations/channel_installations_alter_migration";
50
50
  export * from "./migrations/channel_bindings_migration";
51
51
  export * from "./migrations/shared_resources_migration";
52
52
  export * from "./migrations/a2a_api_key_migration";
53
+ export * from "./migrations/agent_web_apps_migration";
53
54
  export * from "./migrations/workflow_tracking_migrations";
54
55
  export * from "./stores/ChannelBindingStore";
55
56
  export * from "./stores/ThreadMessageQueueStore";
56
57
  export * from "./stores/ChannelIdentityMappingStore";
57
58
  export * from "./stores/PostgreSQLChannelInstallationStore";
58
59
  export * from "./stores/PostgreSQLA2AApiKeyStore";
60
+ export * from "./stores/PostgreSQLAgentWebAppStore";
59
61
  export * from "./stores/PostgreSQLWorkflowTrackingStore";
60
62
  export * from "./stores/PostgreSQLEvalStore";
61
63
  export * from "./migrations/eval_migrations";
@@ -181,4 +183,12 @@ export type {
181
183
  ShareVisibility,
182
184
  CreateShareRequest,
183
185
  ShareResult,
186
+ AgentWebAppStore,
187
+ AgentWebApp,
188
+ AgentWebAppStatus,
189
+ AgentWebAppScope,
190
+ AgentWebAppFeatures,
191
+ AgentWebAppAppearance,
192
+ CreateAgentWebAppInput,
193
+ UpdateAgentWebAppInput,
184
194
  } from "@axiom-lattice/protocols";
@@ -0,0 +1,37 @@
1
+ import type { PoolClient } from "pg";
2
+
3
+ import type { Migration } from "./migration";
4
+
5
+ export const createAgentWebAppsTable: Migration = {
6
+ version: 169,
7
+ name: "create_agent_web_apps_table",
8
+ up: async (client: PoolClient) => {
9
+ await client.query(`
10
+ CREATE TABLE IF NOT EXISTS lattice_agent_web_apps (
11
+ id VARCHAR(255) PRIMARY KEY,
12
+ tenant_id VARCHAR(255) NOT NULL,
13
+ assistant_id VARCHAR(255) NOT NULL,
14
+ name VARCHAR(255) NOT NULL,
15
+ description TEXT,
16
+ status VARCHAR(32) NOT NULL DEFAULT 'draft'
17
+ CONSTRAINT chk_lattice_agent_web_apps_status CHECK (status IN ('draft', 'active', 'disabled')),
18
+ integration JSONB NOT NULL,
19
+ scope JSONB NOT NULL,
20
+ features JSONB NOT NULL,
21
+ appearance JSONB NOT NULL DEFAULT '{}'::jsonb,
22
+ created_at TIMESTAMPTZ NOT NULL DEFAULT date_trunc('milliseconds', NOW()),
23
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT date_trunc('milliseconds', NOW())
24
+ )
25
+ `);
26
+ await client.query(`
27
+ CREATE INDEX IF NOT EXISTS idx_lattice_agent_web_apps_tenant_assistant
28
+ ON lattice_agent_web_apps(tenant_id, assistant_id)
29
+ `);
30
+ },
31
+ down: async (client: PoolClient) => {
32
+ await client.query(
33
+ "DROP INDEX IF EXISTS idx_lattice_agent_web_apps_tenant_assistant",
34
+ );
35
+ await client.query("DROP TABLE IF EXISTS lattice_agent_web_apps");
36
+ },
37
+ };