@axiom-lattice/pg-stores 3.1.6 → 3.1.9

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.
Files changed (29) hide show
  1. package/.turbo/turbo-build.log +10 -10
  2. package/CHANGELOG.md +29 -0
  3. package/dist/index.d.mts +54 -2
  4. package/dist/index.d.ts +54 -2
  5. package/dist/index.js +424 -47
  6. package/dist/index.js.map +1 -1
  7. package/dist/index.mjs +421 -47
  8. package/dist/index.mjs.map +1 -1
  9. package/package.json +3 -3
  10. package/src/__tests__/PostgreSQLA2AApiKeyStore.migrations.test.ts +2 -1
  11. package/src/__tests__/PostgreSQLA2AApiKeyStore.test.ts +45 -0
  12. package/src/__tests__/PostgreSQLMcpServerConfigStore.test.ts +79 -0
  13. package/src/__tests__/PostgreSQLOpenAuditStore.test.ts +100 -0
  14. package/src/__tests__/PostgreSQLProjectRoomMessageStore.test.ts +14 -0
  15. package/src/__tests__/PostgreSQLProjectRoomReadStateStore.test.ts +136 -0
  16. package/src/__tests__/PostgresSharedResourceStore.openGrants.test.ts +62 -0
  17. package/src/__tests__/create_open_audit_table.test.ts +40 -0
  18. package/src/createPgStoreConfig.ts +12 -1
  19. package/src/index.ts +2 -0
  20. package/src/migrations/add_api_key_grants.ts +25 -0
  21. package/src/migrations/add_share_open_grants.ts +23 -0
  22. package/src/migrations/create_open_audit_table.ts +36 -0
  23. package/src/migrations/project_room_migration.ts +26 -0
  24. package/src/stores/PostgreSQLA2AApiKeyStore.ts +90 -14
  25. package/src/stores/PostgreSQLMcpServerConfigStore.ts +23 -0
  26. package/src/stores/PostgreSQLOpenAuditStore.ts +143 -0
  27. package/src/stores/PostgreSQLProjectRoomMessageStore.ts +36 -1
  28. package/src/stores/PostgreSQLProjectRoomReadStateStore.ts +47 -0
  29. package/src/stores/PostgresSharedResourceStore.ts +5 -2
@@ -0,0 +1,36 @@
1
+ import type { Migration } from "./migration";
2
+ import type { PoolClient } from "pg";
3
+
4
+ /**
5
+ * Append-only audit log for the Open API surface (one row per invoke).
6
+ */
7
+ export const createOpenAuditTable: Migration = {
8
+ version: 179,
9
+ name: "create_open_audit_table",
10
+ up: async (client: PoolClient) => {
11
+ await client.query(`
12
+ CREATE TABLE IF NOT EXISTS lattice_open_audit (
13
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
14
+ tenant_id VARCHAR(255) NOT NULL,
15
+ credential_id VARCHAR(255) NOT NULL,
16
+ credential_kind VARCHAR(32) NOT NULL,
17
+ project_id VARCHAR(255) NOT NULL,
18
+ domain VARCHAR(255) NOT NULL,
19
+ action VARCHAR(255) NOT NULL,
20
+ args JSONB,
21
+ status VARCHAR(16) NOT NULL,
22
+ error_code VARCHAR(64),
23
+ duration_ms INTEGER NOT NULL,
24
+ created_at TIMESTAMP NOT NULL DEFAULT NOW()
25
+ )
26
+ `);
27
+
28
+ await client.query(`
29
+ CREATE INDEX IF NOT EXISTS idx_lattice_open_audit_tenant_created
30
+ ON lattice_open_audit(tenant_id, created_at DESC)
31
+ `);
32
+ },
33
+ down: async (client: PoolClient) => {
34
+ await client.query("DROP TABLE IF EXISTS lattice_open_audit");
35
+ },
36
+ };
@@ -126,3 +126,29 @@ export const createProjectRoomTables: Migration = {
126
126
  await client.query("DROP TABLE IF EXISTS lattice_project_rooms");
127
127
  },
128
128
  };
