@axiom-lattice/pg-stores 5.0.0 → 5.0.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.
@@ -0,0 +1,313 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { Pool } from "pg";
3
+
4
+ // The @axiom-lattice/core barrel cannot load under jest in this package (ESM-only
5
+ // transitive deps), so mock it with the real encryption module — node:crypto only.
6
+ jest.mock("@axiom-lattice/core", () => ({
7
+ ...jest.requireActual("../../../core/src/util/encryption"),
8
+ }));
9
+
10
+ import { decrypt } from "@axiom-lattice/core";
11
+ import { PostgreSQLModelProviderStore } from "../stores/PostgreSQLModelProviderStore";
12
+
13
+ const TEST_DATABASE_URL = process.env.TEST_DATABASE_URL;
14
+ const describeIfDatabase = TEST_DATABASE_URL ? describe : describe.skip;
15
+
16
+ describeIfDatabase("PostgreSQLModelProviderStore integration", () => {
17
+ let store: PostgreSQLModelProviderStore;
18
+ let pool: Pool;
19
+ const tenants = new Set<string>();
20
+
21
+ beforeAll(async () => {
22
+ store = new PostgreSQLModelProviderStore({
23
+ poolConfig: TEST_DATABASE_URL!,
24
+ autoMigrate: true,
25
+ });
26
+ await store.initialize();
27
+ pool = new Pool({ connectionString: TEST_DATABASE_URL });
28
+ });
29
+
30
+ afterEach(async () => {
31
+ if (tenants.size === 0) {
32
+ return;
33
+ }
34
+ const ids = [...tenants];
35
+ await pool.query(
36
+ "DELETE FROM lattice_model_provider_models WHERE tenant_id = ANY($1::text[])",
37
+ [ids],
38
+ );
39
+ await pool.query(
40
+ "DELETE FROM lattice_model_providers WHERE tenant_id = ANY($1::text[])",
41
+ [ids],
42
+ );
43
+ tenants.clear();
44
+ });
45
+
46
+ afterAll(async () => {
47
+ await pool.end();
48
+ await store.dispose();
49
+ });
50
+
51
+ function tenantId(): string {
52
+ const id = `model-provider-${randomUUID()}`;
53
+ tenants.add(id);
54
+ return id;
55
+ }
56
+
57
+ function providerData(index = 0) {
58
+ return {
59
+ name: `openai-${index}-${randomUUID()}`,
60
+ displayName: `OpenAI ${index}`,
61
+ protocol: "openai-compatible" as const,
62
+ apiStyle: "chat-completions" as const,
63
+ baseURL: "https://api.example.com/v1",
64
+ apiKey: "sk-secret-abcd",
65
+ enabled: true,
66
+ };
67
+ }
68
+
69
+ async function createProvider(tenant: string) {
70
+ const id = randomUUID();
71
+ const data = providerData();
72
+ const provider = await store.createProvider(tenant, id, data);
73
+ return { id, data, provider };
74
+ }
75
+
76
+ it("creates and reads a provider by id and by name", async () => {
77
+ const tenant = tenantId();
78
+ const { id, data, provider } = await createProvider(tenant);
79
+
80
+ expect(provider).toMatchObject({
81
+ id,
82
+ tenantId: tenant,
83
+ name: data.name,
84
+ displayName: data.displayName,
85
+ protocol: "openai-compatible",
86
+ apiStyle: "chat-completions",
87
+ llmProvider: "openai",
88
+ baseURL: data.baseURL,
89
+ enabled: true,
90
+ status: "unknown",
91
+ apiKey: data.apiKey,
92
+ apiKeyHint: "abcd",
93
+ });
94
+
95
+ const byId = await store.getProviderById(tenant, id);
96
+ expect(byId?.id).toBe(id);
97
+ expect(byId?.apiKey).toBe(data.apiKey);
98
+
99
+ const byName = await store.getProviderByName(tenant, data.name);
100
+ expect(byName?.id).toBe(id);
101
+
102
+ const listed = await store.listProviders(tenant);
103
+ expect(listed.map((entry) => entry.id)).toEqual([id]);
104
+ });
105
+
106
+ it("encrypts the API key at rest and decrypts it on read", async () => {
107
+ const tenant = tenantId();
108
+ const { id, data } = await createProvider(tenant);
109
+
110
+ const raw = await pool.query<{ api_key_enc: string; api_key_hint: string }>(
111
+ "SELECT api_key_enc, api_key_hint FROM lattice_model_providers WHERE tenant_id = $1 AND id = $2",
112
+ [tenant, id],
113
+ );
114
+
115
+ expect(raw.rows[0].api_key_enc).not.toBe(data.apiKey);
116
+ expect(decrypt(raw.rows[0].api_key_enc)).toBe(data.apiKey);
117
+ expect(raw.rows[0].api_key_hint).toBe("abcd");
118
+ });
119
+
120
+ it("updates provider fields, keeps an omitted key, and can clear it", async () => {
121
+ const tenant = tenantId();
122
+ const { id, data } = await createProvider(tenant);
123
+
124
+ const renamed = await store.updateProvider(tenant, id, {
125
+ name: `${data.name}-renamed`,
126
+ displayName: "Renamed",
127
+ baseURL: "https://new.example.com/v1",
128
+ enabled: false,
129
+ });
130
+
131
+ expect(renamed).toMatchObject({
132
+ name: `${data.name}-renamed`,
133
+ displayName: "Renamed",
134
+ baseURL: "https://new.example.com/v1",
135
+ enabled: false,
136
+ apiKey: data.apiKey,
137
+ });
138
+
139
+ const rekeyed = await store.updateProvider(tenant, id, { apiKey: "sk-new-wxyz" });
140
+ expect(rekeyed?.apiKey).toBe("sk-new-wxyz");
141
+ expect(rekeyed?.apiKeyHint).toBe("wxyz");
142
+
143
+ const cleared = await store.updateProvider(tenant, id, { clearApiKey: true });
144
+ expect(cleared?.apiKey).toBeUndefined();
145
+ expect(cleared?.apiKeyHint).toBeUndefined();
146
+ });
147
+
148
+ it("records discovery status and clears the error on success", async () => {
149
+ const tenant = tenantId();
150
+ const { id } = await createProvider(tenant);
151
+
152
+ await store.markProviderStatus(tenant, id, "error", "UPSTREAM_UNAVAILABLE");
153
+ const failed = await store.getProviderById(tenant, id);
154
+ expect(failed?.status).toBe("error");
155
+ expect(failed?.lastError).toBe("UPSTREAM_UNAVAILABLE");
156
+
157
+ const discoveredAt = new Date("2024-01-02T03:04:05.000Z");
158
+ await store.markProviderStatus(tenant, id, "ok", undefined, discoveredAt);
159
+ const ok = await store.getProviderById(tenant, id);
160
+ expect(ok?.status).toBe("ok");
161
+ expect(ok?.lastError).toBeUndefined();
162
+ // `last_discovered_at` is a zone-naive TIMESTAMP, so the driver interprets
163
+ // the stored wall-clock value in the local zone. Assert the instant was
164
+ // persisted rather than an exact ISO string, which is timezone-dependent.
165
+ expect(ok?.lastDiscoveredAt).toBeInstanceOf(Date);
166
+ expect(
167
+ Math.abs(ok!.lastDiscoveredAt!.getTime() - discoveredAt.getTime()),
168
+ ).toBeLessThan(24 * 60 * 60 * 1000);
169
+ });
170
+
171
+ it("replaces, reads, toggles, and deletes a provider's models", async () => {
172
+ const tenant = tenantId();
173
+ const { id: providerId } = await createProvider(tenant);
174
+
175
+ const returned = await store.replaceProviderModels(tenant, providerId, [
176
+ { id: "gpt-4o", displayName: "GPT-4o", ownedBy: "openai" },
177
+ { id: "gpt-4o-mini", extra: { temperature: 0.2 }, kind: "chat" },
178
+ ]);
179
+ expect(returned).toEqual(["gpt-4o", "gpt-4o-mini"]);
180
+
181
+ const all = await store.listModels(tenant);
182
+ expect(all.map((model) => model.modelId).sort()).toEqual(["gpt-4o", "gpt-4o-mini"]);
183
+
184
+ const scoped = await store.listModels(tenant, providerId);
185
+ expect(scoped).toHaveLength(2);
186
+
187
+ const model = await store.getModel(tenant, providerId, "gpt-4o-mini");
188
+ expect(model).toMatchObject({
189
+ tenantId: tenant,
190
+ providerId,
191
+ modelId: "gpt-4o-mini",
192
+ enabled: true,
193
+ kind: "chat",
194
+ extra: { temperature: 0.2 },
195
+ });
196
+
197
+ const disabled = await store.setModelEnabled(tenant, providerId, "gpt-4o-mini", false);
198
+ expect(disabled?.enabled).toBe(false);
199
+
200
+ // A later discovery pass without an explicit `enabled` keeps the tenant choice.
201
+ await store.replaceProviderModels(tenant, providerId, [
202
+ { id: "gpt-4o" },
203
+ { id: "gpt-4o-mini" },
204
+ ]);
205
+ expect(
206
+ (await store.getModel(tenant, providerId, "gpt-4o-mini"))?.enabled,
207
+ ).toBe(false);
208
+
209
+ expect(await store.deleteModelsByProvider(tenant, providerId)).toBe(2);
210
+ expect(await store.listModels(tenant)).toEqual([]);
211
+ });
212
+
213
+ it("reconciles a snapshot: drops removed non-defaults, keeps the default stale, and clears stale on rediscovery", async () => {
214
+ const tenant = tenantId();
215
+ const { id: providerId } = await createProvider(tenant);
216
+
217
+ await store.replaceProviderModels(tenant, providerId, [
218
+ { id: "default-model", isDefault: true },
219
+ { id: "removed-model" },
220
+ ]);
221
+ expect(
222
+ (await store.getModel(tenant, providerId, "default-model"))?.isDefault,
223
+ ).toBe(true);
224
+
225
+ // The upstream drops both existing models and offers a new one.
226
+ await store.replaceProviderModels(tenant, providerId, [
227
+ { id: "replacement-model" },
228
+ ]);
229
+
230
+ expect(await store.getModel(tenant, providerId, "removed-model")).toBeNull();
231
+
232
+ const retainedDefault = await store.getModel(
233
+ tenant,
234
+ providerId,
235
+ "default-model",
236
+ );
237
+ expect(retainedDefault).toMatchObject({
238
+ modelId: "default-model",
239
+ isDefault: true,
240
+ stale: true,
241
+ });
242
+
243
+ const replacement = await store.getModel(
244
+ tenant,
245
+ providerId,
246
+ "replacement-model",
247
+ );
248
+ expect(replacement).toMatchObject({ isDefault: false, stale: false });
249
+ expect(
250
+ (await store.listModels(tenant, providerId)).map((model) => model.modelId).sort(),
251
+ ).toEqual(["default-model", "replacement-model"]);
252
+
253
+ // Rediscovery offers the retained default again: stale clears while the
254
+ // default flag is preserved, because discovery carries no isDefault opinion.
255
+ await store.replaceProviderModels(tenant, providerId, [
256
+ { id: "default-model" },
257
+ { id: "replacement-model" },
258
+ ]);
259
+
260
+ expect(
261
+ await store.getModel(tenant, providerId, "default-model"),
262
+ ).toMatchObject({ isDefault: true, stale: false });
263
+ expect(
264
+ await store.getModel(tenant, providerId, "replacement-model"),
265
+ ).toMatchObject({ isDefault: false, stale: false });
266
+ });
267
+
268
+ it("deletes a provider together with its model snapshot", async () => {
269
+ const tenant = tenantId();
270
+ const { id: providerId } = await createProvider(tenant);
271
+ await store.replaceProviderModels(tenant, providerId, [{ id: "m1" }]);
272
+
273
+ expect(await store.deleteProvider(tenant, providerId)).toBe(true);
274
+ expect(await store.getProviderById(tenant, providerId)).toBeNull();
275
+ expect(await store.listModels(tenant)).toEqual([]);
276
+ expect(await store.deleteProvider(tenant, providerId)).toBe(false);
277
+ });
278
+
279
+ it("isolates providers and models by tenant", async () => {
280
+ const tenantA = tenantId();
281
+ const tenantB = tenantId();
282
+ const idA = randomUUID();
283
+ const idB = randomUUID();
284
+ const name = `shared-name-${randomUUID()}`;
285
+
286
+ await store.createProvider(tenantA, idA, { ...providerData(), name });
287
+ await store.createProvider(tenantB, idB, { ...providerData(), name });
288
+
289
+ expect((await store.getProviderById(tenantA, idB))).toBeNull();
290
+ expect((await store.getProviderByName(tenantA, name))?.id).toBe(idA);
291
+ expect((await store.listProviders(tenantA)).map((entry) => entry.id)).toEqual([idA]);
292
+
293
+ await store.replaceProviderModels(tenantA, idA, [{ id: "same-model" }]);
294
+ await store.replaceProviderModels(tenantB, idB, [{ id: "same-model" }]);
295
+
296
+ expect(await store.listModels(tenantA)).toHaveLength(1);
297
+ expect(await store.listModels(tenantB)).toHaveLength(1);
298
+ expect(await store.getModel(tenantA, idB, "same-model")).toBeNull();
299
+ });
300
+
301
+ it("returns absent results for unknown providers and models", async () => {
302
+ const tenant = tenantId();
303
+ const missing = randomUUID();
304
+
305
+ expect(await store.getProviderById(tenant, missing)).toBeNull();
306
+ expect(await store.getProviderByName(tenant, "does-not-exist")).toBeNull();
307
+ expect(await store.updateProvider(tenant, missing, { name: "x" })).toBeNull();
308
+ expect(await store.deleteProvider(tenant, missing)).toBe(false);
309
+ expect(await store.getModel(tenant, missing, "m1")).toBeNull();
310
+ expect(await store.setModelEnabled(tenant, missing, "m1", false)).toBeNull();
311
+ expect(await store.deleteModelsByProvider(tenant, missing)).toBe(0);
312
+ });
313
+ });
@@ -0,0 +1,203 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ import { PostgreSQLSkillStore } from "../stores/PostgreSQLSkillStore";
4
+
5
+ const TEST_DATABASE_URL = process.env.TEST_DATABASE_URL;
6
+ const describeIfDatabase = TEST_DATABASE_URL ? describe : describe.skip;
7
+
8
+ describeIfDatabase("PostgreSQLSkillStore integration", () => {
9
+ let store: PostgreSQLSkillStore;
10
+ const created: Array<{ tenantId: string; id: string }> = [];
11
+
12
+ beforeAll(async () => {
13
+ store = new PostgreSQLSkillStore({
14
+ poolConfig: TEST_DATABASE_URL!,
15
+ autoMigrate: true,
16
+ });
17
+ await store.initialize();
18
+ });
19
+
20
+ afterEach(async () => {
21
+ while (created.length > 0) {
22
+ const entry = created.pop()!;
23
+ await store.deleteSkill(entry.tenantId, entry.id);
24
+ }
25
+ });
26
+
27
+ afterAll(async () => {
28
+ await store.dispose();
29
+ });
30
+
31
+ function tenantId(): string {
32
+ return `skill-${randomUUID()}`;
33
+ }
34
+
35
+ async function seedSkill(
36
+ tenant: string,
37
+ data: Partial<Parameters<PostgreSQLSkillStore["createSkill"]>[2]> = {},
38
+ ): Promise<{ id: string; tenantId: string }> {
39
+ const id = randomUUID();
40
+ created.push({ tenantId: tenant, id });
41
+ await store.createSkill(tenant, id, {
42
+ name: `skill-${id}`,
43
+ description: "A test skill",
44
+ ...data,
45
+ });
46
+ return { id, tenantId: tenant };
47
+ }
48
+
49
+ it("round-trips a created skill through get by id", async () => {
50
+ const tenant = tenantId();
51
+ const id = randomUUID();
52
+ created.push({ tenantId: tenant, id });
53
+
54
+ const createdSkill = await store.createSkill(tenant, id, {
55
+ name: "pdf-reader",
56
+ description: "Reads PDFs",
57
+ license: "MIT",
58
+ compatibility: ">=1.0.0",
59
+ metadata: { category: "documents", level: "1" },
60
+ content: "# Body",
61
+ subSkills: ["pdf", "ocr"],
62
+ });
63
+
64
+ expect(createdSkill.tenantId).toBe(tenant);
65
+ expect(createdSkill.name).toBe("pdf-reader");
66
+
67
+ const read = await store.getSkillById(tenant, id);
68
+ expect(read).not.toBeNull();
69
+ expect(read).toMatchObject({
70
+ id,
71
+ tenantId: tenant,
72
+ name: "pdf-reader",
73
+ description: "Reads PDFs",
74
+ license: "MIT",
75
+ compatibility: ">=1.0.0",
76
+ metadata: { category: "documents", level: "1" },
77
+ content: "# Body",
78
+ subSkills: ["pdf", "ocr"],
79
+ });
80
+ expect(read!.createdAt).toBeInstanceOf(Date);
81
+ expect(read!.updatedAt).toBeInstanceOf(Date);
82
+ });
83
+
84
+ it("returns null and false for an absent skill id", async () => {
85
+ const tenant = tenantId();
86
+ const missing = randomUUID();
87
+
88
+ expect(await store.getSkillById(tenant, missing)).toBeNull();
89
+ expect(await store.hasSkill(tenant, missing)).toBe(false);
90
+ expect(await store.updateSkill(tenant, missing, { name: "nope" })).toBeNull();
91
+ expect(await store.deleteSkill(tenant, missing)).toBe(false);
92
+ });
93
+
94
+ it("lists only the requested tenant's skills", async () => {
95
+ const tenantA = tenantId();
96
+ const tenantB = tenantId();
97
+ const a1 = await seedSkill(tenantA);
98
+ const a2 = await seedSkill(tenantA);
99
+ const b1 = await seedSkill(tenantB);
100
+
101
+ const listedA = await store.getAllSkills(tenantA);
102
+ const idsA = listedA.map((skill) => skill.id).sort();
103
+ expect(idsA).toEqual([a1.id, a2.id].sort());
104
+ expect(listedA.every((skill) => skill.tenantId === tenantA)).toBe(true);
105
+
106
+ const listedB = await store.getAllSkills(tenantB);
107
+ expect(listedB.map((skill) => skill.id)).toEqual([b1.id]);
108
+ });
109
+
110
+ it("updates only the provided fields and preserves the rest", async () => {
111
+ const tenant = tenantId();
112
+ const { id } = await seedSkill(tenant, {
113
+ name: "before",
114
+ description: "keep me",
115
+ license: "MIT",
116
+ metadata: { a: "1" },
117
+ });
118
+
119
+ const updated = await store.updateSkill(tenant, id, {
120
+ name: "after",
121
+ metadata: { a: "2" },
122
+ });
123
+
124
+ expect(updated).not.toBeNull();
125
+ expect(updated!.name).toBe("after");
126
+ expect(updated!.metadata).toEqual({ a: "2" });
127
+ expect(updated!.description).toBe("keep me");
128
+ expect(updated!.license).toBe("MIT");
129
+ });
130
+
131
+ it("reports has and deletes a skill", async () => {
132
+ const tenant = tenantId();
133
+ const { id } = await seedSkill(tenant);
134
+
135
+ expect(await store.hasSkill(tenant, id)).toBe(true);
136
+ expect(await store.deleteSkill(tenant, id)).toBe(true);
137
+ expect(await store.hasSkill(tenant, id)).toBe(false);
138
+ expect(await store.getSkillById(tenant, id)).toBeNull();
139
+ expect(await store.deleteSkill(tenant, id)).toBe(false);
140
+ });
141
+
142
+ it("searches by metadata within a tenant", async () => {
143
+ const tenant = tenantId();
144
+ const otherTenant = tenantId();
145
+ const match = await seedSkill(tenant, { metadata: { category: "finance" } });
146
+ await seedSkill(tenant, { metadata: { category: "other" } });
147
+ await seedSkill(otherTenant, { metadata: { category: "finance" } });
148
+
149
+ const results = await store.searchByMetadata(tenant, "category", "finance");
150
+ expect(results.map((skill) => skill.id)).toEqual([match.id]);
151
+ });
152
+
153
+ it("filters by compatibility within a tenant", async () => {
154
+ const tenant = tenantId();
155
+ const otherTenant = tenantId();
156
+ const match = await seedSkill(tenant, { compatibility: ">=2.0.0" });
157
+ await seedSkill(tenant, { compatibility: ">=3.0.0" });
158
+ await seedSkill(otherTenant, { compatibility: ">=2.0.0" });
159
+
160
+ const results = await store.filterByCompatibility(tenant, ">=2.0.0");
161
+ expect(results.map((skill) => skill.id)).toEqual([match.id]);
162
+ });
163
+
164
+ it("filters by license within a tenant", async () => {
165
+ const tenant = tenantId();
166
+ const otherTenant = tenantId();
167
+ const match = await seedSkill(tenant, { license: "Apache-2.0" });
168
+ await seedSkill(tenant, { license: "MIT" });
169
+ await seedSkill(otherTenant, { license: "Apache-2.0" });
170
+
171
+ const results = await store.filterByLicense(tenant, "Apache-2.0");
172
+ expect(results.map((skill) => skill.id)).toEqual([match.id]);
173
+ });
174
+
175
+ it("finds skills that list the parent in subSkills within a tenant", async () => {
176
+ const tenant = tenantId();
177
+ const otherTenant = tenantId();
178
+ const parent = "parent-skill";
179
+
180
+ await seedSkill(tenant, { name: parent });
181
+ const child = await seedSkill(tenant, {
182
+ name: "child-skill",
183
+ subSkills: [parent],
184
+ });
185
+ // Membership, not exact-array equality: the parent is one element among others.
186
+ const membershipChild = await seedSkill(tenant, {
187
+ name: "membership-child",
188
+ subSkills: ["other", parent],
189
+ });
190
+ const otherChild = await seedSkill(otherTenant, {
191
+ name: "other-child",
192
+ subSkills: [parent],
193
+ });
194
+
195
+ const results = await store.getSubSkills(tenant, parent);
196
+
197
+ expect(results.map((skill) => skill.id).sort()).toEqual(
198
+ [child.id, membershipChild.id].sort(),
199
+ );
200
+ expect(results.map((skill) => skill.id)).not.toContain(otherChild.id);
201
+ expect(results.every((skill) => skill.tenantId === tenant)).toBe(true);
202
+ });
203
+ });