@axiom-lattice/pg-stores 3.0.2 → 3.1.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.
@@ -5,11 +5,13 @@
5
5
  import { Pool } from "pg";
6
6
  import type { PoolConfig } from "pg";
7
7
  import {
8
+ assertGenericProjectConfig,
8
9
  ProjectStore,
9
10
  Project,
10
11
  ProjectFilter,
11
12
  CreateProjectRequest,
12
13
  UpdateProjectRequest,
14
+ UpdateProjectCapabilityBundlesResult,
13
15
  } from "@axiom-lattice/protocols";
14
16
  import { MigrationManager } from "../migrations/migration";
15
17
  import { createProjectsTable } from "../migrations/project_migrations";
@@ -218,37 +220,70 @@ export class PostgreSQLProjectStore implements ProjectStore {
218
220
  id: string,
219
221
  data: CreateProjectRequest
220
222
  ): Promise<Project> {
223
+ assertGenericProjectConfig(data.config);
221
224
  await this.ensureInitialized();
222
225
 
223
226
  const now = new Date();
224
227
  const kind = data.kind || "business";
225
228
 
226
- await this.pool.query(
227
- `
228
- INSERT INTO lattice_projects (id, tenant_id, workspace_id, name, description, config, kind, created_at, updated_at)
229
+ const client = await this.pool.connect();
230
+ try {
231
+ await client.query("BEGIN");
232
+ await client.query(
233
+ "SELECT pg_advisory_xact_lock(hashtextextended($1 || ':project-mutations', 0))",
234
+ [tenantId],
235
+ );
236
+ await client.query(
237
+ "SELECT pg_advisory_xact_lock(hashtextextended($2 || ':project:' || $1, 0))",
238
+ [id, tenantId],
239
+ );
240
+ const existing = await client.query<{ config: Record<string, unknown> | null }>(
241
+ "SELECT config FROM lattice_projects WHERE id = $1 AND tenant_id = $2 FOR UPDATE",
242
+ [id, tenantId],
243
+ );
244
+ const ids = existing.rows[0]?.config?.capabilityBundleIds;
245
+ const bundleIds = Array.isArray(ids) && ids.every((bundleId) => typeof bundleId === "string")
246
+ ? [...ids].sort()
247
+ : [];
248
+ if (bundleIds.length > 0) {
249
+ await client.query(
250
+ `SELECT pg_advisory_xact_lock(hashtextextended($1 || ':' || bundle_id, 0))
251
+ FROM unnest($2::text[]) AS bundle_id
252
+ ORDER BY bundle_id`,
253
+ [tenantId, bundleIds],
254
+ );
255
+ }
256
+ const result = await client.query<{
257
+ id: string; tenant_id: string; workspace_id: string; name: string;
258
+ description: string | null; config: unknown | null; kind: string | null;
259
+ created_at: Date; updated_at: Date;
260
+ }>(
261
+ `INSERT INTO lattice_projects (id, tenant_id, workspace_id, name, description, config, kind, created_at, updated_at)
229
262
  VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
230
263
  ON CONFLICT (id, tenant_id) DO UPDATE SET
231
264
  workspace_id = EXCLUDED.workspace_id,
232
265
  name = EXCLUDED.name,
233
266
  description = EXCLUDED.description,
234
- config = EXCLUDED.config,
267
+ config = CASE
268
+ WHEN lattice_projects.config ? 'capabilityBundleIds'
269
+ THEN COALESCE(EXCLUDED.config, '{}'::jsonb)
270
+ || jsonb_build_object('capabilityBundleIds', lattice_projects.config->'capabilityBundleIds')
271
+ ELSE EXCLUDED.config
272
+ END,
235
273
  kind = EXCLUDED.kind,
236
274
  updated_at = EXCLUDED.updated_at
275
+ RETURNING id, tenant_id, workspace_id, name, description, config, kind, created_at, updated_at
237
276
  `,
238
277
  [id, tenantId, workspaceId, data.name, data.description || null, data.config || null, kind, now, now]
239
- );
240
-
241
- return {
242
- id,
243
- tenantId,
244
- workspaceId,
245
- name: data.name,
246
- description: data.description,
247
- config: data.config,
248
- kind,
249
- createdAt: now,
250
- updatedAt: now,
251
- };
278
+ );
279
+ await client.query("COMMIT");
280
+ return this.mapRowToProject(result.rows[0]);
281
+ } catch (error) {
282
+ await client.query("ROLLBACK");
283
+ throw error;
284
+ } finally {
285
+ client.release();
286
+ }
252
287
  }
253
288
 
254
289
  /**
@@ -263,17 +298,12 @@ export class PostgreSQLProjectStore implements ProjectStore {
263
298
  id: string,
264
299
  updates: UpdateProjectRequest
265
300
  ): Promise<Project | null> {
301
+ assertGenericProjectConfig(updates.config);
266
302
  await this.ensureInitialized();
267
303
 
268
- // Get existing project
269
- const existing = await this.getProjectById(tenantId, id);
270
- if (!existing) {
271
- return null;
272
- }
273
-
274
304
  // Build update query dynamically based on provided fields
275
305
  const updateFields: string[] = [];
276
- const updateValues: any[] = [];
306
+ const updateValues: unknown[] = [];
277
307
  let paramIndex = 1;
278
308
 
279
309
  if (updates.name !== undefined) {
@@ -287,8 +317,14 @@ export class PostgreSQLProjectStore implements ProjectStore {
287
317
  }
288
318
 
289
319
  if (updates.config !== undefined) {
290
- updateFields.push(`config = $${paramIndex++}`);
291
- updateValues.push(updates.config || null);
320
+ const configParam = `$${paramIndex++}`;
321
+ updateFields.push(`config = CASE
322
+ WHEN config ? 'capabilityBundleIds'
323
+ THEN COALESCE(${configParam}::jsonb, '{}'::jsonb)
324
+ || jsonb_build_object('capabilityBundleIds', config->'capabilityBundleIds')
325
+ ELSE ${configParam}::jsonb
326
+ END`);
327
+ updateValues.push(updates.config);
292
328
  }
293
329
 
294
330
  if (updates.kind !== undefined) {
@@ -298,28 +334,63 @@ export class PostgreSQLProjectStore implements ProjectStore {
298
334
 
299
335
  if (updateFields.length === 0) {
300
336
  // No fields to update
301
- return existing;
337
+ return await this.getProjectById(tenantId, id);
302
338
  }
303
339
 
304
340
  // Always update updated_at
305
341
  updateFields.push(`updated_at = $${paramIndex++}`);
306
342
  updateValues.push(new Date());
307
343
 
308
- // Add id and tenant_id for WHERE clause
309
- updateValues.push(id);
310
- updateValues.push(tenantId);
311
-
312
- await this.pool.query(
313
- `
314
- UPDATE lattice_projects
315
- SET ${updateFields.join(", ")}
316
- WHERE id = $${paramIndex} AND tenant_id = $${paramIndex + 1}
317
- `,
318
- updateValues
319
- );
320
-
321
- // Return updated project
322
- return await this.getProjectById(tenantId, id);
344
+ updateValues.push(id, tenantId);
345
+ const client = await this.pool.connect();
346
+ try {
347
+ await client.query("BEGIN");
348
+ await client.query(
349
+ "SELECT pg_advisory_xact_lock(hashtextextended($1 || ':project-mutations', 0))",
350
+ [tenantId],
351
+ );
352
+ await client.query(
353
+ "SELECT pg_advisory_xact_lock(hashtextextended($2 || ':project:' || $1, 0))",
354
+ [id, tenantId],
355
+ );
356
+ const existing = await client.query<{ config: Record<string, unknown> | null }>(
357
+ "SELECT config FROM lattice_projects WHERE id = $1 AND tenant_id = $2 FOR UPDATE",
358
+ [id, tenantId],
359
+ );
360
+ if (!existing.rows[0]) {
361
+ await client.query("COMMIT");
362
+ return null;
363
+ }
364
+ const ids = existing.rows[0].config?.capabilityBundleIds;
365
+ const bundleIds = Array.isArray(ids) && ids.every((bundleId) => typeof bundleId === "string")
366
+ ? [...ids].sort()
367
+ : [];
368
+ if (bundleIds.length > 0) {
369
+ await client.query(
370
+ `SELECT pg_advisory_xact_lock(hashtextextended($1 || ':' || bundle_id, 0))
371
+ FROM unnest($2::text[]) AS bundle_id
372
+ ORDER BY bundle_id`,
373
+ [tenantId, bundleIds],
374
+ );
375
+ }
376
+ const result = await client.query<{
377
+ id: string; tenant_id: string; workspace_id: string; name: string;
378
+ description: string | null; config: unknown | null; kind: string | null;
379
+ created_at: Date; updated_at: Date;
380
+ }>(
381
+ `UPDATE lattice_projects SET ${updateFields.join(", ")}
382
+ WHERE id = $${paramIndex} AND tenant_id = $${paramIndex + 1}
383
+ RETURNING id, tenant_id, workspace_id, name, description, config, kind, created_at, updated_at`,
384
+ updateValues,
385
+ );
386
+ await client.query("COMMIT");
387
+ return this.mapRowToProject(result.rows[0]);
388
+ } catch (error) {
389
+ await client.query("ROLLBACK");
390
+ throw error;
391
+ } finally {
392
+ client.release();
393
+ }
323
394
  }
324
395
 
325
396
  /**
@@ -327,15 +398,124 @@ export class PostgreSQLProjectStore implements ProjectStore {
327
398
  */
328
399
  async deleteProject(tenantId: string, id: string): Promise<boolean> {
329
400
  await this.ensureInitialized();
401
+ const client = await this.pool.connect();
402
+ try {
403
+ await client.query("BEGIN");
404
+ await client.query(
405
+ "SELECT pg_advisory_xact_lock(hashtextextended($1 || ':project-mutations', 0))",
406
+ [tenantId],
407
+ );
408
+ await client.query(
409
+ "SELECT pg_advisory_xact_lock(hashtextextended($2 || ':project:' || $1, 0))",
410
+ [id, tenantId],
411
+ );
412
+ const selected = await client.query<{ config: Record<string, unknown> | null }>(
413
+ "SELECT config FROM lattice_projects WHERE id = $1 AND tenant_id = $2 FOR UPDATE",
414
+ [id, tenantId],
415
+ );
416
+ if (!selected.rows[0]) {
417
+ await client.query("COMMIT");
418
+ return false;
419
+ }
420
+ const ids = selected.rows[0].config?.capabilityBundleIds;
421
+ const bundleIds = Array.isArray(ids) && ids.every((bundleId) => typeof bundleId === "string")
422
+ ? [...ids].sort()
423
+ : [];
424
+ if (bundleIds.length > 0) {
425
+ await client.query(
426
+ `SELECT pg_advisory_xact_lock(hashtextextended($1 || ':' || bundle_id, 0))
427
+ FROM unnest($2::text[]) AS bundle_id
428
+ ORDER BY bundle_id`,
429
+ [tenantId, bundleIds],
430
+ );
431
+ }
432
+ const result = await client.query(
433
+ "DELETE FROM lattice_projects WHERE id = $1 AND tenant_id = $2",
434
+ [id, tenantId],
435
+ );
436
+ await client.query("COMMIT");
437
+ return result.rowCount !== null && result.rowCount > 0;
438
+ } catch (error) {
439
+ await client.query("ROLLBACK");
440
+ throw error;
441
+ } finally {
442
+ client.release();
443
+ }
444
+ }
330
445
 
331
- const result = await this.pool.query(
332
- `
333
- DELETE FROM lattice_projects
334
- WHERE id = $1 AND tenant_id = $2
335
- `,
336
- [id, tenantId]
337
- );
446
+ async updateCapabilityBundleIds(tenantId: string, projectId: string, bundleIds: string[], expectedRevisions: Record<string, string> = {}): Promise<UpdateProjectCapabilityBundlesResult> {
447
+ await this.ensureInitialized();
448
+ const client = await this.pool.connect();
449
+ try {
450
+ await client.query("BEGIN");
451
+ await client.query(
452
+ "SELECT pg_advisory_xact_lock(hashtextextended($1 || ':project-mutations', 0))",
453
+ [tenantId],
454
+ );
455
+ await client.query(
456
+ "SELECT pg_advisory_xact_lock(hashtextextended($2 || ':project:' || $1, 0))",
457
+ [projectId, tenantId],
458
+ );
459
+ await client.query(
460
+ "SELECT id FROM lattice_projects WHERE id = $1 AND tenant_id = $2 FOR UPDATE",
461
+ [projectId, tenantId],
462
+ );
463
+ if (bundleIds.length > 0) {
464
+ await client.query(
465
+ `SELECT pg_advisory_xact_lock(hashtextextended($1 || ':' || bundle_id, 0))
466
+ FROM unnest($2::text[]) AS bundle_id
467
+ ORDER BY bundle_id`,
468
+ [tenantId, [...bundleIds].sort()],
469
+ );
470
+ if (Object.keys(expectedRevisions).length > 0) {
471
+ const revisions = await client.query<{ id: string; updated_at: string }>("SELECT id, updated_at::text AS updated_at FROM lattice_capability_bundles WHERE tenant_id = $1 AND id = ANY($2::uuid[]) FOR UPDATE", [tenantId, bundleIds]);
472
+ if (revisions.rows.some((bundle) => expectedRevisions[bundle.id] !== undefined && expectedRevisions[bundle.id] !== bundle.updated_at)) {
473
+ await client.query("ROLLBACK");
474
+ return { status: "bundle_conflict" };
475
+ }
476
+ }
477
+ }
478
+ const result = await client.query(
479
+ `UPDATE lattice_projects
480
+ SET config = COALESCE(config, '{}'::jsonb) || jsonb_build_object('capabilityBundleIds', $3::jsonb), updated_at = NOW()
481
+ WHERE id = $1 AND tenant_id = $2
482
+ AND NOT EXISTS (
483
+ SELECT 1 FROM unnest($4::uuid[]) AS bundle_id
484
+ WHERE NOT EXISTS (
485
+ SELECT 1 FROM lattice_capability_bundles
486
+ WHERE tenant_id = $2 AND id = bundle_id
487
+ )
488
+ )
489
+ RETURNING id, tenant_id, workspace_id, name, description, config, kind, created_at, updated_at`,
490
+ [projectId, tenantId, JSON.stringify(bundleIds), bundleIds],
491
+ );
492
+ if (result.rows[0]) {
493
+ await client.query("COMMIT");
494
+ return { status: "updated", project: this.mapRowToProject(result.rows[0]) };
495
+ }
496
+ const project = await client.query(
497
+ "SELECT id FROM lattice_projects WHERE id = $1 AND tenant_id = $2",
498
+ [projectId, tenantId],
499
+ );
500
+ await client.query("COMMIT");
501
+ return project.rows.length > 0 ? { status: "bundle_not_found" } : { status: "project_not_found" };
502
+ } catch (error) {
503
+ await client.query("ROLLBACK");
504
+ throw error;
505
+ } finally {
506
+ client.release();
507
+ }
508
+ }
338
509
 
339
- return result.rowCount !== null && result.rowCount > 0;
510
+ async isCapabilityBundleReferenced(tenantId: string, bundleId: string): Promise<boolean> {
511
+ await this.ensureInitialized();
512
+ const result = await this.pool.query<{ exists: number }>(
513
+ `SELECT 1 AS exists FROM lattice_projects
514
+ WHERE tenant_id = $1
515
+ AND jsonb_typeof(config->'capabilityBundleIds') = 'array'
516
+ AND COALESCE(config->'capabilityBundleIds', '[]'::jsonb) ? $2
517
+ LIMIT 1`, [tenantId, bundleId],
518
+ );
519
+ return result.rows.length > 0;
340
520
  }
341
521
  }
@@ -1,5 +1,6 @@
1
1
  import type { Pool } from "pg";
2
2
  import type { TaskWorkItemStore, TaskWorkItem, CreateWorkItemRequest, CreateWorkItemIfAbsentRequest, TaskWorkItemListFilter } from "@axiom-lattice/protocols";
3
+ import { MAX_PENDING_EXECUTION_RESULTS_LIMIT } from "@axiom-lattice/protocols";
3
4
  import { v4 } from "uuid";
4
5
 
5
6
  export class PostgreSQLTaskWorkItemStore implements TaskWorkItemStore {
@@ -87,6 +88,44 @@ export class PostgreSQLTaskWorkItemStore implements TaskWorkItemStore {
87
88
  return result.rows.map((row: Record<string, unknown>) => this.rowToItem(row));
88
89
  }
89
90
 
91
+ /** List pending execution results using one bounded PostgreSQL anti-join query. */
92
+ async listPendingExecutionResults(params: {
93
+ tenantId: string;
94
+ taskId: string;
95
+ limit: number;
96
+ }): Promise<TaskWorkItem[]> {
97
+ if (!Number.isSafeInteger(params.limit)
98
+ || params.limit < 0
99
+ || params.limit > MAX_PENDING_EXECUTION_RESULTS_LIMIT) {
100
+ const error = new RangeError(`limit must be a safe integer between 0 and ${MAX_PENDING_EXECUTION_RESULTS_LIMIT}`) as RangeError & {
101
+ code: "INVALID_LIMIT";
102
+ };
103
+ error.code = "INVALID_LIMIT";
104
+ throw error;
105
+ }
106
+ if (params.limit === 0) return [];
107
+ const result = await this.pool.query(
108
+ `SELECT result.*
109
+ FROM lattice_task_work_items AS result
110
+ WHERE result.tenant_id = $1
111
+ AND result.task_id = $2
112
+ AND result.action = 'execution_result'
113
+ AND result.event_key COLLATE "C" ~ '^execution-result:[A-Za-z0-9._:-]+$'
114
+ AND NOT EXISTS (
115
+ SELECT 1
116
+ FROM lattice_task_work_items AS reconciled
117
+ WHERE reconciled.tenant_id = result.tenant_id
118
+ AND reconciled.task_id = result.task_id
119
+ AND reconciled.action = 'execution_reconciled'
120
+ AND reconciled.detail ->> 'executionResultId' = result.event_key
121
+ )
122
+ ORDER BY result.created_at DESC, result.id DESC
123
+ LIMIT $3`,
124
+ [params.tenantId, params.taskId, params.limit],
125
+ );
126
+ return result.rows.map((row: Record<string, unknown>) => this.rowToItem(row));
127
+ }
128
+
90
129
  private rowToItem(row: Record<string, unknown>): TaskWorkItem {
91
130
  return {
92
131
  id: row.id as string,
@@ -11,6 +11,7 @@ import type {
11
11
  PendingMessage,
12
12
  AddMessageParams,
13
13
  ThreadInfo,
14
+ QueueScope,
14
15
  } from "@axiom-lattice/core";
15
16
  import { MigrationManager } from "../migrations/migration";
16
17
  import { createThreadMessageQueueTable } from "../migrations/thread_message_queue_migrations";
@@ -108,12 +109,51 @@ export class ThreadMessageQueueStore implements IMessageQueueStore {
108
109
  (id, thread_id, tenant_id, assistant_id, workspace_id, project_id, message_content, message_type, sequence_order, priority, command, custom_run_config)
109
110
  VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
110
111
  RETURNING *`,
111
- [id || crypto.randomUUID(), threadId, tenantId, assistantId, workspaceId || null, projectId || null, JSON.stringify(content), type, nextSeq, priority, command ? JSON.stringify(command) : null, custom_run_config ? JSON.stringify(custom_run_config) : null]
112
+ [id || crypto.randomUUID(), threadId, tenantId, assistantId, workspaceId ?? null, projectId ?? null, JSON.stringify(content), type, nextSeq, priority, command ? JSON.stringify(command) : null, custom_run_config ? JSON.stringify(custom_run_config) : null]
112
113
  );
113
114
 
114
115
  return this.rowToMessage(result.rows[0]);
115
116
  }