129
+
130
+ /** Adds per-user, per-room read markers for unread counts. */
131
+ export const createProjectRoomReadStates: Migration = {
132
+ version: 177,
133
+ name: "create_project_room_read_states",
134
+ up: async (client) => {
135
+ await client.query(`
136
+ CREATE TABLE IF NOT EXISTS lattice_project_room_read_states (
137
+ tenant_id VARCHAR(255) NOT NULL,
138
+ room_id VARCHAR(255) NOT NULL,
139
+ user_id VARCHAR(255) NOT NULL,
140
+ last_read_at TIMESTAMPTZ NOT NULL,
141
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
142
+ PRIMARY KEY (tenant_id, room_id, user_id)
143
+ )
144
+ `);
145
+ await client.query(`
146
+ CREATE INDEX IF NOT EXISTS idx_lattice_project_room_read_states_user
147
+ ON lattice_project_room_read_states (tenant_id, user_id)
148
+ `);
149
+ },
150
+ down: async (client) => {
151
+ await client.query("DROP INDEX IF EXISTS idx_lattice_project_room_read_states_user");
152
+ await client.query("DROP TABLE IF EXISTS lattice_project_room_read_states");
153
+ },
154
+ };
@@ -1,6 +1,7 @@
1
1
  import { Pool } from "pg";
2
2
  import type { PoolConfig } from "pg";
