@axiom-lattice/local-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.
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Local SQLite implementation of the Open audit store (append-only).
3
+ */
4
+
5
+ import { DatabaseWrapper, ensureTable, nowISO, parseISO } from "../database";
6
+ import type {
7
+ OpenAuditStore,
8
+ OpenAuditAppendInput,
9
+ OpenAuditQuery,
10
+ OpenAuditRecord,
11
+ } from "@axiom-lattice/protocols";
12
+ import { randomUUID } from "crypto";
13
+
14
+ const DDL = `
15
+ CREATE TABLE IF NOT EXISTS lt_open_audit (
16
+ id TEXT PRIMARY KEY,
17
+ tenant_id TEXT NOT NULL,
18
+ credential_id TEXT NOT NULL,
19
+ credential_kind TEXT NOT NULL,
20
+ project_id TEXT NOT NULL,
21
+ domain TEXT NOT NULL,
22
+ action TEXT NOT NULL,
23
+ args TEXT,
24
+ status TEXT NOT NULL,
25
+ error_code TEXT,
26
+ duration_ms INTEGER NOT NULL,
27
+ created_at TEXT NOT NULL
28
+ );
29
+ `;
30
+
31
+ /** SQLite-backed append-only audit store for the Open API surface. */
32
+ export class LocalOpenAuditStore implements OpenAuditStore {
33
+ private db: DatabaseWrapper;
34
+
35
+ constructor(db: DatabaseWrapper) {
36
+ this.db = db;
37
+ ensureTable(db, DDL);
38
+ }
39
+
40
+ async query(params: OpenAuditQuery): Promise<OpenAuditRecord[]> {
41
+ const clauses = ["tenant_id = ?"];
42
+ const values: unknown[] = [params.tenantId];
43
+ if (params.credentialId) {
44
+ clauses.push("credential_id = ?");
45
+ values.push(params.credentialId);
46
+ }
47
+ if (params.domain) {
48
+ clauses.push("domain = ?");
49
+ values.push(params.domain);
50
+ }
51
+ if (params.status) {
52
+ clauses.push("status = ?");
53
+ values.push(params.status);
54
+ }
55
+ if (params.from) {
56
+ clauses.push("created_at >= ?");
57
+ values.push(params.from);
58
+ }
59
+ if (params.to) {
60
+ clauses.push("created_at < ?");
61
+ values.push(params.to);
62
+ }
63
+ const limit = params.limit ?? 100;
64
+ const offset = params.offset ?? 0;
65
+ values.push(limit, offset);
66
+ const rows = this.db
67
+ .prepare(
68
+ `SELECT * FROM lt_open_audit WHERE ${clauses.join(" AND ")}
69
+ ORDER BY created_at DESC LIMIT ? OFFSET ?`,
70
+ )
71
+ .all(...values) as unknown as Array<Record<string, unknown>>;
72
+ return rows.map((row) => ({
73
+ id: row.id as string,
74
+ tenantId: row.tenant_id as string,
75
+ credentialId: row.credential_id as string,
76
+ credentialKind: row.credential_kind as OpenAuditRecord["credentialKind"],
77
+ projectId: row.project_id as string,
78
+ domain: row.domain as string,
79
+ action: row.action as string,
80
+ args: row.args ? JSON.parse(row.args as string) : undefined,
81
+ status: row.status as "ok" | "error",
82
+ errorCode: (row.error_code as string) ?? undefined,
83
+ durationMs: row.duration_ms as number,
84
+ createdAt: parseISO(row.created_at as string),
85
+ }));
86
+ }
87
+
88
+ async append(record: OpenAuditAppendInput): Promise<void> {
89
+ this.db
90
+ .prepare(
91
+ `INSERT INTO lt_open_audit
92
+ (id, tenant_id, credential_id, credential_kind, project_id, domain, action, args, status, error_code, duration_ms, created_at)
93
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
94
+ )
95
+ .run(
96
+ randomUUID(),
97
+ record.tenantId,
98
+ record.credentialId,
99
+ record.credentialKind,
100
+ record.projectId,
101
+ record.domain,
102
+ record.action,
103
+ record.args === undefined ? null : JSON.stringify(record.args),
104
+ record.status,
105
+ record.errorCode ?? null,
106
+ Math.max(0, Math.round(record.durationMs)),
107
+ nowISO(),
108
+ );
109
+ }
110
+ }
@@ -26,6 +26,26 @@ function mapRow(row: Row): ProjectRoomMessage {
26
26
  ...(row.source_id === null ? {} : { sourceId: row.source_id }), ...(row.idempotency_key === null ? {} : { idempotencyKey: row.idempotency_key }), createdAt };
27
27
  }
28
28
 
29
+ function mapRowsTolerant(
30
+ rows: Row[],
31
+ scope: { tenantId: string; roomId: string },
32
+ ): ProjectRoomMessage[] {
33
+ const messages: ProjectRoomMessage[] = [];
34
+ for (const row of rows) {
35
+ try {
36
+ messages.push(mapRow(row));
37
+ } catch (error) {
38
+ console.warn("[ProjectRoomMessageStore] Skipping invalid message row", {
39
+ tenantId: scope.tenantId,
40
+ roomId: scope.roomId,
41
+ rowId: typeof row.id === "string" ? row.id : undefined,
42
+ reason: error instanceof Error ? error.message : String(error),
43
+ });
44
+ }
45
+ }
46
+ return messages;
47
+ }
48
+
29
49
  /** Persists validated project room messages in local SQLite. */
30
50
  export class LocalProjectRoomMessageStore implements ProjectRoomMessageStore {
31
51
  constructor(private readonly db: DatabaseWrapper) { ensureProjectRoomSchema(db); }
@@ -37,9 +57,23 @@ export class LocalProjectRoomMessageStore implements ProjectRoomMessageStore {
37
57
  const rows = input.before
38
58
  ? this.db.prepare(`SELECT ${columns} FROM lt_project_room_messages WHERE tenant_id = ? AND room_id = ? AND (created_at < ? OR (created_at = ? AND id < ?)) ORDER BY created_at DESC, id DESC LIMIT ?`).all(input.tenantId, input.roomId, input.before.createdAt.toISOString(), input.before.createdAt.toISOString(), input.before.id, limit)
39
59
  : this.db.prepare(`SELECT ${columns} FROM lt_project_room_messages WHERE tenant_id = ? AND room_id = ? ORDER BY created_at DESC, id DESC LIMIT ?`).all(input.tenantId, input.roomId, limit);
40
- return (rows as Row[]).map(mapRow);
60
+ return mapRowsTolerant(rows as Row[], { tenantId: input.tenantId, roomId: input.roomId });
41
61
  }
42
62
  async findById(tenantId: string, id: string): Promise<ProjectRoomMessage | null> { const row = this.byId(tenantId, id); return row ? mapRow(row) : null; }
63
+ async countAfter(input: {
64
+ tenantId: string; roomId: string; after: Date; excludeAuthorUserId?: string;
65
+ }): Promise<number> {
66
+ if (!validDate(input.after)) throw new RangeError("Project room message count horizon date is invalid");
67
+ const rows = this.db.prepare(
68
+ `SELECT author FROM lt_project_room_messages
69
+ WHERE tenant_id = ? AND room_id = ? AND created_at > ?`,
70
+ ).all(input.tenantId, input.roomId, input.after.toISOString()) as Array<{ author: string }>;
71
+ return rows.filter((row) => {
72
+ if (input.excludeAuthorUserId === undefined) return true;
73
+ const parsed = parseJson(row.author);
74
+ return !(record(parsed) && parsed.type === "human" && parsed.userId === input.excludeAuthorUserId);
75
+ }).length;
76
+ }
43
77
  private insert(input: Omit<ProjectRoomMessage, "createdAt">, idempotent: boolean): ProjectRoomMessage {
44
78
  return transaction(this.db, () => {
45
79
  if (idempotent && input.idempotencyKey) { const canonical = this.byKey(input.tenantId, input.roomId, input.idempotencyKey); if (canonical) return mapRow(canonical); }
@@ -0,0 +1,50 @@
1
+ import type { ProjectRoomReadState, ProjectRoomReadStateStore } from "@axiom-lattice/protocols";
2
+ import type { DatabaseWrapper } from "../database";
3
+ import { ensureProjectRoomSchema, timestamp, transaction, validDate, write } from "./project-room-schema";
4
+
5
+ type ReadStateRow = { tenant_id: string; room_id: string; user_id: string; last_read_at: string; updated_at: string };
6
+ const columns = "tenant_id, room_id, user_id, last_read_at, updated_at";
7
+
8
+ function mapRow(row: ReadStateRow): ProjectRoomReadState {
9
+ const lastReadAt = new Date(row.last_read_at);
10
+ const updatedAt = new Date(row.updated_at);
11
+ if (Number.isNaN(lastReadAt.getTime()) || Number.isNaN(updatedAt.getTime())) throw new Error("Invalid project room read state row");
12
+ return { tenantId: row.tenant_id, roomId: row.room_id, userId: row.user_id, lastReadAt, updatedAt };
13
+ }
14
+
15
+ /** Persists per-user project room read markers in the local SQLite store. */
16
+ export class LocalProjectRoomReadStateStore implements ProjectRoomReadStateStore {
17
+ /** Creates a store over a managed local database wrapper. */
18
+ constructor(private readonly db: DatabaseWrapper) { ensureProjectRoomSchema(db); }
19
+
20
+ /** Returns the user's read marker for a room, or null when never reported. */
21
+ async get(tenantId: string, roomId: string, userId: string): Promise<ProjectRoomReadState | null> {
22
+ const row = this.byKey(tenantId, roomId, userId);
23
+ return row ? mapRow(row) : null;
24
+ }
25
+
26
+ /** Upserts the marker monotonically (an older report never regresses the horizon). */
27
+ async markRead(input: { tenantId: string; roomId: string; userId: string; lastReadAt: Date }): Promise<ProjectRoomReadState> {
28
+ if (!validDate(input.lastReadAt)) throw new RangeError("Project room read state lastReadAt is invalid");
29
+ return transaction(this.db, () => {
30
+ const existing = this.byKey(input.tenantId, input.roomId, input.userId);
31
+ const lastReadAt = existing !== undefined && new Date(existing.last_read_at).getTime() > input.lastReadAt.getTime()
32
+ ? new Date(existing.last_read_at)
33
+ : input.lastReadAt;
34
+ write(this.db,
35
+ `INSERT INTO lt_project_room_read_states (${columns})
36
+ VALUES (?, ?, ?, ?, ?)
37
+ ON CONFLICT (tenant_id, room_id, user_id) DO UPDATE
38
+ SET last_read_at = excluded.last_read_at, updated_at = excluded.updated_at`,
39
+ [input.tenantId, input.roomId, input.userId, lastReadAt.toISOString(), timestamp()]);
40
+ const row = this.byKey(input.tenantId, input.roomId, input.userId) as ReadStateRow;
41
+ return mapRow(row);
42
+ });
43
+ }
44
+
45
+ private byKey(tenantId: string, roomId: string, userId: string): ReadStateRow | undefined {
46
+ return this.db.prepare(
47
+ `SELECT ${columns} FROM lt_project_room_read_states WHERE tenant_id = ? AND room_id = ? AND user_id = ?`,
48
+ ).get(tenantId, roomId, userId) as ReadStateRow | undefined;
49
+ }
50
+ }
@@ -49,6 +49,13 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_lt_project_room_messages_idempotency
49
49
  ON lt_project_room_messages (tenant_id, room_id, idempotency_key) WHERE idempotency_key IS NOT NULL;
50
50
  CREATE INDEX IF NOT EXISTS idx_lt_project_room_messages_room_created
51
51
  ON lt_project_room_messages (tenant_id, room_id, created_at DESC, id DESC);
52
+ CREATE TABLE IF NOT EXISTS lt_project_room_read_states (
53
+ tenant_id TEXT NOT NULL, room_id TEXT NOT NULL, user_id TEXT NOT NULL,
54
+ last_read_at TEXT NOT NULL, updated_at TEXT NOT NULL,
55
+ PRIMARY KEY (tenant_id, room_id, user_id)
56
+ );
57
+ CREATE INDEX IF NOT EXISTS idx_lt_project_room_read_states_user
58
+ ON lt_project_room_read_states (tenant_id, user_id);
52
59
  `;
53
60
 
54
61
  const tableColumns: Record<string, string[]> = {
@@ -56,6 +63,7 @@ const tableColumns: Record<string, string[]> = {
56
63
  lt_project_memberships: ["id", "tenant_id", "project_id", "user_id", "role", "status", "joined_at", "updated_at"],
57
64
  lt_project_bot_memberships: ["id", "tenant_id", "workspace_id", "project_id", "room_id", "assistant_id", "role", "title", "responsibility", "mention_name", "status", "room_thread_id", "joined_at", "updated_at"],
58
65
  lt_project_room_messages: ["id", "tenant_id", "workspace_id", "project_id", "room_id", "author", "content", "mentions", "reply_to_message_id", "source", "source_id", "idempotency_key", "created_at"],
66
+ lt_project_room_read_states: ["tenant_id", "room_id", "user_id", "last_read_at", "updated_at"],
59
67
  };
60
68
  const indexes: Record<string, { table: string; columns: string[]; unique: boolean; partial: boolean; sql?: string[] }> = {
61
69
  idx_lt_project_memberships_project: { table: "lt_project_memberships", columns: ["tenant_id", "project_id"], unique: false, partial: false },
@@ -65,12 +73,14 @@ const indexes: Record<string, { table: string; columns: string[]; unique: boolea
65
73
  idx_lt_project_bot_memberships_room: { table: "lt_project_bot_memberships", columns: ["tenant_id", "room_id"], unique: false, partial: false },
66
74
  uq_lt_project_room_messages_idempotency: { table: "lt_project_room_messages", columns: ["tenant_id", "room_id", "idempotency_key"], unique: true, partial: true, sql: ["idempotency_key is not null"] },
67
75
  idx_lt_project_room_messages_room_created: { table: "lt_project_room_messages", columns: ["tenant_id", "room_id", "created_at", "id"], unique: false, partial: false, sql: ["created_at desc", "id desc"] },
76
+ idx_lt_project_room_read_states_user: { table: "lt_project_room_read_states", columns: ["tenant_id", "user_id"], unique: false, partial: false },
68
77
  };
69
78
  const uniqueConstraints: Record<string, string[][]> = {
70
79
  lt_project_rooms: [["tenant_id", "id"], ["tenant_id", "project_id", "type"]],
71
80
  lt_project_memberships: [["tenant_id", "id"], ["tenant_id", "project_id", "user_id"]],
72
81
  lt_project_bot_memberships: [["tenant_id", "id"], ["tenant_id", "project_id", "assistant_id"]],
73
82
  lt_project_room_messages: [["tenant_id", "id"]],
83
+ lt_project_room_read_states: [["tenant_id", "room_id", "user_id"]],
74
84
  };
75
85
  const checkConstraints: Record<string, Array<{ name: string; fragment: string }>> = {
76
86
  lt_project_rooms: [{ name: "type", fragment: "type='main'" }],
@@ -83,6 +93,7 @@ const checkConstraints: Record<string, Array<{ name: string; fragment: string }>
83
93
  { name: "status", fragment: "status in ('active','paused','removed')" },
84
94
  ],
85
95
  lt_project_room_messages: [{ name: "source", fragment: "source in ('user','agent','task','routine','system')" }],
96
+ lt_project_room_read_states: [],
86
97
  };
87
98
 
88
99
  function rows(db: DatabaseWrapper, sql: string): Record<string, unknown>[] {
@@ -179,6 +190,25 @@ export function ensureProjectRoomSchema(db: DatabaseWrapper): void {
179
190
  const applied = hasMigrationTable ? rows(db, `SELECT name, version FROM lattice_schema_migrations WHERE store_name = 'project_room' AND name = '${migrationName}'`)[0] : undefined;
180
191
  if (applied) {
181
192
  if (applied.version !== 1) throw new Error(`Invalid project room schema migration version: ${String(applied.version)}`);
193
+ const hasReadStates = rows(db, "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'lt_project_room_read_states'").length > 0;
194
+ if (!hasReadStates) {
195
+ raw.exec("BEGIN IMMEDIATE");
196
+ try {
197
+ raw.exec(`CREATE TABLE IF NOT EXISTS lt_project_room_read_states (
198
+ tenant_id TEXT NOT NULL, room_id TEXT NOT NULL, user_id TEXT NOT NULL,
199
+ last_read_at TEXT NOT NULL, updated_at TEXT NOT NULL,
200
+ PRIMARY KEY (tenant_id, room_id, user_id)
201
+ ); CREATE INDEX IF NOT EXISTS idx_lt_project_room_read_states_user
202
+ ON lt_project_room_read_states (tenant_id, user_id);`);
203
+ raw.run("INSERT INTO lattice_schema_migrations (store_name, name, version, applied_at) VALUES (?, ?, ?, ?)",
204
+ ["project_room", "create_project_room_read_states", 2, new Date().toISOString()]);
205
+ raw.exec("COMMIT");
206
+ db.save();
207
+ } catch (error) {
208
+ try { raw.exec("ROLLBACK"); } catch { /* preserve the schema failure */ }
209
+ throw error;
210
+ }
211
+ }
182
212
  validateProjectRoomSchema(db);
183
213
  marked[schemaMarker] = true;
184
214
  return;