@axiom-lattice/pg-stores 3.3.3 → 3.3.5

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.3.3",
3
+ "version": "3.3.5",
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": "6.3.3",
29
- "@axiom-lattice/protocols": "4.7.1"
28
+ "@axiom-lattice/core": "6.4.1",
29
+ "@axiom-lattice/protocols": "4.8.0"
30
30
  },
31
31
  "devDependencies": {
32
32
  "@types/jest": "^29.5.14",
@@ -0,0 +1,139 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { afterAll, beforeAll, describe, expect, it } from "@jest/globals";
3
+ import { Pool } from "pg";
4
+ import type { PluginDeployment } from "@axiom-lattice/protocols";
5
+
6
+ import { MigrationManager } from "../migrations/migration";
7
+ import {
8
+ createPluginDeployments,
9
+ dropPluginDeploymentAuthToken,
10
+ } from "../migrations/plugin_deployment_migration";
11
+ import { PostgreSQLPluginDeploymentStore } from "../stores/PostgreSQLPluginDeploymentStore";
12
+
13
+ const databaseUrl = process.env.TEST_DATABASE_URL;
14
+ const describeIfDatabase = databaseUrl ? describe : describe.skip;
15
+
16
+ function record(
17
+ tenantId: string,
18
+ pluginType: string,
19
+ overrides: Partial<PluginDeployment> = {},
20
+ ): PluginDeployment {
21
+ const now = new Date().toISOString();
22
+ return {
23
+ id: randomUUID(),
24
+ tenantId,
25
+ pluginType,
26
+ pluginVersion: "1.0.0",
27
+ mode: "onDemand",
28
+ status: "running",
29
+ provider: "microsandbox-remote",
30
+ sandboxName: `plugin-${pluginType}`,
31
+ port: 8080,
32
+ idleTimeoutMs: 600_000,
33
+ createdAt: now,
34
+ updatedAt: now,
35
+ ...overrides,
36
+ };
37
+ }
38
+
39
+ describeIfDatabase("PostgreSQLPluginDeploymentStore", () => {
40
+ let pool: Pool;
41
+ let store: PostgreSQLPluginDeploymentStore;
42
+ const tenantId = `plugin-deploy-${randomUUID()}`;
43
+ const otherTenantId = `plugin-deploy-${randomUUID()}`;
44
+
45
+ beforeAll(async () => {
46
+ pool = new Pool({ connectionString: databaseUrl });
47
+ const mm = new MigrationManager(pool);
48
+ mm.register(createPluginDeployments);
49
+ mm.register(dropPluginDeploymentAuthToken);
50
+ await mm.migrate();
51
+ store = new PostgreSQLPluginDeploymentStore({ pool });
52
+ });
53
+
54
+ afterAll(async () => {
55
+ await pool.query("DELETE FROM lattice_plugin_deployments WHERE tenant_id = ANY($1)", [
56
+ [tenantId, otherTenantId],
57
+ ]);
58
+ await pool.end();
59
+ });
60
+
61
+ it("upserts and gets a deployment", async () => {
62
+ const saved = await store.upsert(record(tenantId, "weather"));
63
+ expect(saved.pluginType).toBe("weather");
64
+
65
+ const got = await store.get(tenantId, "weather");
66
+ expect(got?.pluginType).toBe("weather");
67
+ expect(await store.get(tenantId, "missing")).toBeUndefined();
68
+ });
69
+
70
+ it("preserves id and createdAt on conflict while applying mutable fields", async () => {
71
+ const first = await store.upsert(record(tenantId, "conflict", { status: "requested" }));
72
+ await new Promise((resolve) => setTimeout(resolve, 20));
73
+ const second = await store.upsert({
74
+ ...record(tenantId, "conflict"),
75
+ id: randomUUID(),
76
+ status: "running",
77
+ pluginVersion: "2.0.0",
78
+ });
79
+ expect(second.id).toBe(first.id);
80
+ expect(second.createdAt).toBe(first.createdAt);
81
+ expect(second.status).toBe("running");
82
+ expect(second.pluginVersion).toBe("2.0.0");
83
+ expect(second.updatedAt).not.toBe(first.updatedAt);
84
+ });
85
+
86
+ it("maps NULL optional columns to undefined", async () => {
87
+ await store.upsert(record(tenantId, "nulls"));
88
+ const got = await store.get(tenantId, "nulls");
89
+ expect(got?.endpoint).toBeUndefined();
90
+ expect(got?.imageRef).toBeUndefined();
91
+ expect(got?.lastHealthAt).toBeUndefined();
92
+ expect(got?.lastError).toBeUndefined();
93
+ });
94
+
95
+ it("patches allowed fields and returns undefined for missing rows", async () => {
96
+ await store.upsert(record(tenantId, "patch"));
97
+ const updated = await store.updateStatus(tenantId, "patch", {
98
+ status: "paused",
99
+ mode: "alwaysOn",
100
+ endpoint: "srv:1",
101
+ imageRef: "img:1",
102
+ lastHealthAt: "2026-01-02T00:00:00.000Z",
103
+ lastError: "boom",
104
+ pluginVersion: "1.2.0",
105
+ provider: "opensandbox",
106
+ sandboxName: "plugin-patch-2",
107
+ port: 9090,
108
+ idleTimeoutMs: 1000,
109
+ });
110
+ expect(updated?.status).toBe("paused");
111
+ expect(updated?.mode).toBe("alwaysOn");
112
+ expect(updated?.endpoint).toBe("srv:1");
113
+ expect(updated?.imageRef).toBe("img:1");
114
+ expect(updated?.lastHealthAt).toBe("2026-01-02T00:00:00.000Z");
115
+ expect(updated?.lastError).toBe("boom");
116
+ expect(updated?.pluginVersion).toBe("1.2.0");
117
+ expect(updated?.provider).toBe("opensandbox");
118
+ expect(updated?.sandboxName).toBe("plugin-patch-2");
119
+ expect(updated?.port).toBe(9090);
120
+ expect(updated?.idleTimeoutMs).toBe(1000);
121
+
122
+ expect(await store.updateStatus(tenantId, "nope", { status: "paused" })).toBeUndefined();
123
+ });
124
+
125
+ it("isolates tenants, lists ordered by plugin type, and deletes", async () => {
126
+ await store.upsert(record(tenantId, "list-b"));
127
+ await store.upsert(record(tenantId, "list-a"));
128
+ await store.upsert(record(otherTenantId, "list-a"));
129
+
130
+ const listed = (await store.listByTenant(tenantId)).filter((entry) =>
131
+ entry.pluginType.startsWith("list-"),
132
+ );
133
+ expect(listed.map((entry) => entry.pluginType)).toEqual(["list-a", "list-b"]);
134
+ expect((await store.listByTenant(otherTenantId)).every((entry) => entry.tenantId === otherTenantId)).toBe(true);
135
+
136
+ expect(await store.delete(tenantId, "list-a")).toBe(true);
137
+ expect(await store.delete(tenantId, "list-a")).toBe(false);
138
+ });
139
+ });
@@ -0,0 +1,74 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { Pool } from "pg";
3
+ import { PostgreSQLProjectRoomMessageStore } from "../stores/PostgreSQLProjectRoomMessageStore";
4
+ import { MigrationManager } from "../migrations/migration";
5
+ import { createProjectRoomTables } from "../migrations/project_room_migration";
6
+
7
+ const databaseUrl = process.env.TEST_DATABASE_URL;
8
+ const describeIfDatabase = databaseUrl ? describe : describe.skip;
9
+
10
+ /**
11
+ * Real-PostgreSQL contract for the source-less correlated interrupt write path
12
+ * used by Project Task HITL cards: a bot-authored message with no human source
13
+ * (`reply_to_message_id` NULL) that is idempotent on the caller key.
14
+ */
15
+ describeIfDatabase("PostgreSQLProjectRoomMessageStore correlated interrupt", () => {
16
+ let pool: Pool;
17
+ let store: PostgreSQLProjectRoomMessageStore;
18
+ const tenantId = `correlated-interrupt-${randomUUID()}`;
19
+
20
+ beforeAll(async () => {
21
+ pool = new Pool({ connectionString: databaseUrl });
22
+ const mm = new MigrationManager(pool);
23
+ mm.register(createProjectRoomTables);
24
+ await mm.migrate();
25
+ store = new PostgreSQLProjectRoomMessageStore({ pool });
26
+ });
27
+
28
+ afterEach(async () => {
29
+ await pool.query("DELETE FROM lattice_project_room_messages WHERE tenant_id = $1", [tenantId]);
30
+ });
31
+
32
+ afterAll(async () => {
33
+ await pool.end();
34
+ });
35
+
36
+ it("round-trips a source-less bot interrupt without a reply target", async () => {
37
+ const candidate = {
38
+ id: randomUUID(), tenantId, workspaceId: "w1", projectId: "p1", roomId: "r1",
39
+ author: { type: "bot" as const, membershipId: "m1", assistantId: "a1" },
40
+ content: { type: "text" as const, text: "```clarify\n{}\n```" }, mentions: [],
41
+ source: "agent" as const, sourceId: "interrupt:task:t1:run1", idempotencyKey: "room-interrupt:m1:task:t1:run1",
42
+ };
43
+
44
+ const first = await store.createIdempotent(candidate);
45
+ expect(first.author).toEqual({ type: "bot", membershipId: "m1", assistantId: "a1" });
46
+ expect(first.source).toBe("agent");
47
+ expect(first.sourceId).toBe("interrupt:task:t1:run1");
48
+ expect(first.replyToMessageId).toBeUndefined();
49
+ expect(first.idempotencyKey).toBe("room-interrupt:m1:task:t1:run1");
50
+
51
+ const reloaded = await store.findById(tenantId, first.id);
52
+ expect(reloaded).toMatchObject({ id: first.id, source: "agent", sourceId: "interrupt:task:t1:run1" });
53
+ expect(reloaded?.replyToMessageId).toBeUndefined();
54
+ });
55
+
56
+ it("is idempotent on the caller correlation key", async () => {
57
+ const base = {
58
+ tenantId, workspaceId: "w1", projectId: "p1", roomId: "r1",
59
+ author: { type: "bot" as const, membershipId: "m1", assistantId: "a1" },
60
+ content: { type: "text" as const, text: "```clarify\n{}\n```" }, mentions: [],
61
+ source: "agent" as const, sourceId: "interrupt:task:t2:run1", idempotencyKey: "room-interrupt:m1:task:t2:run1",
62
+ };
63
+
64
+ const first = await store.createIdempotent({ ...base, id: randomUUID() });
65
+ const second = await store.createIdempotent({ ...base, id: randomUUID() });
66
+ expect(second.id).toBe(first.id);
67
+
68
+ const rows = await pool.query<{ count: string }>(
69
+ "SELECT COUNT(*)::text AS count FROM lattice_project_room_messages WHERE tenant_id = $1 AND idempotency_key = $2",
70
+ [tenantId, base.idempotencyKey],
71
+ );
72
+ expect(rows.rows[0]?.count).toBe("1");
73
+ });
74
+ });
@@ -139,4 +139,27 @@ describe("PostgreSQLProjectRoomMessageStore", () => {
139
139
  await store.deleteChatOlderThan({ before: now, limit: 99_999 });
140
140
  expect(query.mock.calls[0][1]).toEqual([now, 1_000]);
141
141
  });
142
+
143
+ it("maps a source-less bot interrupt with a NULL reply target and caller idempotency", async () => {
144
+ const botAuthor = { type: "bot", membershipId: "m1", assistantId: "a1" };
145
+ query.mockResolvedValueOnce({
146
+ rows: [{
147
+ ...row, author: botAuthor, content: { type: "text", text: "```clarify\n{}\n```" },
148
+ mentions: [], reply_to_message_id: null, source: "agent",
149
+ source_id: "interrupt:task:t1:run1", idempotency_key: "room-interrupt:m1:task:t1:run1",
150
+ }],
151
+ });
152
+ const store = new PostgreSQLProjectRoomMessageStore({ pool });
153
+ await store.createIdempotent({
154
+ id: "candidate", tenantId: "tenant-1", workspaceId: "workspace-1", projectId: "project-1", roomId: "room-1",
155
+ author: botAuthor, content: { type: "text", text: "```clarify\n{}\n```" }, mentions: [],
156
+ source: "agent", sourceId: "interrupt:task:t1:run1", idempotencyKey: "room-interrupt:m1:task:t1:run1",
157
+ });
158
+ const params = query.mock.calls[0][1] as unknown[];
159
+ expect(params[8]).toBeNull();
160
+ expect(params[5]).toBe(JSON.stringify(botAuthor));
161
+ expect(params[9]).toBe("agent");
162
+ expect(params[10]).toBe("interrupt:task:t1:run1");
163
+ expect(params[11]).toBe("room-interrupt:m1:task:t1:run1");
164
+ });
142
165
  });
