@axiom-lattice/pg-stores 3.4.0 → 4.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axiom-lattice/pg-stores",
3
- "version": "3.4.0",
3
+ "version": "4.0.1",
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": "9.0.0",
29
- "@axiom-lattice/protocols": "5.0.0"
28
+ "@axiom-lattice/core": "10.0.1",
29
+ "@axiom-lattice/protocols": "6.0.0"
30
30
  },
31
31
  "devDependencies": {
32
32
  "@types/jest": "^29.5.14",
@@ -9,7 +9,11 @@ import { Pool } from "pg";
9
9
  import { PGVectorStore } from "@langchain/community/vectorstores/pgvector";
10
10
  import { Document } from "@langchain/core/documents";
11
11
  import { embeddingsLatticeManager } from "@axiom-lattice/core";
12
- import type { VectorStoreProvider, VectorStoreCreateParams } from "@axiom-lattice/protocols";
12
+ import {
13
+ EMBEDDING_DEFAULT_KEY,
14
+ type VectorStoreProvider,
15
+ type VectorStoreCreateParams,
16
+ } from "@axiom-lattice/protocols";
13
17
  import type { DocumentInterface } from "@langchain/core/documents";
14
18
 
15
19
  function safeTableName(name: string): string {
@@ -31,15 +35,22 @@ export class PGVectorStoreProvider implements VectorStoreProvider {
31
35
  async create(params: VectorStoreCreateParams): Promise<any> {
32
36
  const { tableName, embeddingKey } = params;
33
37
  const tenantId = params.tenantId ?? "default";
38
+ const requestedKey = embeddingKey || EMBEDDING_DEFAULT_KEY;
34
39
 
35
40
  // Tenant first, deployment second — the same order every model resolver
36
- // uses. A tenant model key is `${providerId}/${modelId}` and only resolves
37
- // in that tenant's namespace.
41
+ // uses. A concrete key that misses falls back to the tenant's embedding
42
+ // default, then to the deployment registration.
38
43
  const embeddings =
39
44
  (await embeddingsLatticeManager.getEmbeddingsClientWithTenant(
40
45
  tenantId,
41
- embeddingKey
46
+ requestedKey
42
47
  )) ??
48
+ (requestedKey === EMBEDDING_DEFAULT_KEY
49
+ ? undefined
50
+ : await embeddingsLatticeManager.getEmbeddingsClientWithTenant(
51
+ tenantId,
52
+ EMBEDDING_DEFAULT_KEY
53
+ )) ??
43
54
  (await embeddingsLatticeManager.getEmbeddingsClientWithTenant(
44
55
  tenantId,
45
56
  "default"
@@ -48,7 +59,7 @@ export class PGVectorStoreProvider implements VectorStoreProvider {
48
59
  if (!embeddings) {
49
60
  throw new Error(
50
61
  `No embedding model "${embeddingKey}" registered for tenant "${tenantId}", ` +
51
- `and no deployment default. Configure one in the model provider panel.`
62
+ `and no tenant or deployment default. Configure one in the model service panel.`
52
63
  );
53
64
  }
54
65
 
@@ -34,7 +34,9 @@ describe("agent run retention migration", () => {
34
34
  (name) => name === createAgentRunRetentionMigration.name,
35
35
  ),
36
36
  ).toHaveLength(1);
37
- expect(registeredMigrationNames.at(-1)).toBe(
37
+ // Registration order is not the apply order — the manager sorts by version —
38
+ // so assert presence rather than that this is the last line registered.
39
+ expect(registeredMigrationNames).toContain(
38
40
  agentRunProjectTaskRunningIndexMigration.name,
39
41
  );
40
42
  });
@@ -19,7 +19,9 @@ function input(
19
19
  channelInstallationId: "install-1",
20
20
  replyTarget: { chatId: "chat-1" },
21
21
  inputMessageId: "input-1",
22
- availableAt: Date.now() - 1_000,
22
+ // Well in the past: the store compares against the database clock, which
23
+ // can trail the Node process clock by a few milliseconds.
24
+ availableAt: Date.now() - 60_000,
23
25
  ...overrides,
24
26
  };
25
27
  }
@@ -194,7 +196,7 @@ maybeDescribe("PostgreSQLChannelReplyJobStore (PostgreSQL)", () => {
194
196
  store.reschedule(created.tenantId, created.runId, "owner-b", "nope", Date.now(), 1),
195
197
  ).resolves.toBeNull();
196
198
 
197
- const availableAt = Date.now() - 1;
199
+ const availableAt = Date.now() - 60_000;
198
200
  const rescheduled = await store.reschedule(
199
201
  created.tenantId,
200
202
  created.runId,
@@ -6,16 +6,27 @@
6
6
  */
7
7
 
8
8
  import { describe, expect, it, jest } from "@jest/globals";
9
- import { addTaskIdToEvalRuns, evalMigrations } from "../migrations/eval_migrations";
9
+ import { addCancelRequestedToEvalRuns, addTaskIdToEvalRuns, evalMigrations } from "../migrations/eval_migrations";
10
10
 
11
11
  describe("add_task_id_to_eval_runs migration", () => {
12
12
  it("is registered in evalMigrations", () => {
13
13
  expect(evalMigrations.map((m) => m.name)).toContain("add_task_id_to_eval_runs");
14
14
  });
15
15
 
16
- it("has a version greater than all existing eval migrations", () => {
16
+ it("keeps task_id before the cancellation migration", () => {
17
17
  const versions = evalMigrations.map((m) => m.version);
18
- expect(addTaskIdToEvalRuns.version).toBe(Math.max(...versions));
18
+ expect(addCancelRequestedToEvalRuns.version).toBe(Math.max(...versions));
19
+ expect(addTaskIdToEvalRuns.version).toBeLessThan(addCancelRequestedToEvalRuns.version);
20
+ });
21
+
22
+ it("emits the cooperative cancel column in up() and down()", async () => {
23
+ const upQueries: string[] = [];
24
+ await addCancelRequestedToEvalRuns.up({ query: jest.fn(async (sql: string) => { upQueries.push(sql); }) } as never);
25
+ expect(upQueries.some((q) => q.includes("cancel_requested BOOLEAN NOT NULL DEFAULT FALSE"))).toBe(true);
26
+
27
+ const downQueries: string[] = [];
28
+ await addCancelRequestedToEvalRuns.down({ query: jest.fn(async (sql: string) => { downQueries.push(sql); }) } as never);
29
+ expect(downQueries.some((q) => q.includes("DROP COLUMN IF EXISTS cancel_requested"))).toBe(true);
19
30
  });
20
31
 
21
32
  it("emits ADD COLUMN task_id in up()", async () => {
@@ -109,6 +109,7 @@ import {
109
109
  addModelProviderModelExtra,
110
110
  addModelProviderModelDefault,
111
111
  addModelProviderModelKind,
112
+ addModelProviderKindDefault,
112
113
  } from "./migrations/model_provider_migrations";
113
114
  import { PostgreSQLCapabilityBundleStore } from "./stores/PostgreSQLCapabilityBundleStore";
114
115
  import { PostgreSQLProjectRoomStore } from "./stores/PostgreSQLProjectRoomStore";
@@ -222,6 +223,7 @@ export async function createPgStoreConfig(connectionString: string) {
222
223
  mm.register(createAgentRunRetentionMigration); // v201
223
224
  mm.register(createAgentRunProjectTaskMigration); // v203
224
225
  mm.register(agentRunProjectTaskRunningIndexMigration); // v204
226
+ mm.register(addModelProviderKindDefault); // v207
225
227
 
226
228
  await mm.migrate();
227
229
 
@@ -381,6 +381,24 @@ export const addTaskIdToEvalRuns: Migration = {
381
381
  },
382
382
  };
383
383
 
384
+
385
+ /** Cooperative cancellation flag for running eval runs. */
386
+ export const addCancelRequestedToEvalRuns: Migration = {
387
+ version: 205,
388
+ name: "add_cancel_requested_to_eval_runs",
389
+ up: async (client: PoolClient) => {
390
+ await client.query(`
391
+ ALTER TABLE lattice_eval_runs
392
+ ADD COLUMN IF NOT EXISTS cancel_requested BOOLEAN NOT NULL DEFAULT FALSE
393
+ `);
394
+ },
395
+ down: async (client: PoolClient) => {
396
+ await client.query(`
397
+ ALTER TABLE lattice_eval_runs DROP COLUMN IF EXISTS cancel_requested
398
+ `);
399
+ },
400
+ };
401
+
384
402
  /** All eval migrations in version order */
385
403
  export const evalMigrations: Migration[] = [
386
404
  createEvalProjectsTable,
@@ -394,4 +412,5 @@ export const evalMigrations: Migration[] = [
394
412
  addInterruptPolicyToEvalCases,
395
413
  enforceUniqueEvalProjectName,
396
414
  addTaskIdToEvalRuns,
415
+ addCancelRequestedToEvalRuns,
397
416
  ];
@@ -256,3 +256,41 @@ export const addModelProviderModelKind: Migration = {
256
256
  `);
257
257
  },
258
258
  };
259
+
260
+ /**
261
+ * One default per tenant **per kind**.
262
+ *
263
+ * v192's index was tenant-wide, so a tenant could have a default chat model
264
+ * *or* a default embedding model, never both. Each kind publishes its own
265
+ * alias key (`default`, `default:embedding`, `default:asr`), so the uniqueness
266
+ * constraint has to be per kind too.
267
+ *
268
+ * Existing rows satisfy the wider index unchanged: before this, at most one
269
+ * row per tenant could be the default.
270
+ *
271
+ * Note the `down` migration can legitimately fail once a tenant has one
272
+ * default per kind — the narrower index cannot represent that state. That is a
273
+ * deliberate one-way step.
274
+ */
275
+ export const addModelProviderKindDefault: Migration = {
276
+ version: 207,
277
+ name: 'add_model_provider_kind_default',
278
+ up: async (client: PoolClient) => {
279
+ await client.query(`
280
+ DROP INDEX IF EXISTS uk_lattice_model_provider_models_tenant_default
281
+ `);
282
+ await client.query(`
283
+ CREATE UNIQUE INDEX IF NOT EXISTS uk_lattice_model_provider_models_tenant_kind_default
284
+ ON lattice_model_provider_models (tenant_id, kind) WHERE is_default
285
+ `);
286
+ },
287
+ down: async (client: PoolClient) => {
288
+ await client.query(`
289
+ DROP INDEX IF EXISTS uk_lattice_model_provider_models_tenant_kind_default
290
+ `);
291
+ await client.query(`
292
+ CREATE UNIQUE INDEX IF NOT EXISTS uk_lattice_model_provider_models_tenant_default
293
+ ON lattice_model_provider_models (tenant_id) WHERE is_default
294
+ `);
295
+ },
296
+ };
@@ -112,6 +112,22 @@ export const addTaskDependenciesGinIndex: Migration = {
112
112
  },
113
113
  };
114
114
 
115
+ /** Index task execution threads for ownership checks on isolated worker threads. */
116
+ export const addTaskContextThreadIndex: Migration = {
117
+ version: 206,
118
+ name: "add_task_context_thread_index",
119
+ up: async (client) => {
120
+ await client.query(`
121
+ CREATE INDEX IF NOT EXISTS idx_lattice_tasks_context_thread
122
+ ON lattice_tasks (tenant_id, (context->>'thread_id'))
123
+ WHERE context->>'thread_id' IS NOT NULL
124
+ `);
125
+ },
126
+ down: async (client) => {
127
+ await client.query("DROP INDEX IF EXISTS idx_lattice_tasks_context_thread");
128
+ },
129
+ };
130
+
115
131
  /** All task migrations in version order */
116
132
  export const taskMigrations: Migration[] = [
117
133
  createTasksTable,
@@ -119,4 +135,5 @@ export const taskMigrations: Migration[] = [
119
135
  addTaskProjectFieldsMigration,
120
136
  addFilesToTasks,
121
137
  addTaskDependenciesGinIndex,
138
+ addTaskContextThreadIndex,
122
139
  ];
@@ -274,7 +274,10 @@ export class PostgreSQLAgentRunLedger implements IAgentRunLedger {
274
274
  const inputMessageIds = input.inputMessageIds
275
275
  ? normalizeAgentRunMessageIds(input.inputMessageIds)
276
276
  : extractAgentRunInputMessageIds(input.input);
277
- const availableAt = input.availableAt ?? Date.now();
277
+ // Default to the database clock. Mixing Node's `Date.now()` with the
278
+ // `available_at <= now()` claim predicate makes freshly created rows
279
+ // briefly unclaimable whenever the two clocks drift by a few milliseconds.
280
+ const availableAt = input.availableAt ?? null;
278
281
  const limit = input.maxActiveRunsForTenant;
279
282
  const client = await this.pool.connect();
280
283
  try {
@@ -333,7 +336,10 @@ export class PostgreSQLAgentRunLedger implements IAgentRunLedger {
333
336
  available_at, created_at, updated_at, task_id)
334
337
  VALUES ($1, $2, $3, $4, $5, $6, 'pending', $7, NULL, NULL, $8, 0,
335
338
  $9::jsonb, $10::jsonb, $11::jsonb, $12::jsonb, $13,
336
- to_timestamp($14::double precision / 1000.0),
339
+ COALESCE(
340
+ to_timestamp($14::double precision / 1000.0),
341
+ date_trunc('milliseconds', now())
342
+ ),
337
343
  date_trunc('milliseconds', now()),
338
344
  date_trunc('milliseconds', now()), $15)
339
345
  RETURNING *`,
@@ -971,14 +977,19 @@ export class PostgreSQLAgentRunLedger implements IAgentRunLedger {
971
977
  options?: SettleExpiredRunOptions,
972
978
  ): Promise<boolean> {
973
979
  if (outcome === "pending") {
974
- const availableAt = options?.availableAt ?? Date.now();
980
+ // Same clock rule as createPendingRun: only explicit backoff timestamps
981
+ // are caller-supplied; the default must come from the database.
982
+ const availableAt = options?.availableAt ?? null;
975
983
  const result = await this.pool.query(
976
984
  `UPDATE lattice_agent_runs
977
985
  SET status = 'pending',
978
986
  owner = NULL,
979
987
  lease_expires_at = NULL,
980
988
  started_at = NULL,
981
- available_at = to_timestamp($2::double precision / 1000.0),
989
+ available_at = COALESCE(
990
+ to_timestamp($2::double precision / 1000.0),
991
+ date_trunc('milliseconds', now())
992
+ ),
982
993
  finished_at = NULL,
983
994
  updated_at = now()
984
995
  WHERE run_id = $1
@@ -180,6 +180,7 @@ export class PostgreSQLEvalStore implements EvalStore {
180
180
  interruptedCases: (row.interrupted_cases as number) ?? 0,
181
181
  avgScore: (row.avg_score as number) ?? 0,
182
182
  error: row.error as string | undefined,
183
+ cancelRequested: (row.cancel_requested as boolean) ?? false,
183
184
  holdout: row.holdout as boolean | undefined,
184
185
  envProjectId: row.env_project_id as string | undefined,
185
186
  envWorkspaceId: row.env_workspace_id as string | undefined,
@@ -610,7 +611,7 @@ export class PostgreSQLEvalStore implements EvalStore {
610
611
  `SELECT id, project_id, tenant_id,
611
612
  status, concurrency, total_cases,
612
613
  passed_cases, failed_cases, interrupted_cases, avg_score,
613
- error, holdout, env_project_id, env_workspace_id, task_id, created_at, started_at, completed_at
614
+ error, holdout, env_project_id, env_workspace_id, task_id, cancel_requested, created_at, started_at, completed_at
614
615
  FROM lattice_eval_runs
615
616
  WHERE ${conditions.join(" AND ")}
616
617
  ORDER BY created_at DESC`,
@@ -632,7 +633,7 @@ export class PostgreSQLEvalStore implements EvalStore {
632
633
  `SELECT id, project_id, tenant_id,
633
634
  status, concurrency, total_cases,
634
635
  passed_cases, failed_cases, interrupted_cases, avg_score,
635
- error, holdout, env_project_id, env_workspace_id, task_id, created_at, started_at, completed_at
636
+ error, holdout, env_project_id, env_workspace_id, task_id, cancel_requested, created_at, started_at, completed_at
636
637
  FROM lattice_eval_runs
637
638
  WHERE id = $1 AND tenant_id = $2`,
638
639
  [id, tenantId]
@@ -662,7 +663,7 @@ export class PostgreSQLEvalStore implements EvalStore {
662
663
  RETURNING id, project_id, tenant_id,
663
664
  status, concurrency, total_cases,
664
665
  passed_cases, failed_cases, interrupted_cases, avg_score,
665
- error, holdout, env_project_id, env_workspace_id, task_id, created_at, started_at, completed_at`,
666
+ error, holdout, env_project_id, env_workspace_id, task_id, cancel_requested, created_at, started_at, completed_at`,
666
667
  [
667
668
  actualId,
668
669
  projectId,
@@ -722,12 +723,24 @@ export class PostgreSQLEvalStore implements EvalStore {
722
723
  RETURNING id, project_id, tenant_id,
723
724
  status, concurrency, total_cases,
724
725
  passed_cases, failed_cases, interrupted_cases, avg_score,
725
- error, holdout, env_project_id, env_workspace_id, task_id, created_at, started_at, completed_at`,
726
+ error, holdout, env_project_id, env_workspace_id, task_id, cancel_requested, created_at, started_at, completed_at`,
726
727
  vals
727
728
  );
728
729
  return rows.length ? this.mapRowToRun(rows[0] as unknown as Record<string, unknown>) : null;
729
730
  }
730
731
 
732
+ /** Request cooperative cancellation for a running evaluation. */
733
+ async requestCancel(tenantId: string, id: string): Promise<boolean> {
734
+ await this.ensureInitialized();
735
+ const { rowCount } = await this.pool.query(
736
+ `UPDATE lattice_eval_runs
737
+ SET cancel_requested = TRUE
738
+ WHERE tenant_id = $1 AND id = $2 AND status = 'running'`,
739
+ [tenantId, id],
740
+ );
741
+ return (rowCount ?? 0) > 0;
742
+ }
743
+
731
744
  /** Delete a run and its results */
732
745
  async deleteRun(tenantId: string, id: string): Promise<boolean> {
733
746
  await this.ensureInitialized();
@@ -26,6 +26,7 @@ import {
26
26
  addModelProviderModelExtra,
27
27
  addModelProviderModelDefault,
28
28
  addModelProviderModelKind,
29
+ addModelProviderKindDefault,
29
30
  } from '../migrations/model_provider_migrations';
30
31
  import { encrypt, safeDecrypt } from '@axiom-lattice/core';
31
32
 
@@ -148,6 +149,7 @@ export class PostgreSQLModelProviderStore implements ModelProviderStore {
148
149
  this.migrationManager.register(addModelProviderModelExtra); // v191
149
150
  this.migrationManager.register(addModelProviderModelDefault); // v192
150
151
  this.migrationManager.register(addModelProviderModelKind); // v193
152
+ this.migrationManager.register(addModelProviderKindDefault); // v207
151
153
 
152
154
  // Auto-migrate by default
153
155
  if (options.autoMigrate !== false) {
@@ -610,30 +612,42 @@ export class PostgreSQLModelProviderStore implements ModelProviderStore {
610
612
  (model) => model.kind ?? currentKind.get(model.id) ?? "chat"
611
613
  );
612
614
 
613
- // Whether this write is naming a default at all.
615
+ // Which kinds this write names a default for.
614
616
  //
615
- // The unique index spans the *tenant*, while this method writes one
617
+ // The unique index is per *(tenant, kind)*, while this method writes one
616
618
  // provider. Two consequences:
617
619
  //
618
- // 1. A swap (A was the default, B now is) would see two true rows
619
- // mid-statement — PostgreSQL checks a unique index per row, so whether
620
- // it fails would depend on which row the batch happened to process
621
- // first. Clearing tenant-wide *before* the upsert makes order
620
+ // 1. A swap (A was kind K's default, B now is) would see two true rows of
621
+ // kind K mid-statement — PostgreSQL checks a unique index per row, so
622
+ // whether it fails would depend on which row the batch happened to
623
+ // process first. Clearing that kind before the upsert makes order
622
624
  // irrelevant.
623
- // 2. Once cleared, an omitted flag must resolve to `false`, not "keep":
624
- // keeping it would restore the old default alongside the new one.
625
- const namesDefault = rows.some((model) => model.isDefault === true);
626
- if (namesDefault) {
625
+ // 2. Once cleared, an omitted flag must resolve to `false` for the kinds
626
+ // this write names — keeping it would restore the old default
627
+ // alongside the new one. Other kinds keep their stored flag, so a chat
628
+ // write cannot drop the tenant's embedding default.
629
+ const namedDefaultKinds = new Set<ModelKind>();
630
+ rows.forEach((model, index) => {
631
+ if (model.isDefault === true) {
632
+ namedDefaultKinds.add(effectiveKind[index] as ModelKind);
633
+ }
634
+ });
635
+
636
+ for (const kind of namedDefaultKinds) {
627
637
  await client.query(
628
- `UPDATE lattice_model_provider_models SET is_default = FALSE WHERE tenant_id = $1`,
629
- [tenantId]
638
+ `UPDATE lattice_model_provider_models
639
+ SET is_default = FALSE
640
+ WHERE tenant_id = $1 AND kind = $2 AND is_default`,
641
+ [tenantId, kind]
630
642
  );
631
643
  }
632
644
 
633
645
  const effectiveIsDefault = rows.map(
634
- (model) =>
646
+ (model, index) =>
635
647
  model.isDefault ??
636
- (namesDefault ? false : currentIsDefault.get(model.id)) ??
648
+ (namedDefaultKinds.has(effectiveKind[index] as ModelKind)
649
+ ? false
650
+ : currentIsDefault.get(model.id)) ??
637
651
  false
638
652
  );
639
653
 
@@ -283,6 +283,10 @@ export class PostgreSQLTaskStore implements TaskStore {
283
283
  conditions.push(`priority = $${paramIndex++}`);
284
284
  params.push(filter.priority);
285
285
  }
286
+ if (filter.threadId) {
287
+ conditions.push(`context->>'thread_id' = $${paramIndex++}`);
288
+ params.push(filter.threadId);
289
+ }
286
290
  if (filter.workspaceId) {
287
291
  conditions.push(`workspace_id = $${paramIndex++}`);
288
292
  params.push(filter.workspaceId);