@axiom-lattice/local-stores 1.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.
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Local SQLite implementation of IMessageQueueStore.
3
+ */
4
+
5
+ import { DatabaseWrapper } from "../database";
6
+ import type {
7
+ IMessageQueueStore,
8
+ PendingMessage,
9
+ AddMessageParams,
10
+ ThreadInfo,
11
+ } from "@axiom-lattice/core";
12
+ import { ensureTable, nowISO, parseISO } from "../database";
13
+ import { randomUUID } from "crypto";
14
+
15
+ const DDL = `
16
+ CREATE TABLE IF NOT EXISTS lt_thread_message_queue (
17
+ id TEXT PRIMARY KEY,
18
+ thread_id TEXT NOT NULL,
19
+ tenant_id TEXT NOT NULL,
20
+ assistant_id TEXT NOT NULL,
21
+ message_content TEXT NOT NULL,
22
+ message_type TEXT NOT NULL DEFAULT 'human',
23
+ sequence_order INTEGER NOT NULL DEFAULT 0,
24
+ priority INTEGER NOT NULL DEFAULT 0,
25
+ status TEXT NOT NULL DEFAULT 'pending',
26
+ command TEXT,
27
+ custom_run_config TEXT,
28
+ created_at TEXT NOT NULL
29
+ );
30
+ CREATE INDEX IF NOT EXISTS idx_lt_tmq_thread ON lt_thread_message_queue(thread_id, status);
31
+ CREATE INDEX IF NOT EXISTS idx_lt_tmq_pending ON lt_thread_message_queue(status, priority DESC, sequence_order ASC);
32
+ `;
33
+
34
+ interface QueueRow {
35
+ id: string;
36
+ thread_id: string;
37
+ tenant_id: string;
38
+ assistant_id: string;
39
+ message_content: string;
40
+ message_type: string;
41
+ sequence_order: number;
42
+ priority: number;
43
+ status: string;
44
+ command: string | null;
45
+ custom_run_config: string | null;
46
+ created_at: string;
47
+ }
48
+
49
+ export class LocalThreadMessageQueueStore implements IMessageQueueStore {
50
+ private db: DatabaseWrapper;
51
+
52
+ constructor(db: DatabaseWrapper) {
53
+ this.db = db;
54
+ ensureTable(db, DDL);
55
+ }
56
+
57
+ async addMessage(params: AddMessageParams): Promise<PendingMessage> {
58
+ const now = nowISO();
59
+ const id = params.id || randomUUID();
60
+
61
+ const seqRow = this.db.prepare(
62
+ `SELECT COALESCE(MAX(sequence_order), 0) + 1 as next_seq FROM lt_thread_message_queue WHERE thread_id = ?`,
63
+ ).get(params.threadId) as { next_seq: number };
64
+ const nextSeq = seqRow.next_seq;
65
+
66
+ this.db.prepare(
67
+ `INSERT INTO lt_thread_message_queue (id, thread_id, tenant_id, assistant_id, message_content, message_type, sequence_order, priority, command, custom_run_config, created_at)
68
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
69
+ ).run(
70
+ id, params.threadId, params.tenantId, params.assistantId,
71
+ JSON.stringify(params.content), params.type || "human", nextSeq,
72
+ params.priority || 0,
73
+ params.command ? JSON.stringify(params.command) : null,
74
+ params.custom_run_config ? JSON.stringify(params.custom_run_config) : null,
75
+ now,
76
+ );
77
+
78
+ return this.getById(id);
79
+ }
80
+
81
+ async addMessageAtHead(params: AddMessageParams): Promise<PendingMessage> {
82
+ const now = nowISO();
83
+ const id = params.id || randomUUID();
84
+
85
+ const seqRow = this.db.prepare(
86
+ `SELECT COALESCE(MAX(sequence_order), 0) + 1 as next_seq FROM lt_thread_message_queue WHERE thread_id = ?`,
87
+ ).get(params.threadId) as { next_seq: number };
88
+
89
+ this.db.prepare(
90
+ `INSERT INTO lt_thread_message_queue (id, thread_id, tenant_id, assistant_id, message_content, message_type, sequence_order, priority, command, custom_run_config, created_at)
91
+ VALUES (?, ?, ?, ?, ?, ?, ?, 100, ?, ?, ?)`,
92
+ ).run(
93
+ id, params.threadId, params.tenantId!, params.assistantId!,
94
+ JSON.stringify(params.content), params.type || "human", seqRow.next_seq,
95
+ params.command ? JSON.stringify(params.command) : null,
96
+ params.custom_run_config ? JSON.stringify(params.custom_run_config) : null,
97
+ now,
98
+ );
99
+
100
+ return this.getById(id);
101
+ }
102
+
103
+ async getPendingMessages(threadId: string): Promise<PendingMessage[]> {
104
+ const rows = this.db.prepare(
105
+ `SELECT * FROM lt_thread_message_queue WHERE thread_id = ? AND status = 'pending' ORDER BY priority DESC, sequence_order ASC`,
106
+ ).all(threadId) as unknown as QueueRow[];
107
+ return rows.map(rowToMessage);
108
+ }
109
+
110
+ async getProcessingMessages(threadId: string): Promise<PendingMessage[]> {
111
+ const rows = this.db.prepare(
112
+ `SELECT * FROM lt_thread_message_queue WHERE thread_id = ? AND status = 'processing' ORDER BY priority DESC, sequence_order ASC`,
113
+ ).all(threadId) as unknown as QueueRow[];
114
+ return rows.map(rowToMessage);
115
+ }
116
+
117
+ async getQueueSize(threadId: string): Promise<number> {
118
+ const row = this.db.prepare(
119
+ `SELECT COUNT(*) as count FROM lt_thread_message_queue WHERE thread_id = ? AND status = 'pending'`,
120
+ ).get(threadId) as { count: number };
121
+ return row.count;
122
+ }
123
+
124
+ async getThreadsWithPendingMessages(): Promise<ThreadInfo[]> {
125
+ const rows = this.db.prepare(
126
+ `SELECT DISTINCT tenant_id, assistant_id, thread_id FROM lt_thread_message_queue WHERE status IN ('pending', 'processing') ORDER BY thread_id`,
127
+ ).all() as { tenant_id: string; assistant_id: string; thread_id: string }[];
128
+ return rows.map((r) => ({
129
+ tenantId: r.tenant_id,
130
+ assistantId: r.assistant_id,
131
+ threadId: r.thread_id,
132
+ }));
133
+ }
134
+
135
+ async removeMessage(messageId: string): Promise<boolean> {
136
+ const result = this.db.prepare(`DELETE FROM lt_thread_message_queue WHERE id = ?`).run(messageId);
137
+ return result.changes > 0;
138
+ }
139
+
140
+ async clearMessages(threadId: string): Promise<void> {
141
+ this.db.prepare(`DELETE FROM lt_thread_message_queue WHERE thread_id = ?`).run(threadId);
142
+ }
143
+
144
+ async markProcessing(messageId: string): Promise<void> {
145
+ this.db.prepare(`UPDATE lt_thread_message_queue SET status = 'processing' WHERE id = ?`).run(messageId);
146
+ }
147
+
148
+ async resetProcessingToPending(threadId: string): Promise<number> {
149
+ const result = this.db.prepare(
150
+ `UPDATE lt_thread_message_queue SET status = 'pending' WHERE thread_id = ? AND status = 'processing'`,
151
+ ).run(threadId);
152
+ return result.changes;
153
+ }
154
+
155
+ private getById(id: string): PendingMessage {
156
+ const row = this.db.prepare(`SELECT * FROM lt_thread_message_queue WHERE id = ?`).get(id) as unknown as QueueRow;
157
+ return rowToMessage(row);
158
+ }
159
+ }
160
+
161
+ function rowToMessage(row: QueueRow): PendingMessage {
162
+ return {
163
+ id: row.id,
164
+ content: JSON.parse(row.message_content),
165
+ type: row.message_type as "human" | "system",
166
+ sequence: row.sequence_order,
167
+ createdAt: parseISO(row.created_at),
168
+ priority: row.priority || 0,
169
+ command: row.command ? JSON.parse(row.command) : undefined,
170
+ custom_run_config: row.custom_run_config ? JSON.parse(row.custom_run_config) : undefined,
171
+ };
172
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Local SQLite implementation of ThreadStore.
3
+ */
4
+
5
+ import { DatabaseWrapper } from "../database";
6
+ import type {
7
+ ThreadStore,
8
+ Thread,
9
+ CreateThreadRequest,
10
+ } from "@axiom-lattice/protocols";
11
+ import { ensureTable, nowISO, parseISO } from "../database";
12
+
13
+ const DDL = `
14
+ CREATE TABLE IF NOT EXISTS lt_threads (
15
+ id TEXT NOT NULL,
16
+ tenant_id TEXT NOT NULL,
17
+ assistant_id TEXT NOT NULL,
18
+ metadata TEXT DEFAULT '{}',
19
+ created_at TEXT NOT NULL,
20
+ updated_at TEXT NOT NULL,
21
+ PRIMARY KEY (tenant_id, id)
22
+ );
23
+ CREATE INDEX IF NOT EXISTS idx_lt_threads_assistant ON lt_threads(tenant_id, assistant_id);
24
+ `;
25
+
26
+ interface ThreadRow {
27
+ id: string;
28
+ tenant_id: string;
29
+ assistant_id: string;
30
+ metadata: string;
31
+ created_at: string;
32
+ updated_at: string;
33
+ }
34
+
35
+ export class LocalThreadStore implements ThreadStore {
36
+ private db: DatabaseWrapper;
37
+
38
+ constructor(db: DatabaseWrapper) {
39
+ this.db = db;
40
+ ensureTable(db, DDL);
41
+ }
42
+
43
+ async getThreadsByAssistantId(
44
+ tenantId: string,
45
+ assistantId: string,
46
+ metadataFilter?: Record<string, string>,
47
+ ): Promise<Thread[]> {
48
+ const rows = this.db.prepare(
49
+ `SELECT * FROM lt_threads WHERE tenant_id = ? AND assistant_id = ? ORDER BY created_at DESC`,
50
+ ).all(tenantId, assistantId) as unknown as ThreadRow[];
51
+
52
+ let threads = rows.map(mapRowToThread);
53
+
54
+ if (metadataFilter && Object.keys(metadataFilter).length > 0) {
55
+ threads = threads.filter((t) =>
56
+ Object.entries(metadataFilter).every(
57
+ ([key, value]) => t.metadata?.[key] === value,
58
+ ),
59
+ );
60
+ }
61
+
62
+ return threads;
63
+ }
64
+
65
+ async getThreadById(tenantId: string, threadId: string): Promise<Thread | undefined> {
66
+ const row = this.db.prepare(
67
+ `SELECT * FROM lt_threads WHERE tenant_id = ? AND id = ?`,
68
+ ).get(tenantId, threadId) as unknown as ThreadRow | undefined;
69
+ return row ? mapRowToThread(row) : undefined;
70
+ }
71
+
72
+ async createThread(
73
+ tenantId: string,
74
+ assistantId: string,
75
+ threadId: string,
76
+ data: CreateThreadRequest,
77
+ ): Promise<Thread> {
78
+ const now = nowISO();
79
+ const metadata = JSON.stringify(data.metadata || {});
80
+
81
+ this.db.prepare(
82
+ `INSERT INTO lt_threads (id, tenant_id, assistant_id, metadata, created_at, updated_at)
83
+ VALUES (?, ?, ?, ?, ?, ?)
84
+ ON CONFLICT(tenant_id, id) DO UPDATE SET
85
+ assistant_id = excluded.assistant_id,
86
+ metadata = excluded.metadata,
87
+ updated_at = excluded.updated_at`,
88
+ ).run(threadId, tenantId, assistantId, metadata, now, now);
89
+
90
+ return {
91
+ id: threadId,
92
+ tenantId,
93
+ assistantId,
94
+ metadata: data.metadata || {},
95
+ createdAt: parseISO(now),
96
+ updatedAt: parseISO(now),
97
+ };
98
+ }
99
+
100
+ async updateThread(
101
+ tenantId: string,
102
+ threadId: string,
103
+ updates: Partial<CreateThreadRequest>,
104
+ ): Promise<Thread | null> {
105
+ const existing = await this.getThreadById(tenantId, threadId);
106
+ if (!existing) return null;
107
+
108
+ const updatedMetadata = {
109
+ ...existing.metadata,
110
+ ...(updates.metadata || {}),
111
+ };
112
+ const now = nowISO();
113
+
114
+ this.db.prepare(
115
+ `UPDATE lt_threads SET metadata = ?, updated_at = ? WHERE tenant_id = ? AND id = ?`,
116
+ ).run(JSON.stringify(updatedMetadata), now, tenantId, threadId);
117
+
118
+ return {
119
+ ...existing,
120
+ metadata: updatedMetadata,
121
+ updatedAt: parseISO(now),
122
+ };
123
+ }
124
+
125
+ async deleteThread(tenantId: string, threadId: string): Promise<boolean> {
126
+ const result = this.db.prepare(
127
+ `DELETE FROM lt_threads WHERE tenant_id = ? AND id = ?`,
128
+ ).run(tenantId, threadId);
129
+ return result.changes > 0;
130
+ }
131
+
132
+ async hasThread(tenantId: string, threadId: string): Promise<boolean> {
133
+ const row = this.db.prepare(
134
+ `SELECT 1 FROM lt_threads WHERE tenant_id = ? AND id = ? LIMIT 1`,
135
+ ).get(tenantId, threadId);
136
+ return row !== undefined;
137
+ }
138
+ }
139
+
140
+ function mapRowToThread(row: ThreadRow): Thread {
141
+ return {
142
+ id: row.id,
143
+ tenantId: row.tenant_id,
144
+ assistantId: row.assistant_id,
145
+ metadata: JSON.parse(row.metadata || "{}"),
146
+ createdAt: parseISO(row.created_at),
147
+ updatedAt: parseISO(row.updated_at),
148
+ };
149
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Local SQLite implementation of UserStore.
3
+ */
4
+
5
+ import { DatabaseWrapper } from "../database";
6
+ import type {
7
+ UserStore,
8
+ User,
9
+ UserStatus,
10
+ CreateUserRequest,
11
+ UpdateUserRequest,
12
+ } from "@axiom-lattice/protocols";
13
+ import { ensureTable, nowISO, parseISO } from "../database";
14
+
15
+ const DDL = `
16
+ CREATE TABLE IF NOT EXISTS lt_users (
17
+ id TEXT PRIMARY KEY,
18
+ email TEXT NOT NULL,
19
+ name TEXT NOT NULL,
20
+ status TEXT NOT NULL DEFAULT 'pending',
21
+ metadata TEXT DEFAULT '{}',
22
+ created_at TEXT NOT NULL,
23
+ updated_at TEXT NOT NULL
24
+ );
25
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_lt_users_email ON lt_users(email);
26
+ `;
27
+
28
+ interface UserRow {
29
+ id: string;
30
+ email: string;
31
+ name: string;
32
+ status: string;
33
+ metadata: string;
34
+ created_at: string;
35
+ updated_at: string;
36
+ }
37
+
38
+ export class LocalUserStore implements UserStore {
39
+ private db: DatabaseWrapper;
40
+
41
+ constructor(db: DatabaseWrapper) {
42
+ this.db = db;
43
+ ensureTable(db, DDL);
44
+ }
45
+
46
+ async getAllUsers(): Promise<User[]> {
47
+ const rows = this.db.prepare(
48
+ `SELECT * FROM lt_users ORDER BY created_at DESC`,
49
+ ).all() as unknown as UserRow[];
50
+ return rows.map(mapRowToUser);
51
+ }
52
+
53
+ async getUserById(id: string): Promise<User | null> {
54
+ const row = this.db.prepare(
55
+ `SELECT * FROM lt_users WHERE id = ?`,
56
+ ).get(id) as unknown as UserRow | undefined;
57
+ return row ? mapRowToUser(row) : null;
58
+ }
59
+
60
+ async getUserByEmail(email: string): Promise<User | null> {
61
+ const row = this.db.prepare(
62
+ `SELECT * FROM lt_users WHERE email = ?`,
63
+ ).get(email) as unknown as UserRow | undefined;
64
+ return row ? mapRowToUser(row) : null;
65
+ }
66
+
67
+ async createUser(id: string, data: CreateUserRequest): Promise<User> {
68
+ const now = nowISO();
69
+ const status = data.status || "pending";
70
+ const metadata = JSON.stringify(data.metadata || {});
71
+
72
+ this.db.prepare(
73
+ `INSERT INTO lt_users (id, email, name, status, metadata, created_at, updated_at)
74
+ VALUES (?, ?, ?, ?, ?, ?, ?)
75
+ ON CONFLICT(id) DO UPDATE SET
76
+ email = excluded.email,
77
+ name = excluded.name,
78
+ status = excluded.status,
79
+ metadata = excluded.metadata,
80
+ updated_at = excluded.updated_at`,
81
+ ).run(id, data.email, data.name, status, metadata, now, now);
82
+
83
+ return {
84
+ id,
85
+ email: data.email,
86
+ name: data.name,
87
+ status,
88
+ metadata: data.metadata,
89
+ createdAt: parseISO(now),
90
+ updatedAt: parseISO(now),
91
+ };
92
+ }
93
+
94
+ async updateUser(id: string, updates: UpdateUserRequest): Promise<User | null> {
95
+ const existing = await this.getUserById(id);
96
+ if (!existing) return null;
97
+
98
+ const setClauses: string[] = [];
99
+ const values: unknown[] = [];
100
+
101
+ if (updates.email !== undefined) { setClauses.push("email = ?"); values.push(updates.email); }
102
+ if (updates.name !== undefined) { setClauses.push("name = ?"); values.push(updates.name); }
103
+ if (updates.status !== undefined) { setClauses.push("status = ?"); values.push(updates.status); }
104
+ if (updates.metadata !== undefined) { setClauses.push("metadata = ?"); values.push(JSON.stringify(updates.metadata)); }
105
+
106
+ if (setClauses.length === 0) return existing;
107
+
108
+ const now = nowISO();
109
+ setClauses.push("updated_at = ?");
110
+ values.push(now);
111
+ values.push(id);
112
+
113
+ this.db.prepare(
114
+ `UPDATE lt_users SET ${setClauses.join(", ")} WHERE id = ?`,
115
+ ).run(...values);
116
+
117
+ return this.getUserById(id);
118
+ }
119
+
120
+ async deleteUser(id: string): Promise<boolean> {
121
+ const result = this.db.prepare(`DELETE FROM lt_users WHERE id = ?`).run(id);
122
+ return result.changes > 0;
123
+ }
124
+ }
125
+
126
+ function mapRowToUser(row: UserRow): User {
127
+ return {
128
+ id: row.id,
129
+ email: row.email,
130
+ name: row.name,
131
+ status: row.status as UserStatus,
132
+ metadata: JSON.parse(row.metadata || "{}"),
133
+ createdAt: parseISO(row.created_at),
134
+ updatedAt: parseISO(row.updated_at),
135
+ };
136
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Local SQLite implementation of UserTenantLinkStore.
3
+ */
4
+
5
+ import { DatabaseWrapper } from "../database";
6
+ import type {
7
+ UserTenantLinkStore,
8
+ UserTenantLink,
9
+ UserTenantRole,
10
+ CreateUserTenantLinkRequest,
11
+ UpdateUserTenantLinkRequest,
12
+ } from "@axiom-lattice/protocols";
13
+ import { ensureTable, nowISO, parseISO } from "../database";
14
+
15
+ const DDL = `
16
+ CREATE TABLE IF NOT EXISTS lt_user_tenant_links (
17
+ user_id TEXT NOT NULL,
18
+ tenant_id TEXT NOT NULL,
19
+ role TEXT NOT NULL DEFAULT 'member',
20
+ joined_at TEXT NOT NULL,
21
+ metadata TEXT DEFAULT '{}',
22
+ PRIMARY KEY (user_id, tenant_id)
23
+ );
24
+ CREATE INDEX IF NOT EXISTS idx_lt_utl_user ON lt_user_tenant_links(user_id);
25
+ CREATE INDEX IF NOT EXISTS idx_lt_utl_tenant ON lt_user_tenant_links(tenant_id);
26
+ `;
27
+
28
+ interface LinkRow {
29
+ user_id: string;
30
+ tenant_id: string;
31
+ role: string;
32
+ joined_at: string;
33
+ metadata: string;
34
+ }
35
+
36
+ export class LocalUserTenantLinkStore implements UserTenantLinkStore {
37
+ private db: DatabaseWrapper;
38
+
39
+ constructor(db: DatabaseWrapper) {
40
+ this.db = db;
41
+ ensureTable(db, DDL);
42
+ }
43
+
44
+ async getTenantsByUser(userId: string): Promise<UserTenantLink[]> {
45
+ const rows = this.db.prepare(
46
+ `SELECT * FROM lt_user_tenant_links WHERE user_id = ? ORDER BY joined_at DESC`,
47
+ ).all(userId) as unknown as LinkRow[];
48
+ return rows.map(mapRowToLink);
49
+ }
50
+
51
+ async getUsersByTenant(tenantId: string): Promise<UserTenantLink[]> {
52
+ const rows = this.db.prepare(
53
+ `SELECT * FROM lt_user_tenant_links WHERE tenant_id = ? ORDER BY joined_at DESC`,
54
+ ).all(tenantId) as unknown as LinkRow[];
55
+ return rows.map(mapRowToLink);
56
+ }
57
+
58
+ async getLink(userId: string, tenantId: string): Promise<UserTenantLink | null> {
59
+ const row = this.db.prepare(
60
+ `SELECT * FROM lt_user_tenant_links WHERE user_id = ? AND tenant_id = ?`,
61
+ ).get(userId, tenantId) as unknown as LinkRow | undefined;
62
+ return row ? mapRowToLink(row) : null;
63
+ }
64
+
65
+ async createLink(data: CreateUserTenantLinkRequest): Promise<UserTenantLink> {
66
+ const now = nowISO();
67
+ const role = data.role || "member";
68
+ const metadata = JSON.stringify(data.metadata || {});
69
+
70
+ this.db.prepare(
71
+ `INSERT INTO lt_user_tenant_links (user_id, tenant_id, role, joined_at, metadata)
72
+ VALUES (?, ?, ?, ?, ?)
73
+ ON CONFLICT(user_id, tenant_id) DO UPDATE SET
74
+ role = excluded.role,
75
+ metadata = excluded.metadata`,
76
+ ).run(data.userId, data.tenantId, role, now, metadata);
77
+
78
+ return {
79
+ userId: data.userId,
80
+ tenantId: data.tenantId,
81
+ role,
82
+ joinedAt: parseISO(now),
83
+ metadata: data.metadata,
84
+ };
85
+ }
86
+
87
+ async updateLink(
88
+ userId: string,
89
+ tenantId: string,
90
+ updates: UpdateUserTenantLinkRequest,
91
+ ): Promise<UserTenantLink | null> {
92
+ const existing = await this.getLink(userId, tenantId);
93
+ if (!existing) return null;
94
+
95
+ const setClauses: string[] = [];
96
+ const values: unknown[] = [];
97
+
98
+ if (updates.role !== undefined) { setClauses.push("role = ?"); values.push(updates.role); }
99
+ if (updates.metadata !== undefined) { setClauses.push("metadata = ?"); values.push(JSON.stringify(updates.metadata)); }
100
+
101
+ if (setClauses.length === 0) return existing;
102
+
103
+ values.push(userId, tenantId);
104
+
105
+ this.db.prepare(
106
+ `UPDATE lt_user_tenant_links SET ${setClauses.join(", ")} WHERE user_id = ? AND tenant_id = ?`,
107
+ ).run(...values);
108
+
109
+ return this.getLink(userId, tenantId);
110
+ }
111
+
112
+ async deleteLink(userId: string, tenantId: string): Promise<boolean> {
113
+ const result = this.db.prepare(
114
+ `DELETE FROM lt_user_tenant_links WHERE user_id = ? AND tenant_id = ?`,
115
+ ).run(userId, tenantId);
116
+ return result.changes > 0;
117
+ }
118
+
119
+ async hasLink(userId: string, tenantId: string): Promise<boolean> {
120
+ const row = this.db.prepare(
121
+ `SELECT 1 FROM lt_user_tenant_links WHERE user_id = ? AND tenant_id = ? LIMIT 1`,
122
+ ).get(userId, tenantId);
123
+ return row !== undefined;
124
+ }
125
+ }
126
+
127
+ function mapRowToLink(row: LinkRow): UserTenantLink {
128
+ return {
129
+ userId: row.user_id,
130
+ tenantId: row.tenant_id,
131
+ role: row.role as UserTenantRole,
132
+ joinedAt: parseISO(row.joined_at),
133
+ metadata: JSON.parse(row.metadata || "{}"),
134
+ };
135
+ }