116
117
 
118
+ async addMessageIfCapacity(params: AddMessageParams, maxSize: number): Promise<boolean> {
119
+ if (maxSize === Infinity) {
120
+ await this.addMessage(params);
121
+ return true;
122
+ }
123
+ const client = await this.pool.connect();
124
+ try {
125
+ await client.query("BEGIN");
126
+ const scope = { tenantId: params.tenantId, assistantId: params.assistantId, workspaceId: params.workspaceId, projectId: params.projectId };
127
+ const lockKey = `${params.tenantId}:${params.assistantId}:${params.threadId}:${params.workspaceId ?? ""}:${params.projectId ?? ""}`;
128
+ await client.query("SELECT pg_advisory_xact_lock(hashtext($1))", [lockKey]);
129
+ const filter = scopeClause(scope, 2);
130
+ const count = await client.query(
131
+ `SELECT COUNT(*) as count FROM lattice_thread_message_queue WHERE thread_id = $1 AND status = 'pending'${filter.sql}`,
132
+ [params.threadId, ...filter.params],
133
+ );
134
+ if (parseInt(count.rows[0].count, 10) >= maxSize) {
135
+ await client.query("ROLLBACK");
136
+ return false;
137
+ }
138
+ const seq = await client.query(
139
+ `SELECT COALESCE(MAX(sequence_order), 0) + 1 as next_seq FROM lattice_thread_message_queue WHERE thread_id = $1`,
140
+ [params.threadId],
141
+ );
142
+ const result = await client.query(
143
+ `INSERT INTO lattice_thread_message_queue (id, thread_id, tenant_id, assistant_id, workspace_id, project_id, message_content, message_type, sequence_order, priority, command, custom_run_config)
144
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING *`,
145
+ [params.id || crypto.randomUUID(), params.threadId, params.tenantId, params.assistantId, params.workspaceId ?? null, params.projectId ?? null, JSON.stringify(params.content), params.type || "human", seq.rows[0].next_seq, params.priority ?? 0, params.command ? JSON.stringify(params.command) : null, params.custom_run_config ? JSON.stringify(params.custom_run_config) : null],
146
+ );
147
+ await client.query("COMMIT");
148
+ return Boolean(result.rows[0]);
149
+ } catch (error) {
150
+ await client.query("ROLLBACK");
151
+ throw error;
152
+ } finally {
153
+ client.release();
154
+ }
155
+ }
156
+
117
157
  /**
118
158
  * Add message at head of queue (high priority, e.g., STEER/Command messages)
119
159
  * Uses priority=100 to ensure message is processed first
@@ -139,7 +179,7 @@ export class ThreadMessageQueueStore implements IMessageQueueStore {
139
179
  (id, thread_id, tenant_id, assistant_id, workspace_id, project_id, message_content, message_type, sequence_order, priority, command, custom_run_config)
140
180
  VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 100, $10, $11)
141
181
  RETURNING *`,
142
- [id || crypto.randomUUID(), threadId, resolvedTenantId, resolvedAssistantId, workspaceId || null, projectId || null, JSON.stringify(content), type, nextSeq, command ? JSON.stringify(command) : null, custom_run_config ? JSON.stringify(custom_run_config) : null]
182
+ [id || crypto.randomUUID(), threadId, resolvedTenantId, resolvedAssistantId, workspaceId ?? null, projectId ?? null, JSON.stringify(content), type, nextSeq, command ? JSON.stringify(command) : null, custom_run_config ? JSON.stringify(custom_run_config) : null]
143
183
  );
144
184
 
145
185
  return this.rowToMessage(result.rows[0]);
@@ -148,12 +188,13 @@ export class ThreadMessageQueueStore implements IMessageQueueStore {
148
188
  /**
149
189
  * Get pending messages for thread
150
190
  */