@@ -87,6 +87,10 @@ import { addProjectRoomMessageRetentionIndex } from "./migrations/project_room_m
87
87
  import { createUserPushSubscriptions } from "./migrations/user_push_subscription_migration";
88
88
  import { createDistributedLeases } from "./migrations/distributed_lease_migration";
89
89
  import { createOutboxTable } from "./migrations/outbox_migration";
90
+ import {
91
+ createPluginDeployments,
92
+ dropPluginDeploymentAuthToken,
93
+ } from "./migrations/plugin_deployment_migration";
90
94
  import {
91
95
  createModelProvidersTable,
92
96
  createModelProviderModelsTable,
@@ -103,6 +107,7 @@ import { PostgreSQLUserPushSubscriptionStore } from "./stores/PostgreSQLUserPush
103
107
  import { PostgreSQLModelProviderStore } from "./stores/PostgreSQLModelProviderStore";
104
108
  import { PostgreSQLLeaseStore } from "./stores/PostgreSQLLeaseStore";
105
109
  import { PostgreSQLOutboxStore } from "./stores/PostgreSQLOutboxStore";
110
+ import { PostgreSQLPluginDeploymentStore } from "./stores/PostgreSQLPluginDeploymentStore";
106
111
 
107
112
  export async function createPgStoreConfig(connectionString: string) {
108
113
  const pool = new Pool({ connectionString });
@@ -179,6 +184,8 @@ export async function createPgStoreConfig(connectionString: string) {
179
184
  mm.register(createDistributedLeases); // v185
180
185
  mm.register(createOutboxTable); // v187
181
186
  mm.register(addConnectionSecretFields); // v188
187
+ mm.register(createPluginDeployments); // v189
188
+ mm.register(dropPluginDeploymentAuthToken); // v190
182
189
 
183
190
  await mm.migrate();
184
191
 
@@ -227,6 +234,7 @@ export async function createPgStoreConfig(connectionString: string) {
227
234
  userPushSubscription: new PostgreSQLUserPushSubscriptionStore(opts),
228
235
  lease: new PostgreSQLLeaseStore(opts),
229
236
  outbox: new PostgreSQLOutboxStore(opts),
237
+ pluginDeployment: new PostgreSQLPluginDeploymentStore(opts),
230
238
  vectorStoreProvider: new PGVectorStoreProvider(pool, connectionString),
231
239
  checkpoint,
232
240
  };
package/src/index.ts CHANGED
@@ -80,6 +80,8 @@ export * from "./stores/PostgreSQLProjectBotMembershipStore";
80
80
  export * from "./stores/PostgreSQLProjectRoomMessageStore";
81
81
  export * from "./stores/PostgreSQLProjectRoomReadStateStore";
82
82
  export * from "./migrations/project_room_migration";
83
+ export * from "./stores/PostgreSQLPluginDeploymentStore";
84
+ export * from "./migrations/plugin_deployment_migration";
83
85
 
84
86
  export * from "./PGVectorStoreProvider";
85
87
 
@@ -0,0 +1,67 @@
1
+ import type { Migration } from "./migration";
2
+
3
+ /** Creates the durable plugin-deployment table. */
4
+ export const createPluginDeployments: Migration = {
5
+ version: 189,
6
+ name: "create_plugin_deployments",
7
+ up: async (client) => {
8
+ await client.query(`
9
+ CREATE TABLE IF NOT EXISTS lattice_plugin_deployments (
10
+ id VARCHAR(255) PRIMARY KEY,
11
+ tenant_id VARCHAR(255) NOT NULL,
12
+ plugin_type VARCHAR(255) NOT NULL,
13
+ plugin_version VARCHAR(255) NOT NULL,
14
+ mode VARCHAR(16) NOT NULL,
15
+ status VARCHAR(32) NOT NULL,
16
+ provider VARCHAR(64) NOT NULL,
17
+ sandbox_name VARCHAR(255) NOT NULL,
18
+ port INTEGER NOT NULL,
19
+ endpoint TEXT,
20
+ image_ref TEXT,
21
+ auth_token TEXT NOT NULL,
22
+ idle_timeout_ms INTEGER NOT NULL,
23
+ last_health_at TIMESTAMPTZ,
24
+ last_error TEXT,
25
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
26
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
27
+ UNIQUE (tenant_id, plugin_type)
28
+ )
29
+ `);
30
+ await client.query(`
31
+ CREATE INDEX IF NOT EXISTS idx_lattice_plugin_deployments_tenant
32
+ ON lattice_plugin_deployments (tenant_id)
33
+ `);
34
+ },
35
+ down: async (client) => {
36
+ await client.query("DROP INDEX IF EXISTS idx_lattice_plugin_deployments_tenant");
37
+ await client.query("DROP TABLE IF EXISTS lattice_plugin_deployments");
38
+ },
39
+ };
40
+
41
+ /**
42
+ * Drops the now-unused `auth_token` column introduced by
43
+ * {@link createPluginDeployments}.
44
+ *
45
+ * Resident plugin hosts no longer receive a per-deployment bearer token; the
46
+ * sandbox service key plus loopback transport replace it. This is a separate
47
+ * migration (rather than editing v189 in place) because migration identity is
48
+ * the name: databases that already applied the released v189 keep its
49
+ * `auth_token NOT NULL` column, so the token removal must be applied as a new
50
+ * forward migration.
51
+ */
52
+ export const dropPluginDeploymentAuthToken: Migration = {
53
+ version: 190,
54
+ name: "drop_plugin_deployment_auth_token",
55
+ up: async (client) => {
56
+ await client.query(`
57
+ ALTER TABLE lattice_plugin_deployments
58
+ DROP COLUMN IF EXISTS auth_token
59
+ `);
60
+ },
61
+ down: async (client) => {
62
+ await client.query(`
63
+ ALTER TABLE lattice_plugin_deployments
64
+ ADD COLUMN IF NOT EXISTS auth_token TEXT NOT NULL DEFAULT ''
65
+ `);
66
+ },
67
+ };
@@ -0,0 +1,189 @@
1
+ import type {
2
+ PluginDeployment,
3
+ PluginDeploymentStatus,
4
+ PluginDeploymentStore,
5
+ } from "@axiom-lattice/protocols";
6
+ import type { Pool } from "pg";
7
+
8
+ type Row = {
9
+ id: unknown; tenant_id: unknown; plugin_type: unknown; plugin_version: unknown;
10
+ mode: unknown; status: unknown; provider: unknown; sandbox_name: unknown;
11
+ port: unknown; endpoint: unknown; image_ref: unknown;
12
+ idle_timeout_ms: unknown; last_health_at: unknown; last_error: unknown;
13
+ created_at: unknown; updated_at: unknown;
14
+ };
15
+
16
+ const columns =
17
+ "id, tenant_id, plugin_type, plugin_version, mode, status, provider, sandbox_name, " +
18
+ "port, endpoint, image_ref, idle_timeout_ms, last_health_at, last_error, " +
19
+ "created_at, updated_at";
20
+
21
+ const statuses: readonly PluginDeploymentStatus[] = [
22
+ "requested",
23
+ "provisioning",
24
+ "running",
25
+ "paused",
26
+ "stopping",
27
+ "stopped",
28
+ "failed",
29
+ ];
30
+
31
+ function isStatus(value: unknown): value is PluginDeploymentStatus {
32
+ return typeof value === "string" && (statuses as readonly string[]).includes(value);
33
+ }
34
+
35
+ function mapRow(row: Row): PluginDeployment {
36
+ if (
37
+ typeof row.id !== "string" ||
38
+ typeof row.tenant_id !== "string" ||
39
+ typeof row.plugin_type !== "string" ||
40
+ typeof row.plugin_version !== "string" ||
41
+ (row.mode !== "onDemand" && row.mode !== "alwaysOn") ||
42
+ !isStatus(row.status) ||
43
+ typeof row.provider !== "string" ||
44
+ typeof row.sandbox_name !== "string" ||
45
+ typeof row.port !== "number" ||
46
+ typeof row.idle_timeout_ms !== "number" ||
47
+ !(row.created_at instanceof Date) || Number.isNaN(row.created_at.getTime()) ||
48
+ !(row.updated_at instanceof Date) || Number.isNaN(row.updated_at.getTime())
49
+ ) {
50
+ throw new Error("Invalid plugin deployment row");
51
+ }
52
+ return {
53
+ id: row.id,
54
+ tenantId: row.tenant_id,
55
+ pluginType: row.plugin_type,
56
+ pluginVersion: row.plugin_version,
57
+ mode: row.mode,
58
+ status: row.status,
59
+ provider: row.provider,
60
+ sandboxName: row.sandbox_name,
61
+ port: row.port,
62
+ ...(typeof row.endpoint === "string" ? { endpoint: row.endpoint } : {}),
63
+ ...(typeof row.image_ref === "string" ? { imageRef: row.image_ref } : {}),
64
+ idleTimeoutMs: row.idle_timeout_ms,
65
+ ...(row.last_health_at instanceof Date && !Number.isNaN(row.last_health_at.getTime())
66
+ ? { lastHealthAt: row.last_health_at.toISOString() }
67
+ : {}),
68
+ ...(typeof row.last_error === "string" ? { lastError: row.last_error } : {}),
69
+ createdAt: row.created_at.toISOString(),
70
+ updatedAt: row.updated_at.toISOString(),
71
+ };
72
+ }
73
+
74
+ /** Persists tenant-scoped plugin deployments in PostgreSQL. */
75
+ export class PostgreSQLPluginDeploymentStore implements PluginDeploymentStore {
76
+ /** Creates a store using an externally managed shared pool. */
77
+ constructor(options: { pool: Pool }) {
78
+ this.pool = options.pool;
79
+ }
80
+
81
+ private readonly pool: Pool;
82
+
83
+ /** Lists every deployment for a tenant, ordered by plugin type. */
84
+ async listByTenant(tenantId: string): Promise<PluginDeployment[]> {
85
+ const result = await this.pool.query<Row>(
86
+ `SELECT ${columns} FROM lattice_plugin_deployments WHERE tenant_id = $1 ORDER BY plugin_type`,
87
+ [tenantId],
88
+ );
89
+ return result.rows.map(mapRow);
90
+ }
91
+
92
+ /** Returns the deployment for a tenant + plugin type, or undefined. */
93
+ async get(tenantId: string, pluginType: string): Promise<PluginDeployment | undefined> {
94
+ const result = await this.pool.query<Row>(
95
+ `SELECT ${columns} FROM lattice_plugin_deployments WHERE tenant_id = $1 AND plugin_type = $2`,
96
+ [tenantId, pluginType],
97
+ );
98
+ return result.rows[0] ? mapRow(result.rows[0]) : undefined;
99
+ }
100
+
101
+ /** Upserts a deployment keyed by tenant + plugin type, preserving the original id and createdAt. */
102
+ async upsert(deployment: PluginDeployment): Promise<PluginDeployment> {
103
+ const result = await this.pool.query<Row>(
104
+ `INSERT INTO lattice_plugin_deployments (${columns})
105
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
106
+ ON CONFLICT (tenant_id, plugin_type) DO UPDATE SET
107
+ plugin_version = EXCLUDED.plugin_version,
108
+ mode = EXCLUDED.mode,
109
+ status = EXCLUDED.status,
110
+ provider = EXCLUDED.provider,
111
+ sandbox_name = EXCLUDED.sandbox_name,
112
+ port = EXCLUDED.port,
113
+ endpoint = EXCLUDED.endpoint,
114
+ image_ref = EXCLUDED.image_ref,
115
+ idle_timeout_ms = EXCLUDED.idle_timeout_ms,
116
+ last_health_at = EXCLUDED.last_health_at,
117
+ last_error = EXCLUDED.last_error,
118
+ updated_at = NOW()
119
+ RETURNING ${columns}`,
120
+ [
121
+ deployment.id,
122
+ deployment.tenantId,
123
+ deployment.pluginType,
124
+ deployment.pluginVersion,
125
+ deployment.mode,
126
+ deployment.status,
127
+ deployment.provider,
128
+ deployment.sandboxName,
129
+ deployment.port,
130
+ deployment.endpoint ?? null,
131
+ deployment.imageRef ?? null,
132
+ deployment.idleTimeoutMs,
133
+ deployment.lastHealthAt ? new Date(deployment.lastHealthAt) : null,
134
+ deployment.lastError ?? null,
135
+ new Date(deployment.createdAt),
136
+ new Date(deployment.updatedAt),
137
+ ],
138
+ );
139
+ return mapRow(result.rows[0]);
140
+ }
141
+
142
+ /** Applies a partial update to mutable columns; returns undefined when no row exists. */
143
+ async updateStatus(
144
+ tenantId: string,
145
+ pluginType: string,
146
+ patch: Partial<Omit<PluginDeployment, "id" | "tenantId" | "pluginType">>,
147
+ ): Promise<PluginDeployment | undefined> {
148
+ const assignments: string[] = [];
149
+ const params: unknown[] = [];
150
+ const assign = (column: string, value: unknown): void => {
151
+ params.push(value);
152
+ assignments.push(`${column} = $${params.length}`);
153
+ };
154
+
155
+ if (patch.status !== undefined) assign("status", patch.status);
156
+ if (patch.mode !== undefined) assign("mode", patch.mode);
157
+ if (patch.endpoint !== undefined) assign("endpoint", patch.endpoint);
158
+ if (patch.imageRef !== undefined) assign("image_ref", patch.imageRef);
159
+ if (patch.idleTimeoutMs !== undefined) assign("idle_timeout_ms", patch.idleTimeoutMs);
160
+ if (patch.lastHealthAt !== undefined) assign("last_health_at", new Date(patch.lastHealthAt));
161
+ if (patch.lastError !== undefined) assign("last_error", patch.lastError);
162
+ if (patch.pluginVersion !== undefined) assign("plugin_version", patch.pluginVersion);
163
+ if (patch.provider !== undefined) assign("provider", patch.provider);
164
+ if (patch.sandboxName !== undefined) assign("sandbox_name", patch.sandboxName);
165
+ if (patch.port !== undefined) assign("port", patch.port);
166
+ assignments.push("updated_at = NOW()");
167
+
168
+ params.push(tenantId, pluginType);
169
+ const tenantParam = params.length - 1;
170
+ const pluginParam = params.length;
171
+
172
+ const result = await this.pool.query<Row>(
173
+ `UPDATE lattice_plugin_deployments SET ${assignments.join(", ")}
174
+ WHERE tenant_id = $${tenantParam} AND plugin_type = $${pluginParam}
175
+ RETURNING ${columns}`,
176
+ params,
177
+ );
178
+ return result.rows[0] ? mapRow(result.rows[0]) : undefined;
179
+ }
180
+
181
+ /** Deletes a deployment by tenant + plugin type; returns whether a row was removed. */
182
+ async delete(tenantId: string, pluginType: string): Promise<boolean> {
183
+ const result = await this.pool.query(
184
+ `DELETE FROM lattice_plugin_deployments WHERE tenant_id = $1 AND plugin_type = $2`,
185
+ [tenantId, pluginType],
186
+ );
187
+ return (result.rowCount ?? 0) > 0;
188
+ }
189
+ }