3
3
  import type {
4
+ UpdateA2AApiKeyInput,
4
5
  A2AApiKeyStore,
5
6
  A2AApiKeyRecord,
6
7
  CreateA2AApiKeyInput,
@@ -9,6 +10,7 @@ import type {
9
10
  import { MigrationManager } from "../migrations/migration";
10
11
  import { createA2AApiKeysTable } from "../migrations/a2a_api_key_migration";
11
12
  import { addA2AKeyAssistantIds } from "../migrations/add_a2a_key_assistant_ids";
13
+ import { addApiKeyGrants } from "../migrations/add_api_key_grants";
12
14
  import { encrypt, decrypt } from "@axiom-lattice/core";
13
15
  import { randomUUID } from "crypto";
14
16
 
@@ -24,6 +26,7 @@ type KeyRow = {
24
26
  tenant_id: string;
25
27
  project_id: string;
26
28
  assistant_ids: string[] | null;
29
+ grants: unknown[] | null;
27
30
  label: string | null;
28
31
  enabled: boolean;
29
32
  created_at: Date;
@@ -41,6 +44,7 @@ function mapRowToRecord(row: KeyRow): A2AApiKeyRecord {
41
44
  tenantId: row.tenant_id,
42
45
  projectId: row.project_id,
43
46
  assistantIds: row.assistant_ids ?? undefined,
47
+ grants: (row.grants as A2AApiKeyRecord["grants"]) ?? undefined,
44
48
  label: row.label || undefined,
45
49
  enabled: row.enabled,
46
50
  createdAt: row.created_at,
@@ -78,6 +82,7 @@ export class PostgreSQLA2AApiKeyStore implements A2AApiKeyStore {
78
82
  // overlap is safe.
79
83
  this.migrationManager.register(createA2AApiKeysTable);
80
84
  this.migrationManager.register(addA2AKeyAssistantIds);
85
+ this.migrationManager.register(addApiKeyGrants);
81
86
 
82
87
  if (options.autoMigrate !== false) {
83
88
  this.initialize().catch((error) => {
@@ -111,13 +116,31 @@ export class PostgreSQLA2AApiKeyStore implements A2AApiKeyStore {
111
116
  if (!this.initialized) await this.initialize();
112
117
  }
113
118
 
119
+ /** Map rows that decrypt cleanly; skip rows under an unknown key. */
120
+ private safeMap(rows: KeyRow[]): A2AApiKeyRecord[] {
121
+ const out: A2AApiKeyRecord[] = [];
122
+ for (const row of rows) {
123
+ try {
124
+ out.push(mapRowToRecord(row));
125
+ } catch {
126
+ // Skip undecryptable rows.
127
+ }
128
+ }
129
+ return out;
130
+ }
131
+
114
132
  async findByKey(key: string): Promise<A2AApiKeyRecord | null> {
115
133
  await this.ensureInitialized();
116
134
  const all = await this.pool.query<KeyRow>(
117
135
  `SELECT * FROM lattice_a2a_api_keys WHERE enabled = true`,
118
136
  );
119
137
  for (const row of all.rows) {
120
- if (decrypt(row.key_value) === key) return mapRowToRecord(row);
138
+ // A row encrypted under a different key must not break every lookup.
139
+ try {
140
+ if (decrypt(row.key_value) === key) return mapRowToRecord(row);
141
+ } catch {
142
+ continue;
143
+ }
121
144
  }
122
145
  return null;
123
146
  }
@@ -128,7 +151,12 @@ export class PostgreSQLA2AApiKeyStore implements A2AApiKeyStore {
128
151
  `SELECT * FROM lattice_a2a_api_keys WHERE id = $1`,
129
152
  [id],
130
153
  );
131
- return result.rows[0] ? mapRowToRecord(result.rows[0]) : null;
154
+ if (!result.rows[0]) return null;
155
+ try {
156
+ return mapRowToRecord(result.rows[0]);
157
+ } catch {
158
+ return null;
159
+ }
132
160
  }
133
161
 
134
162
  async list(params: { tenantId?: string; limit?: number; offset?: number }): Promise<A2AApiKeyRecord[]> {
@@ -140,28 +168,71 @@ export class PostgreSQLA2AApiKeyStore implements A2AApiKeyStore {
140
168
  `SELECT * FROM lattice_a2a_api_keys WHERE tenant_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3`,
141
169
  [params.tenantId, limit, offset],
142
170
  );
143
- return result.rows.map(mapRowToRecord);
171
+ return this.safeMap(result.rows);
144
172
  }
145
173
  const result = await this.pool.query<KeyRow>(
146
174
  `SELECT * FROM lattice_a2a_api_keys ORDER BY created_at DESC LIMIT $1 OFFSET $2`,
147
175
  [limit, offset],
148
176
  );
149
- return result.rows.map(mapRowToRecord);
177
+ return this.safeMap(result.rows);
150
178
  }
151
179
 
152
180
  async create(input: CreateA2AApiKeyInput): Promise<A2AApiKeyRecord> {
153
181
  await this.ensureInitialized();
154
182
  const key = generateApiKey();
155
183
  const result = await this.pool.query<KeyRow>(
156
- `INSERT INTO lattice_a2a_api_keys (key_value, tenant_id, project_id, assistant_ids, label)
157
- VALUES ($1, $2, $3, $4, $5) RETURNING *`,
158
- [encrypt(key), input.tenantId, input.projectId, input.assistantIds ? JSON.stringify(input.assistantIds) : null, input.label || null],
184
+ `INSERT INTO lattice_a2a_api_keys (key_value, tenant_id, project_id, assistant_ids, grants, label)
185
+ VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`,
186
+ [
187
+ encrypt(key),
188
+ input.tenantId,
189
+ input.projectId,
190
+ input.assistantIds ? JSON.stringify(input.assistantIds) : null,
191
+ input.grants ? JSON.stringify(input.grants) : null,
192
+ input.label || null,
193
+ ],
159
194
  );
160
195
  const record = mapRowToRecord(result.rows[0]);
161
196
  record.key = key;
162
197
  return record;
163
198
  }
164
199
 
200
+
201
+ async update(id: string, input: UpdateA2AApiKeyInput): Promise<A2AApiKeyRecord> {
202
+ await this.ensureInitialized();
203
+ const sets: string[] = [];
204
+ const values: unknown[] = [];
205
+ if (input.label !== undefined) {
206
+ values.push(input.label || null);
207
+ sets.push(`label = $${values.length}`);
208
+ }
209
+ if (input.projectId !== undefined) {
210
+ values.push(input.projectId);
211
+ sets.push(`project_id = $${values.length}`);
212
+ }
213
+ if (input.assistantIds !== undefined) {
214
+ values.push(input.assistantIds ? JSON.stringify(input.assistantIds) : null);
215
+ sets.push(`assistant_ids = $${values.length}`);
216
+ }
217
+ if (input.grants !== undefined) {
218
+ values.push(input.grants ? JSON.stringify(input.grants) : null);
219
+ sets.push(`grants = $${values.length}`);
220
+ }
221
+ if (sets.length === 0) {
222
+ const existing = await this.findById(id);
223
+ if (!existing) throw new Error(`A2A API key not found: ${id}`);
224
+ return existing;
225
+ }
226
+ sets.push("updated_at = NOW()");
227
+ values.push(id);
228
+ const result = await this.pool.query<KeyRow>(
229
+ `UPDATE lattice_a2a_api_keys SET ${sets.join(", ")} WHERE id = $${values.length} RETURNING *`,
230
+ values,
231
+ );
232
+ if (result.rows.length === 0) throw new Error(`A2A API key not found: ${id}`);
233
+ return mapRowToRecord(result.rows[0]);
234
+ }
235
+
165
236
  async disable(id: string): Promise<A2AApiKeyRecord> {
166
237
  await this.ensureInitialized();
167
238
  const result = await this.pool.query<KeyRow>(
@@ -207,13 +278,18 @@ export class PostgreSQLA2AApiKeyStore implements A2AApiKeyStore {
207
278
  );
208
279
  const map = new Map<string, A2AApiKeyEntry>();
209
280
  for (const row of result.rows) {
210
- const key = decrypt(row.key_value);
211
- map.set(key, {
212
- key,
213
- tenantId: row.tenant_id,
214
- projectId: row.project_id,
215
- assistantIds: row.assistant_ids ?? undefined,
216
- });
281
+ try {
282
+ const key = decrypt(row.key_value);
283
+ map.set(key, {
284
+ key,
285
+ tenantId: row.tenant_id,
286
+ projectId: row.project_id,
287
+ assistantIds: row.assistant_ids ?? undefined,
288
+ grants: (row.grants as A2AApiKeyEntry["grants"]) ?? undefined,
289
+ });
290
+ } catch {
291
+ // Skip rows encrypted under an unknown key.
292
+ }
217
293
  }
218
294
  return map;
219
295
  }
@@ -464,6 +464,20 @@ export class PostgreSQLMcpServerConfigStore implements McpServerConfigStore {
464
464
  }
465
465
  }
466
466
 
467
+ // Decrypt header values if present and encrypted
468
+ if (config.headers && row.is_env_encrypted) {
469
+ try {
470
+ const decryptedHeaders: Record<string, string> = {};
471
+ for (const [key, value] of Object.entries(config.headers)) {
472
+ decryptedHeaders[key] = decrypt(value);
473
+ }
474
+ config.headers = decryptedHeaders;
475
+ } catch (error) {
476
+ console.error('Failed to decrypt MCP server headers:', error);
477
+ throw new Error('Failed to decrypt MCP server configuration');
478
+ }
479
+ }
480
+
467
481
  return {
468
482
  id: row.id,
469
483
  tenantId: row.tenant_id,
@@ -495,6 +509,15 @@ export class PostgreSQLMcpServerConfigStore implements McpServerConfigStore {
495
509
  isEnvEncrypted = true;
496
510
  }
497
511
 
512
+ if (configCopy.headers && Object.keys(configCopy.headers).length > 0) {
513
+ const encryptedHeaders: Record<string, string> = {};
514
+ for (const [key, value] of Object.entries(configCopy.headers)) {
515
+ encryptedHeaders[key] = encrypt(value);
516
+ }
517
+ configCopy.headers = encryptedHeaders;
518
+ isEnvEncrypted = true;
519
+ }
520
+
498
521
  return { config: configCopy, isEnvEncrypted };
499
522
  }
500
523
  }
@@ -0,0 +1,143 @@
1
+ import { Pool } from "pg";
2
+ import type { PoolConfig } from "pg";
3
+ import type {
4
+ OpenAuditStore,
5
+ OpenAuditAppendInput,
6
+ OpenAuditQuery,
7
+ OpenAuditRecord,
8
+ } from "@axiom-lattice/protocols";
9
+ import { MigrationManager } from "../migrations/migration";
10
+ import { createOpenAuditTable } from "../migrations/create_open_audit_table";
11
+
12
+ export interface PostgreSQLOpenAuditStoreOptions {
13
+ pool?: Pool;
14
+ poolConfig?: string | PoolConfig;
15
+ autoMigrate?: boolean;
16
+ }
17
+
18
+ /** Append-only PostgreSQL audit store for the Open API surface. */
19
+ export class PostgreSQLOpenAuditStore implements OpenAuditStore {
20
+ private pool: Pool;
21
+ private migrationManager!: MigrationManager;
22
+ private initialized = false;
23
+ private ownsPool = true;
24
+ private initPromise: Promise<void> | null = null;
25
+
26
+ constructor(options: PostgreSQLOpenAuditStoreOptions) {
27
+ if (options.pool) {
28
+ this.pool = options.pool;
29
+ this.ownsPool = false;
30
+ this.initialized = true;
31
+ return;
32
+ }
33
+ this.pool =
34
+ typeof options.poolConfig === "string"
35
+ ? new Pool({ connectionString: options.poolConfig })
36
+ : options.poolConfig
37
+ ? new Pool(options.poolConfig as PoolConfig)
38
+ : (() => {
39
+ throw new Error("Either pool or poolConfig must be provided");
40
+ })();
41
+ this.migrationManager = new MigrationManager(this.pool);
42
+ this.migrationManager.register(createOpenAuditTable);
43
+ if (options.autoMigrate !== false) {
44
+ this.initialize().catch((error) => {
45
+ console.error("Failed to initialize PostgreSQLOpenAuditStore:", error);
46
+ throw error;
47
+ });
48
+ }
49
+ }
50
+
51
+ async initialize(): Promise<void> {
52
+ if (this.initialized) return;
53
+ if (this.initPromise) return this.initPromise;
54
+ this.initPromise = (async () => {
55
+ try {
56
+ await this.migrationManager.migrate();
57
+ this.initialized = true;
58
+ } finally {
59
+ this.initPromise = null;
60
+ }
61
+ })();
62
+ return this.initPromise;
63
+ }
64
+
65
+ async dispose(): Promise<void> {
66
+ if (this.ownsPool && this.pool) await this.pool.end();
67
+ }
68
+
69
+ private async ensureInitialized(): Promise<void> {
70
+ if (!this.initialized) await this.initialize();
71
+ }
72
+
73
+ async query(params: OpenAuditQuery): Promise<OpenAuditRecord[]> {
74
+ await this.ensureInitialized();
75
+ const clauses = ["tenant_id = $1"];
76
+ const values: unknown[] = [params.tenantId];
77
+ if (params.credentialId) {
78
+ values.push(params.credentialId);
79
+ clauses.push(`credential_id = $${values.length}`);
80
+ }
81
+ if (params.domain) {
82
+ values.push(params.domain);
83
+ clauses.push(`domain = $${values.length}`);
84
+ }
85
+ if (params.status) {
86
+ values.push(params.status);
87
+ clauses.push(`status = $${values.length}`);
88
+ }
89
+ if (params.from) {
90
+ values.push(params.from);
91
+ clauses.push(`created_at >= $${values.length}`);
92
+ }
93
+ if (params.to) {
94
+ values.push(params.to);
95
+ clauses.push(`created_at < $${values.length}`);
96
+ }
97
+ const limit = params.limit ?? 100;
98
+ const offset = params.offset ?? 0;
99
+ values.push(limit, offset);
100
+ const { rows } = await this.pool.query(
101
+ `SELECT * FROM lattice_open_audit
102
+ WHERE ${clauses.join(" AND ")}
103
+ ORDER BY created_at DESC
104
+ LIMIT $${values.length - 1} OFFSET $${values.length}`,
105
+ values,
106
+ );
107
+ return rows.map((row) => ({
108
+ id: row.id,
109
+ tenantId: row.tenant_id,
110
+ credentialId: row.credential_id,
111
+ credentialKind: row.credential_kind,
112
+ projectId: row.project_id,
113
+ domain: row.domain,
114
+ action: row.action,
115
+ args: row.args,
116
+ status: row.status,
117
+ errorCode: row.error_code ?? undefined,
118
+ durationMs: row.duration_ms,
119
+ createdAt: row.created_at,
120
+ }));
121
+ }
122
+
123
+ async append(record: OpenAuditAppendInput): Promise<void> {
124
+ await this.ensureInitialized();
125
+ await this.pool.query(
126
+ `INSERT INTO lattice_open_audit
127
+ (tenant_id, credential_id, credential_kind, project_id, domain, action, args, status, error_code, duration_ms)
128
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
129
+ [
130
+ record.tenantId,
131
+ record.credentialId,
132
+ record.credentialKind,
133
+ record.projectId,
134
+ record.domain,
135
+ record.action,
136
+ record.args === undefined ? null : JSON.stringify(record.args),
137
+ record.status,
138
+ record.errorCode ?? null,
139
+ Math.max(0, Math.round(record.durationMs)),
140
+ ],
141
+ );
142
+ }
143
+ }
@@ -83,6 +83,25 @@ function mapRow(row: MessageRow): ProjectRoomMessage {
83
83
  ...(row.idempotency_key === null ? {} : { idempotencyKey: row.idempotency_key }), createdAt: row.created_at,
84
84
  };
85
85
  }
86
+ function mapRowsTolerant(
87
+ rows: MessageRow[],
88
+ scope: { tenantId: string; roomId: string },
89
+ ): ProjectRoomMessage[] {
90
+ const messages: ProjectRoomMessage[] = [];
91
+ for (const row of rows) {
92
+ try {
93
+ messages.push(mapRow(row));
94
+ } catch (error) {
95
+ console.warn("[ProjectRoomMessageStore] Skipping invalid message row", {
96
+ tenantId: scope.tenantId,
97
+ roomId: scope.roomId,
98
+ rowId: typeof row.id === "string" ? row.id : undefined,
99
+ reason: error instanceof Error ? error.message : String(error),
100
+ });
101
+ }
102
+ }
103
+ return messages;
104
+ }
86
105
  function isUniqueViolation(error: unknown): error is PostgreSQLError {
87
106
  return typeof error === "object" && error !== null && "code" in error
88
107
  && (error as PostgreSQLError).code === "23505";
@@ -159,7 +178,7 @@ export class PostgreSQLProjectRoomMessageStore implements ProjectRoomMessageStor
159
178
  WHERE tenant_id = $1 AND room_id = $2 ORDER BY created_at DESC, id DESC LIMIT $3`,
160
179
  [input.tenantId, input.roomId, limit],
161
180
  );
162
- return result.rows.map(mapRow);
181
+ return mapRowsTolerant(result.rows, { tenantId: input.tenantId, roomId: input.roomId });
163
182
  }
164
183
 
165
184
  /** Finds a message by tenant-scoped ID. */
@@ -170,6 +189,22 @@ export class PostgreSQLProjectRoomMessageStore implements ProjectRoomMessageStor
170
189
  return result.rows[0] ? mapRow(result.rows[0]) : null;
171
190
  }
172
191
 
192
+ /** Counts messages created strictly after a horizon, optionally excluding one human author. */
193
+ async countAfter(input: {
194
+ tenantId: string; roomId: string; after: Date; excludeAuthorUserId?: string;
195
+ }): Promise<number> {
196
+ if (!(input.after instanceof Date) || Number.isNaN(input.after.getTime())) {
197
+ throw new RangeError("Project room message count horizon date is invalid");
198
+ }
199
+ const result = await this.pool.query<{ count: number }>(
200
+ `SELECT COUNT(*)::int AS count FROM lattice_project_room_messages
201
+ WHERE tenant_id = $1 AND room_id = $2 AND created_at > $3
202
+ AND ($4::text IS NULL OR author->>'type' <> 'human' OR author->>'userId' <> $4)`,
203
+ [input.tenantId, input.roomId, input.after, input.excludeAuthorUserId ?? null],
204
+ );
205
+ return result.rows[0]?.count ?? 0;
206
+ }
207
+
173
208
  private parameters(input: Omit<ProjectRoomMessage, "createdAt">): unknown[] {
174
209
  return [input.id, input.tenantId, input.workspaceId, input.projectId, input.roomId, JSON.stringify(input.author), JSON.stringify(input.content),
175
210
  JSON.stringify(input.mentions), input.replyToMessageId ?? null, input.source, input.sourceId ?? null, input.idempotencyKey ?? null];
@@ -0,0 +1,47 @@
1
+ import type { ProjectRoomReadState, ProjectRoomReadStateStore } from "@axiom-lattice/protocols";
2
+ import type { Pool } from "pg";
3
+
4
+ type ReadStateRow = { tenant_id: unknown; room_id: unknown; user_id: unknown; last_read_at: unknown; updated_at: unknown };
5
+
6
+ function mapRow(row: ReadStateRow): ProjectRoomReadState {
7
+ if (typeof row.tenant_id !== "string" || typeof row.room_id !== "string" || typeof row.user_id !== "string"
8
+ || !(row.last_read_at instanceof Date) || Number.isNaN(row.last_read_at.getTime())
9
+ || !(row.updated_at instanceof Date) || Number.isNaN(row.updated_at.getTime())) {
10
+ throw new Error("Invalid project room read state row");
11
+ }
12
+ return { tenantId: row.tenant_id, roomId: row.room_id, userId: row.user_id, lastReadAt: row.last_read_at, updatedAt: row.updated_at };
13
+ }
14
+
15
+ /** Persists tenant-isolated per-user project room read markers in PostgreSQL. */
16
+ export class PostgreSQLProjectRoomReadStateStore implements ProjectRoomReadStateStore {
17
+ /** Creates a store using an externally managed shared pool. */
18
+ constructor(options: { pool: Pool }) { this.pool = options.pool; }
19
+ private readonly pool: Pool;
20
+
21
+ /** Returns the user's read marker for a room, or null when never reported. */
22
+ async get(tenantId: string, roomId: string, userId: string): Promise<ProjectRoomReadState | null> {
23
+ const result = await this.pool.query<ReadStateRow>(
24
+ `SELECT tenant_id, room_id, user_id, last_read_at, updated_at
25
+ FROM lattice_project_room_read_states WHERE tenant_id = $1 AND room_id = $2 AND user_id = $3`,
26
+ [tenantId, roomId, userId],
27
+ );
28
+ return result.rows[0] ? mapRow(result.rows[0]) : null;
29
+ }
30
+
31
+ /** Upserts the marker monotonically using GREATEST so older reports never regress it. */
32
+ async markRead(input: { tenantId: string; roomId: string; userId: string; lastReadAt: Date }): Promise<ProjectRoomReadState> {
33
+ if (!(input.lastReadAt instanceof Date) || Number.isNaN(input.lastReadAt.getTime())) {
34
+ throw new RangeError("Project room read state lastReadAt is invalid");
35
+ }
36
+ const result = await this.pool.query<ReadStateRow>(
37
+ `INSERT INTO lattice_project_room_read_states (tenant_id, room_id, user_id, last_read_at, updated_at)
38
+ VALUES ($1, $2, $3, $4, NOW())
39
+ ON CONFLICT (tenant_id, room_id, user_id) DO UPDATE
40
+ SET last_read_at = GREATEST(lattice_project_room_read_states.last_read_at, EXCLUDED.last_read_at),
41
+ updated_at = NOW()
42
+ RETURNING tenant_id, room_id, user_id, last_read_at, updated_at`,
43
+ [input.tenantId, input.roomId, input.userId, input.lastReadAt],
44
+ );
45
+ return mapRow(result.rows[0]);
46
+ }
47
+ }
@@ -25,6 +25,7 @@ type ShareRow = {
25
25
  password_hash: string | null;
26
26
  expires_at: string | null;
27
27
  max_access: number | null;
28
+ open_grants: unknown[] | null;
28
29
  access_count: number;
29
30
  revoked: boolean;
30
31
  created_at: string;
@@ -106,8 +107,8 @@ export class PostgresSharedResourceStore implements SharedResourceStore {
106
107
  `INSERT INTO lattice_shared_resources
107
108
  (token, address, title, created_by, tenant_id, workspace_id, project_id,
108
109
  assistant_id, vm_isolation, visibility, password_hash,
109
- expires_at, max_access, revoked)
110
- VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
110
+ expires_at, max_access, open_grants, revoked)
111
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
111
112
  RETURNING *`,
112
113
  [
113
114
  record.token,
@@ -123,6 +124,7 @@ export class PostgresSharedResourceStore implements SharedResourceStore {
123
124
  record.passwordHash,
124
125
  record.expiresAt ? record.expiresAt.toISOString() : null,
125
126
  record.maxAccess,
127
+ record.openGrants ? JSON.stringify(record.openGrants) : null,
126
128
  record.revoked,
127
129
  ],
128
130
  );
@@ -229,6 +231,7 @@ export class PostgresSharedResourceStore implements SharedResourceStore {
229
231
  passwordHash: row.password_hash,
230
232
  expiresAt: row.expires_at ? new Date(row.expires_at) : null,
231
233
  maxAccess: row.max_access,
234
+ openGrants: (row.open_grants as ShareRecord["openGrants"]) ?? undefined,
232
235
  accessCount: row.access_count ?? 0,
233
236
  revoked: row.revoked ?? false,
234
237
  createdAt: new Date(row.created_at),