151
- async getPendingMessages(threadId: string): Promise<PendingMessage[]> {
191
+ async getPendingMessages(threadId: string, scope?: QueueScope): Promise<PendingMessage[]> {
192
+ const filter = scopeClause(scope, 2);
152
193
  const result = await this.pool.query(
153
- `SELECT * FROM lattice_thread_message_queue
154
- WHERE thread_id = $1 AND status = 'pending'
194
+ `SELECT * FROM lattice_thread_message_queue
195
+ WHERE thread_id = $1 AND status = 'pending'${filter.sql}
155
196
  ORDER BY priority DESC, sequence_order ASC`,
156
- [threadId]
197
+ [threadId, ...filter.params]
157
198
  );
158
199
 
159
200
  return result.rows.map(row => this.rowToMessage(row));
@@ -162,12 +203,13 @@ export class ThreadMessageQueueStore implements IMessageQueueStore {
162
203
  /**
163
204
  * Get processing messages for a thread
164
205
  */
165
- async getProcessingMessages(threadId: string): Promise<PendingMessage[]> {
206
+ async getProcessingMessages(threadId: string, scope?: QueueScope): Promise<PendingMessage[]> {
207
+ const filter = scopeClause(scope, 2);
166
208
  const result = await this.pool.query(
167
209
  `SELECT * FROM lattice_thread_message_queue
168
- WHERE thread_id = $1 AND status = 'processing'
210
+ WHERE thread_id = $1 AND status = 'processing'${filter.sql}
169
211
  ORDER BY priority DESC, sequence_order ASC`,
170
- [threadId]
212
+ [threadId, ...filter.params]
171
213
  );
172
214
 
173
215
  return result.rows.map(row => this.rowToMessage(row));
@@ -176,11 +218,12 @@ export class ThreadMessageQueueStore implements IMessageQueueStore {
176
218
  /**
177
219
  * Get queue size
178
220
  */
179
- async getQueueSize(threadId: string): Promise<number> {
221
+ async getQueueSize(threadId: string, scope?: QueueScope): Promise<number> {
222
+ const filter = scopeClause(scope, 2);
180
223
  const result = await this.pool.query(
181
224
  `SELECT COUNT(*) as count FROM lattice_thread_message_queue
182
- WHERE thread_id = $1 AND status = 'pending'`,
183
- [threadId]
225
+ WHERE thread_id = $1 AND status = 'pending'${filter.sql}`,
226
+ [threadId, ...filter.params]
184
227
  );
185
228
 
186
229
  return parseInt(result.rows[0].count, 10);
@@ -191,28 +234,29 @@ export class ThreadMessageQueueStore implements IMessageQueueStore {
191
234
  */
192
235
  async getThreadsWithPendingMessages(): Promise<ThreadInfo[]> {
193
236
  const result = await this.pool.query(
194
- `SELECT DISTINCT ON (thread_id) tenant_id, assistant_id, thread_id, workspace_id, project_id
237
+ `SELECT DISTINCT ON (tenant_id, assistant_id, workspace_id, project_id, thread_id) tenant_id, assistant_id, thread_id, workspace_id, project_id
195
238
  FROM lattice_thread_message_queue
196
239
  WHERE status IN ('pending', 'processing')
197
- ORDER BY thread_id`
240
+ ORDER BY tenant_id, assistant_id, workspace_id, project_id, thread_id`
198
241
  );
199
242
 
200
243
  return result.rows.map(row => ({
201
244
  tenantId: row.tenant_id,
202
245
  assistantId: row.assistant_id,
203
246
  threadId: row.thread_id,
204
- workspaceId: row.workspace_id || undefined,
205
- projectId: row.project_id || undefined,
247
+ workspaceId: row.workspace_id,
248
+ projectId: row.project_id,
206
249
  }));
207
250
  }
208
251
 
209
252
  /**
210
253
  * Remove message
211
254
  */
212
- async removeMessage(messageId: string): Promise<boolean> {
255
+ async removeMessage(messageId: string, scope?: QueueScope): Promise<boolean> {
256
+ const filter = scopeClause(scope, 2);
213
257
  const result = await this.pool.query(
214
- `DELETE FROM lattice_thread_message_queue WHERE id = $1 RETURNING id`,
215
- [messageId]
258
+ `DELETE FROM lattice_thread_message_queue WHERE id = $1${filter.sql} RETURNING id`,
259
+ [messageId, ...filter.params]
216
260
  );
217
261
 
218
262
  return (result.rowCount ?? 0) > 0;
@@ -221,20 +265,30 @@ export class ThreadMessageQueueStore implements IMessageQueueStore {
221
265
  /**
222
266
  * Clear all messages for thread
223
267
  */
224
- async clearMessages(threadId: string): Promise<void> {
268
+ async clearMessages(threadId: string, scope?: QueueScope): Promise<void> {
269
+ const filter = scopeClause(scope, 2);
225
270
  await this.pool.query(
226
- `DELETE FROM lattice_thread_message_queue WHERE thread_id = $1`,
227
- [threadId]
271
+ `DELETE FROM lattice_thread_message_queue WHERE thread_id = $1${filter.sql}`,
272
+ [threadId, ...filter.params]
228
273
  );
229
274
  }
230
275
 
231
276
  /**
232
277
  * Mark message as processing
233
278
  */
234
- async markProcessing(messageId: string): Promise<void> {
279
+ async markProcessing(messageId: string, customRunConfig?: unknown, scope?: QueueScope): Promise<void> {
280
+ if (customRunConfig !== undefined) {
281
+ const filter = scopeClause(scope, 3);
282
+ await this.pool.query(
283
+ `UPDATE lattice_thread_message_queue SET status = 'processing', custom_run_config = $2 WHERE id = $1${filter.sql}`,
284
+ [messageId, JSON.stringify(customRunConfig), ...filter.params]
285
+ );
286
+ return;
287
+ }
288
+ const filter = scopeClause(scope, 2);
235
289
  await this.pool.query(
236
- `UPDATE lattice_thread_message_queue SET status = 'processing' WHERE id = $1`,
237
- [messageId]
290
+ `UPDATE lattice_thread_message_queue SET status = 'processing' WHERE id = $1${filter.sql}`,
291
+ [messageId, ...filter.params]
238
292
  );
239
293
  }
240
294
 
@@ -242,13 +296,14 @@ export class ThreadMessageQueueStore implements IMessageQueueStore {
242
296
  * Reset all processing messages to pending state for a thread
243
297
  * Returns the number of messages reset
244
298
  */
245
- async resetProcessingToPending(threadId: string): Promise<number> {
299
+ async resetProcessingToPending(threadId: string, scope?: QueueScope): Promise<number> {
300
+ const filter = scopeClause(scope, 2);
246
301
  const result = await this.pool.query(
247
302
  `UPDATE lattice_thread_message_queue
248
303
  SET status = 'pending'
249
- WHERE thread_id = $1 AND status = 'processing'
304
+ WHERE thread_id = $1 AND status = 'processing'${filter.sql}
250
305
  RETURNING id`,
251
- [threadId]
306
+ [threadId, ...filter.params]
252
307
  );
253
308
  return result.rowCount ?? 0;
254
309
  }
@@ -270,3 +325,17 @@ export class ThreadMessageQueueStore implements IMessageQueueStore {
270
325
  };
271
326
  }
272
327
  }
328
+
329
+ function scopeClause(scope: QueueScope | undefined, start: number): { sql: string; params: unknown[] } {
330
+ if (!scope) return { sql: "", params: [] };
331
+ const params: unknown[] = [];
332
+ const entries = (["tenantId", "assistantId", "workspaceId", "projectId"] as const)
333
+ .map((key) => [key, scope[key]] as const);
334
+ const sql = entries.map(([key, value]) => {
335
+ const column = key.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
336
+ if (value == null) return ` AND ${column} IS NULL`;
337
+ params.push(value);
338
+ return ` AND ${column} = $${start + params.length - 1}`;
339
+ }).join("");
340
+ return { sql, params };
341
+ }