heyquilt 0.0.1 → 0.3.90

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 (204) hide show
  1. package/LICENSE +85 -0
  2. package/README.md +630 -2
  3. package/RELEASES.md +733 -0
  4. package/assets/logo.svg +1 -0
  5. package/bin/quilt.js +816 -0
  6. package/desktop/main.js +315 -0
  7. package/package.json +163 -7
  8. package/src/account.js +563 -0
  9. package/src/admit-policy.js +32 -0
  10. package/src/agent-join.js +370 -0
  11. package/src/agent-task-workflow.js +176 -0
  12. package/src/agents/actions.js +85 -0
  13. package/src/agents/claude-code.js +180 -0
  14. package/src/agents/common.js +21 -0
  15. package/src/agents/cursor-transcripts.js +170 -0
  16. package/src/agents/cursor.js +404 -0
  17. package/src/agents/index.js +69 -0
  18. package/src/ai-vm-signin.js +98 -0
  19. package/src/ai-vm.js +380 -0
  20. package/src/api/access.js +75 -0
  21. package/src/api/activity.js +181 -0
  22. package/src/api/agent-auth.js +130 -0
  23. package/src/api/agent-placement.js +157 -0
  24. package/src/api/audit.js +83 -0
  25. package/src/api/auth.js +17 -0
  26. package/src/api/domains.js +30 -0
  27. package/src/api/file-paths.js +40 -0
  28. package/src/api/file-store.js +99 -0
  29. package/src/api/fly-machines.js +76 -0
  30. package/src/api/github-app.js +172 -0
  31. package/src/api/http.js +34 -0
  32. package/src/api/invite-email.js +53 -0
  33. package/src/api/issues.js +110 -0
  34. package/src/api/join-text.js +78 -0
  35. package/src/api/mailer.js +47 -0
  36. package/src/api/memory-store.js +1083 -0
  37. package/src/api/org-access.js +54 -0
  38. package/src/api/permissions.js +71 -0
  39. package/src/api/relay-cluster.js +251 -0
  40. package/src/api/relay-machines.js +60 -0
  41. package/src/api/routes/access-types.js +54 -0
  42. package/src/api/routes/agent-invites.js +115 -0
  43. package/src/api/routes/agents.js +157 -0
  44. package/src/api/routes/ai-vm.js +369 -0
  45. package/src/api/routes/github.js +380 -0
  46. package/src/api/routes/grants.js +70 -0
  47. package/src/api/routes/invites.js +188 -0
  48. package/src/api/routes/issues.js +47 -0
  49. package/src/api/routes/join.js +167 -0
  50. package/src/api/routes/members.js +110 -0
  51. package/src/api/routes/orgs.js +177 -0
  52. package/src/api/routes/relay-cluster.js +90 -0
  53. package/src/api/routes/relay.js +76 -0
  54. package/src/api/routes/session-invites.js +119 -0
  55. package/src/api/routes/sessions.js +92 -0
  56. package/src/api/routes/teams.js +132 -0
  57. package/src/api/routes/workspace-agents.js +332 -0
  58. package/src/api/routes/workspace-files.js +277 -0
  59. package/src/api/routes/workspace-invites.js +196 -0
  60. package/src/api/routes/workspaces.js +225 -0
  61. package/src/api/server.js +623 -0
  62. package/src/api/slugs.js +20 -0
  63. package/src/api/supabase-store.js +765 -0
  64. package/src/api/team-access.js +30 -0
  65. package/src/api/tokens.js +31 -0
  66. package/src/api/workspace-access.js +62 -0
  67. package/src/api/workspace-reach.js +60 -0
  68. package/src/autowork-tools.js +166 -0
  69. package/src/autowork.js +387 -0
  70. package/src/blobstore.js +99 -0
  71. package/src/boxes.js +37 -0
  72. package/src/branchdocs.js +235 -0
  73. package/src/branches.js +194 -0
  74. package/src/catchup.js +111 -0
  75. package/src/chat-archive.js +85 -0
  76. package/src/chat-links.js +923 -0
  77. package/src/clash.js +140 -0
  78. package/src/cloud-ai.js +50 -0
  79. package/src/commit-request.js +59 -0
  80. package/src/commit.js +213 -0
  81. package/src/connection.js +569 -0
  82. package/src/control.js +245 -0
  83. package/src/conversation.js +125 -0
  84. package/src/doctor.js +156 -0
  85. package/src/duties.js +303 -0
  86. package/src/editors.js +228 -0
  87. package/src/fsutil.js +87 -0
  88. package/src/git-hook.js +48 -0
  89. package/src/git.js +147 -0
  90. package/src/github-commit.js +320 -0
  91. package/src/gitignore.js +49 -0
  92. package/src/gitstate.js +900 -0
  93. package/src/history.js +307 -0
  94. package/src/hooks.js +371 -0
  95. package/src/hosted-agents.js +24 -0
  96. package/src/hosted-client.js +112 -0
  97. package/src/identity.js +99 -0
  98. package/src/inbox.js +237 -0
  99. package/src/integrations.js +221 -0
  100. package/src/key-cli.js +115 -0
  101. package/src/largefiles.js +91 -0
  102. package/src/legacy.js +25 -0
  103. package/src/mcp-proxy.js +40 -0
  104. package/src/mcp-room.js +32 -0
  105. package/src/mcp.js +1261 -0
  106. package/src/merge3.js +158 -0
  107. package/src/merges.js +148 -0
  108. package/src/pass-source.js +176 -0
  109. package/src/passes.js +64 -0
  110. package/src/pathrules.js +127 -0
  111. package/src/persona.js +101 -0
  112. package/src/presence.js +360 -0
  113. package/src/private-file.js +41 -0
  114. package/src/procs.js +70 -0
  115. package/src/project-rules.js +178 -0
  116. package/src/protocol.js +92 -0
  117. package/src/quiet-warnings.js +9 -0
  118. package/src/relay-ai-vm.js +82 -0
  119. package/src/relay-ask.js +141 -0
  120. package/src/relay-ci.js +111 -0
  121. package/src/relay-clash.js +597 -0
  122. package/src/relay-cluster.js +454 -0
  123. package/src/relay-commit.js +887 -0
  124. package/src/relay-filekey.js +46 -0
  125. package/src/relay-git.js +274 -0
  126. package/src/relay-githttp.js +137 -0
  127. package/src/relay-github.js +109 -0
  128. package/src/relay-lease.js +137 -0
  129. package/src/relay-lfs.js +37 -0
  130. package/src/relay-mcp.js +1679 -0
  131. package/src/relay-metrics.js +75 -0
  132. package/src/relay-outbound.js +1144 -0
  133. package/src/relay-sync.js +719 -0
  134. package/src/relay-turn-sync.js +307 -0
  135. package/src/relay-upstream.js +934 -0
  136. package/src/relay-webhooks.js +111 -0
  137. package/src/releases.js +131 -0
  138. package/src/report.js +129 -0
  139. package/src/runner.js +306 -0
  140. package/src/same-edit.js +18 -0
  141. package/src/server.js +4187 -0
  142. package/src/session-access.js +170 -0
  143. package/src/session-name.js +15 -0
  144. package/src/session.js +7361 -0
  145. package/src/settings.js +46 -0
  146. package/src/setup.js +184 -0
  147. package/src/shell-path.js +46 -0
  148. package/src/status.js +199 -0
  149. package/src/summarize.js +109 -0
  150. package/src/sync-words.js +42 -0
  151. package/src/task-comments.js +92 -0
  152. package/src/tasks.js +670 -0
  153. package/src/textdiff.js +20 -0
  154. package/src/turn-sync-plan.js +142 -0
  155. package/src/turn-sync.js +191 -0
  156. package/src/ui/access-form.js +37 -0
  157. package/src/ui/agent-env-key.js +76 -0
  158. package/src/ui/agent-guide.js +127 -0
  159. package/src/ui/agent-kinds.js +175 -0
  160. package/src/ui/agent-place.js +265 -0
  161. package/src/ui/app.css +2309 -0
  162. package/src/ui/app.js +542 -0
  163. package/src/ui/board.js +465 -0
  164. package/src/ui/boxes.js +76 -0
  165. package/src/ui/branches.js +111 -0
  166. package/src/ui/catchup.js +77 -0
  167. package/src/ui/changes.js +159 -0
  168. package/src/ui/chat.js +266 -0
  169. package/src/ui/cloud-ai-model.js +107 -0
  170. package/src/ui/cloud-ai.js +221 -0
  171. package/src/ui/common.js +431 -0
  172. package/src/ui/csv.js +21 -0
  173. package/src/ui/diffview.js +74 -0
  174. package/src/ui/feed-convs.js +39 -0
  175. package/src/ui/feed.js +174 -0
  176. package/src/ui/files.js +579 -0
  177. package/src/ui/fileview.js +123 -0
  178. package/src/ui/git-rule.js +60 -0
  179. package/src/ui/home.js +1323 -0
  180. package/src/ui/index.html +16 -0
  181. package/src/ui/invite.js +97 -0
  182. package/src/ui/invites.js +79 -0
  183. package/src/ui/loom-model.js +516 -0
  184. package/src/ui/loom.css +349 -0
  185. package/src/ui/loom.js +753 -0
  186. package/src/ui/mark.js +104 -0
  187. package/src/ui/merges.js +158 -0
  188. package/src/ui/rail-model.js +78 -0
  189. package/src/ui/rail.js +173 -0
  190. package/src/ui/releases.js +182 -0
  191. package/src/ui/schedule.js +156 -0
  192. package/src/ui/session.js +3394 -0
  193. package/src/ui/signin.js +75 -0
  194. package/src/ui/sounds.js +110 -0
  195. package/src/ui/tool-logo.js +86 -0
  196. package/src/ui/tree.js +276 -0
  197. package/src/ui/workspaces.js +542 -0
  198. package/src/ui-server.js +1272 -0
  199. package/src/update-check.js +67 -0
  200. package/src/upstream.js +155 -0
  201. package/src/webhooks.js +235 -0
  202. package/src/workspace-cli.js +62 -0
  203. package/src/workspace-tools.js +340 -0
  204. package/index.js +0 -2
@@ -0,0 +1,765 @@
1
+ // The accounts API's data in Supabase, using the service role (row-level security is
2
+ // for the website; the API is trusted and writes the secrets).
3
+ import { createClient } from '@supabase/supabase-js'
4
+
5
+ const toCamel = (row) => row && Object.fromEntries(Object.entries(row).map(([k, v]) => [k.replace(/_([a-z])/g, (m, c) => c.toUpperCase()), v]))
6
+ const toSnake = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined).map(([k, v]) => [k.replace(/[A-Z]/g, (c) => '_' + c.toLowerCase()), v]))
7
+ // A PostgREST or= filter for "sent to this account or this address"; a value that could
8
+ // break out of the filter (a comma, quote or bracket) is left out rather than escaped.
9
+ const SAFE_OR = /^[^\s,()"\\]+$/
10
+ const orFor = ({ account, email }) => [account && SAFE_OR.test(account) && `account.eq.${account}`, email && SAFE_OR.test(email) && `email.eq.${email}`].filter(Boolean).join(',')
11
+ const ts = (v) => (v == null ? v : typeof v === 'number' ? new Date(v).toISOString() : v)
12
+ const ms = (v) => (v == null ? v : Date.parse(v))
13
+ // Postgres's foreign-key-violation code, for the same checks memory-store.js mirrors.
14
+ const fkViolation = (what) => Object.assign(new Error(`${what} is still referenced`), { code: '23503' })
15
+ // No secrets live on agents any more; the columns are still named, like every other table.
16
+ const AGENT = 'id, name, provider, type, description, public_key, owner_user_id, org_id, invited_by, created_at, last_used_at, revoked_at'
17
+ // Named columns for the org tables, so a select never picks up a secret by accident.
18
+ const ORG = 'id, name, slug, owner_id, domain, domain_requests, created_at'
19
+ const ROLE = 'id, org_id, name, builtin, grants, created_at'
20
+ const MEMBER = 'id, org_id, user_id, agent_id, role_id, joined_at'
21
+ const TEAM = 'id, org_id, name, created_at'
22
+ const TEAM_MEMBER = 'team_id, member_id, access, scopes, added_at'
23
+ const INVITE = 'id, org_id, email, role_id, token_hash, invited_by, expires_at, accepted_at, cancelled_at, created_at'
24
+ const REQUEST = 'id, org_id, user_id, email, status, decided_by, decided_at, created_at'
25
+ const AGENT_INVITE = 'id, token_hash, owner_user_id, org_id, created_by, role_id, teams, expires_at, used_at, used_by_agent_id, rejoined, cancelled_at, created_at, workspace_id, workspace_access, workspace_sessions, global'
26
+ const AGENT_APP_KEY = 'id, agent_id, name, key_hash, created_at, last_used_at, revoked_at'
27
+ const AGENT_KEY = 'id, agent_id, family_id, access_hash, refresh_hash, access_expires_at, refresh_expires_at, refreshed_at, revoked_at, created_at'
28
+ const RELAY_SESSION = 'room, name, owner_account, created_at, last_active_at, renamed_at, workspace_id, workspace_linked_by'
29
+ const ACCESS_TYPE = 'id, owner_account, name, files, folders, talk, created_at, updated_at'
30
+ const GRANT = 'room, account, type_id, tighten, granted_by, created_at, updated_at'
31
+ const SESSION_INVITE = 'id, room, email, account, account_name, type_id, invited_by, created_at, expires_at, used_at, used_by, cancelled_at'
32
+ const WORKSPACE = 'id, owner_user_id, org_id, name, description, color, created_by, created_at, archived_at, quota_bytes, used_bytes, file_count'
33
+ const WORKSPACE_MEMBER = 'workspace_id, account, access, sessions, added_by, added_at'
34
+ const WORKSPACE_INVITE = 'id, workspace_id, email, account, account_name, access, invited_by, created_at, expires_at, accepted_at, accepted_by, declined_at, cancelled_at'
35
+ const WORKSPACE_FILE = 'id, workspace_id, path, kind, size, mime, sha256, version, object_key, note, uploaded_by, uploaded_at, confirmed_at, deleted_at'
36
+ const WORKSPACE_FILE_VERSION = 'file_id, version, size, sha256, object_key, note, uploaded_by, uploaded_at'
37
+ const AGENT_PLACEMENT = 'agent_id, reach, workspace_ids, sessions, access, scopes, updated_by, updated_at'
38
+ const WORKSPACE_AGENT_OVERRIDE = 'workspace_id, agent_id, sessions, excluded'
39
+ const SESSION_AGENT_EXCLUSION = 'room, agent_id, excluded_by, created_at'
40
+ const AGENT_WEBHOOK = 'agent_id, url, secret, created_at, updated_at'
41
+ const AI_VM = 'user_id, tool, model, prompt, cap, agent_id, invite_id, machine_id, volume_id, region, state, error, signed_in, usage_today, last_report_at, created_at, updated_at'
42
+ const RELAY_MACHINE = 'id, region, capacity, state, dedicated_room, load, hello_at, reported_at, created_at'
43
+ const RELAY_ROOM = 'room, machine_id, state, stats, busy_minutes, heavy, placed_at, reported_at'
44
+ const RELAY_JOB = 'key, state, detail, started_at, finished_at'
45
+ // PostgREST hands back at most 1000 rows per request: longer lists are read a page at a time.
46
+ const PAGE = 1000
47
+
48
+ // One mapper for every table: camelCases the columns and turns every `*At` field
49
+ // (createdAt, updatedAt, lastSeenAt, lastUsedAt, revokedAt, expiresAt, joinedAt,
50
+ // lastActiveAt, ...) from a Postgres timestamp string into epoch milliseconds,
51
+ // matching the memory store (the tested reference).
52
+ export const rowFrom = (row) => row && Object.fromEntries(Object.entries(toCamel(row)).map(([k, v]) => [k, /At$/.test(k) ? ms(v) : v]))
53
+
54
+ // `client` lets tests pass a stand-in for the supabase client.
55
+ export function createSupabaseStore ({ url, serviceKey, client }) {
56
+ const db = client || createClient(url, serviceKey, { auth: { persistSession: false, autoRefreshToken: false } })
57
+ const one = async (q) => { const { data, error } = await q; if (error) throw error; return data }
58
+ const memberOf = async (orgId, userId) => rowFrom(await one(db.from('org_members').select(MEMBER).eq('org_id', orgId).eq('user_id', userId).maybeSingle()))
59
+ // Every row of a query, a page at a time. `build` makes a fresh query (with a stable order) per page.
60
+ const pages = async (build) => {
61
+ const out = []
62
+ for (let from = 0; ; from += PAGE) {
63
+ const rows = await one(build().range(from, from + PAGE - 1))
64
+ out.push(...rows)
65
+ if (rows.length < PAGE) return out
66
+ }
67
+ }
68
+
69
+ return {
70
+ async createLink (l) {
71
+ return rowFrom(await one(db.from('device_links').insert(toSnake({ ...l, expiresAt: ts(l.expiresAt) })).select().single()))
72
+ },
73
+ async linkByDeviceCode (h) { return rowFrom(await one(db.from('device_links').select().eq('device_code_hash', h).maybeSingle())) },
74
+ async linkByUserCode (c) { return rowFrom(await one(db.from('device_links').select().eq('user_code', c).maybeSingle())) },
75
+ async updateLink (id, patch) {
76
+ return rowFrom(await one(db.from('device_links').update(toSnake({ ...patch, expiresAt: ts(patch.expiresAt) })).eq('id', id).select().single()))
77
+ },
78
+ // Check-and-set: only flips status if it's still fromStatus, so two concurrent
79
+ // callers can't both win the same link (e.g. handing out a device token twice).
80
+ async claimLink (id, fromStatus, toStatus) {
81
+ const rows = await one(db.from('device_links').update({ status: toStatus }).eq('id', id).eq('status', fromStatus).select('id'))
82
+ return rows.length > 0
83
+ },
84
+ // One row per (account, key), so approving a link for someone else's key makes
85
+ // your own row instead of taking theirs. A relink retires the old token.
86
+ async upsertDevice ({ userId, name, platform, publicKey }) {
87
+ return rowFrom(await one(db.from('devices')
88
+ .upsert({ user_id: userId, name, platform, public_key: publicKey, token_hash: null, revoked_at: null }, { onConflict: 'user_id,public_key' })
89
+ .select().single()))
90
+ },
91
+ async setDeviceToken (id, tokenHash) { await one(db.from('devices').update({ token_hash: tokenHash }).eq('id', id)) },
92
+ async deviceByToken (h) { return rowFrom(await one(db.from('devices').select().eq('token_hash', h).is('revoked_at', null).maybeSingle())) },
93
+ async deviceByPublicKey (publicKey) {
94
+ return rowFrom((await one(db.from('devices').select().eq('public_key', publicKey).is('revoked_at', null).order('last_seen_at', { ascending: false }).limit(1)))[0] || null)
95
+ },
96
+ async userHasDevice (userId, publicKey) {
97
+ return (await one(db.from('devices').select('id').eq('user_id', userId).eq('public_key', publicKey).limit(1))).length > 0
98
+ },
99
+ async touchDevice (id) { await one(db.from('devices').update({ last_seen_at: new Date().toISOString() }).eq('id', id)) },
100
+ async revokeDevice (id) { await one(db.from('devices').update({ revoked_at: new Date().toISOString(), token_hash: null }).eq('id', id)) },
101
+ async profile (userId) { return rowFrom(await one(db.from('profiles').select('id, name, color, tool').eq('id', userId).maybeSingle())) },
102
+ // 'personal' or 'org': set once at sign-up, and the only thing POST /v1/orgs checks.
103
+ async profileKind (userId) { return (await one(db.from('profiles').select('kind').eq('id', userId).maybeSingle()))?.kind || null },
104
+ async updateProfile (userId, { name, color, tool }) {
105
+ return rowFrom(await one(db.from('profiles').update(toSnake({ name, color, tool })).eq('id', userId).select('id, name, color, tool').single()))
106
+ },
107
+ async createAgent ({ name, provider, type, description = '', publicKey = null, resumeHash = null, ownerUserId = null, orgId = null, invitedBy = null }) {
108
+ return rowFrom(await one(db.from('agents')
109
+ .insert({ name, provider, type, description, public_key: publicKey, resume_hash: resumeHash, owner_user_id: ownerUserId, org_id: orgId, invited_by: invitedBy })
110
+ .select(AGENT).single()))
111
+ },
112
+ async agentById (id) { return rowFrom(await one(db.from('agents').select(AGENT).eq('id', id).maybeSingle())) },
113
+ // AGENT never selects resume_hash, so the hash only ever goes in, never out.
114
+ async agentByResume (hash) { return rowFrom(await one(db.from('agents').select(AGENT).eq('resume_hash', hash).maybeSingle())) },
115
+ async agentByPublicKey (publicKey) {
116
+ if (!publicKey) return null
117
+ return rowFrom(await one(db.from('agents').select(AGENT).eq('public_key', publicKey).maybeSingle()))
118
+ },
119
+ async listPersonalAgents (userId) {
120
+ return (await one(db.from('agents').select(AGENT).eq('owner_user_id', userId).is('revoked_at', null).order('created_at'))).map(rowFrom)
121
+ },
122
+ // The org's own agents, live ones only, by name (for placement pickers).
123
+ async listOrgAgents (orgId) {
124
+ return (await one(db.from('agents').select(AGENT).eq('org_id', orgId).is('revoked_at', null).order('name'))).map(rowFrom)
125
+ },
126
+ async touchAgent (id) { await one(db.from('agents').update({ last_used_at: new Date().toISOString() }).eq('id', id)) },
127
+ // Revoking an agent kills every key it holds at once.
128
+ async revokeAgent (id) {
129
+ const at = new Date().toISOString()
130
+ const rows = await one(db.from('agents').update({ revoked_at: at }).eq('id', id).is('revoked_at', null).select('id'))
131
+ await one(db.from('agent_keys').update({ revoked_at: at }).eq('agent_id', id).is('revoked_at', null))
132
+ await one(db.from('agent_app_keys').update({ revoked_at: at }).eq('agent_id', id).is('revoked_at', null))
133
+ return rows.length > 0
134
+ },
135
+ // An agent coming back with a new invite: its new profile and resume key, and back
136
+ // from revoked. A publicKey left out keeps the one it has.
137
+ async rejoinAgent (id, { name, provider, type, description = '', publicKey = null, resumeHash }) {
138
+ const patch = { name, provider, type, description, resume_hash: resumeHash, revoked_at: null }
139
+ if (publicKey) patch.public_key = publicKey
140
+ return rowFrom(await one(db.from('agents').update(patch).eq('id', id).select(AGENT).single()))
141
+ },
142
+ // Only for undoing a half-finished join; cascades to its keys and membership.
143
+ async deleteAgent (id) { await one(db.from('agents').delete().eq('id', id)) },
144
+ // Agent invites: only the token's hash is stored. A deleted role clears
145
+ // role_id by itself (on delete set null (role_id)).
146
+ async createAgentInvite (i) {
147
+ return rowFrom(await one(db.from('agent_invites').insert(toSnake({ ...i, expiresAt: ts(i.expiresAt) })).select(AGENT_INVITE).single()))
148
+ },
149
+ async agentInviteByToken (h) { return rowFrom(await one(db.from('agent_invites').select(AGENT_INVITE).eq('token_hash', h).maybeSingle())) },
150
+ async agentInviteById (id) { return rowFrom(await one(db.from('agent_invites').select(AGENT_INVITE).eq('id', id).maybeSingle())) },
151
+ async listAgentInvites ({ ownerUserId, orgId }) {
152
+ const q = db.from('agent_invites').select(AGENT_INVITE)
153
+ return (await one((orgId ? q.eq('org_id', orgId) : q.eq('owner_user_id', ownerUserId)).order('created_at', { ascending: false }).limit(50))).map(rowFrom)
154
+ },
155
+ // Check-and-set: an invite is used once, and only while it's open.
156
+ async claimAgentInvite (id) {
157
+ const at = new Date().toISOString()
158
+ const rows = await one(db.from('agent_invites').update({ used_at: at }).eq('id', id).is('used_at', null).is('cancelled_at', null).gt('expires_at', at).select('id'))
159
+ return rows.length > 0
160
+ },
161
+ // Undoes a claim when making the agent failed, so the link can be tried again.
162
+ async releaseAgentInvite (id) { await one(db.from('agent_invites').update({ used_at: null, used_by_agent_id: null, rejoined: false }).eq('id', id)) },
163
+ async setInviteAgent (id, agentId, rejoined = false) { await one(db.from('agent_invites').update({ used_by_agent_id: agentId, rejoined }).eq('id', id)) },
164
+ // Check-and-set: only a waiting invite is cancelled.
165
+ async cancelAgentInvite (id) {
166
+ const rows = await one(db.from('agent_invites').update({ cancelled_at: new Date().toISOString() }).eq('id', id).is('used_at', null).is('cancelled_at', null).gt('expires_at', new Date().toISOString()).select('id'))
167
+ return rows.length > 0
168
+ },
169
+
170
+ // App keys (qk_): hashes only, and they don't run out.
171
+ async createAgentAppKey ({ agentId, name, keyHash }) {
172
+ return rowFrom(await one(db.from('agent_app_keys').insert({ agent_id: agentId, name, key_hash: keyHash }).select(AGENT_APP_KEY).single()))
173
+ },
174
+ async agentAppKeyByHash (h) { return rowFrom(await one(db.from('agent_app_keys').select(AGENT_APP_KEY).eq('key_hash', h).maybeSingle())) },
175
+ async listAgentAppKeys (agentId) {
176
+ return (await one(db.from('agent_app_keys').select(AGENT_APP_KEY).eq('agent_id', agentId).is('revoked_at', null).order('created_at'))).map(rowFrom)
177
+ },
178
+ async touchAgentAppKey (id) { await one(db.from('agent_app_keys').update({ last_used_at: new Date().toISOString() }).eq('id', id)) },
179
+ // Only a key of this agent, and only once.
180
+ async revokeAgentAppKey (agentId, id) {
181
+ const rows = await one(db.from('agent_app_keys').update({ revoked_at: new Date().toISOString() }).eq('id', id).eq('agent_id', agentId).is('revoked_at', null).select('id'))
182
+ return rows.length > 0
183
+ },
184
+
185
+ // Agent keys: hashes only.
186
+ async createAgentKeys (k) {
187
+ return rowFrom(await one(db.from('agent_keys')
188
+ .insert(toSnake({ ...k, accessExpiresAt: ts(k.accessExpiresAt), refreshExpiresAt: ts(k.refreshExpiresAt) }))
189
+ .select(AGENT_KEY).single()))
190
+ },
191
+ async agentKeyByAccess (h) { return rowFrom(await one(db.from('agent_keys').select(AGENT_KEY).eq('access_hash', h).maybeSingle())) },
192
+ async agentKeyByRefresh (h) { return rowFrom(await one(db.from('agent_keys').select(AGENT_KEY).eq('refresh_hash', h).maybeSingle())) },
193
+ async listAgentKeys (agentId) { return (await one(db.from('agent_keys').select(AGENT_KEY).eq('agent_id', agentId).order('created_at'))).map(rowFrom) },
194
+ // Check-and-set: a refresh key is spent once, and never after it was revoked.
195
+ async claimRefresh (id) {
196
+ const rows = await one(db.from('agent_keys').update({ refreshed_at: new Date().toISOString() }).eq('id', id).is('refreshed_at', null).is('revoked_at', null).select('id'))
197
+ return rows.length > 0
198
+ },
199
+ async revokeFamily (familyId) {
200
+ await one(db.from('agent_keys').update({ revoked_at: new Date().toISOString() }).eq('family_id', familyId).is('revoked_at', null))
201
+ },
202
+ // Undoes a claim when minting the new pair failed, so the same refresh key can retry.
203
+ async releaseRefresh (id) {
204
+ await one(db.from('agent_keys').update({ refreshed_at: null }).eq('id', id).is('revoked_at', null))
205
+ },
206
+ // Deleting the auth user cascades through profiles, devices, links and agents. Session
207
+ // activity is keyed by 'person:<id>' and 'agent:<id>', not foreign keys, so it goes first.
208
+ async deleteUser (userId) {
209
+ const agents = await one(db.from('agents').select('id').eq('owner_user_id', userId))
210
+ const accounts = [`person:${userId}`, ...agents.map((a) => `agent:${a.id}`)]
211
+ await one(db.rpc('delete_account_access', { p_accounts: accounts }))
212
+ await one(db.rpc('delete_account_activity', { p_accounts: accounts }))
213
+ const { error } = await db.auth.admin.deleteUser(userId)
214
+ if (error) throw error
215
+ },
216
+
217
+ // The GitHub account a person connected (routes/github.js): { githubId, login, linkedAt } or null.
218
+ async githubLink (userId) { return rowFrom(await one(db.from('github_links').select('github_id, login, linked_at').eq('user_id', userId).maybeSingle())) },
219
+ async setGithubLink (userId, { githubId, login }) {
220
+ await one(db.from('github_links').upsert({ user_id: userId, github_id: githubId, login, linked_at: new Date().toISOString() }, { onConflict: 'user_id' }))
221
+ },
222
+ async deleteGithubLink (userId) { await one(db.from('github_links').delete().eq('user_id', userId)) },
223
+ // GitHub connected for a scope (github_connections), newest first.
224
+ async githubConnections ({ scope, scopeId }) {
225
+ const rows = await one(db.from('github_connections').select('scope, scope_id, connected_by, installation_id, created_at').eq('scope', scope).eq('scope_id', scopeId).order('created_at', { ascending: false }))
226
+ return rows.map((r) => ({ scope: r.scope, scopeId: r.scope_id, connectedBy: r.connected_by, installationId: r.installation_id == null ? null : Number(r.installation_id), createdAt: Date.parse(r.created_at) }))
227
+ },
228
+ async addGithubConnection ({ scope, scopeId, connectedBy, installationId = null }) {
229
+ await one(db.from('github_connections').upsert({ scope, scope_id: scopeId, connected_by: connectedBy, installation_id: installationId, created_at: new Date().toISOString() }, { onConflict: 'scope,scope_id,connected_by' }))
230
+ },
231
+ async removeGithubConnection ({ scope, scopeId, connectedBy }) { await one(db.from('github_connections').delete().eq('scope', scope).eq('scope_id', scopeId).eq('connected_by', connectedBy)) },
232
+ async workspaceGithub (workspaceId) {
233
+ const r = await one(db.from('workspace_github').select('access, repos, agent_commits').eq('workspace_id', workspaceId).maybeSingle())
234
+ return r ? { access: r.access, repos: r.repos || [], agentCommits: r.agent_commits || '' } : { access: 'open', repos: [], agentCommits: '' }
235
+ },
236
+ // Only what is given changes: { access, repos } and/or { agentCommits } ('' for each session to decide).
237
+ async setWorkspaceGithub (workspaceId, { access, repos, agentCommits }) {
238
+ const row = { workspace_id: workspaceId, updated_at: new Date().toISOString() }
239
+ if (access !== undefined) { row.access = access; row.repos = repos || [] }
240
+ if (agentCommits !== undefined) row.agent_commits = agentCommits || null
241
+ await one(db.from('workspace_github').upsert(row, { onConflict: 'workspace_id' }))
242
+ },
243
+ async teamGithub (teamId) {
244
+ const r = await one(db.from('team_github').select('agent_commits').eq('team_id', teamId).maybeSingle())
245
+ return { agentCommits: (r && r.agent_commits) || '' }
246
+ },
247
+ async setTeamGithub (teamId, { agentCommits }) {
248
+ await one(db.from('team_github').upsert({ team_id: teamId, agent_commits: agentCommits || null, updated_at: new Date().toISOString() }, { onConflict: 'team_id' }))
249
+ },
250
+ // The rules of the teams `memberId` is on: [{ teamId, name, agentCommits }], only those that set one.
251
+ async teamGitRules (memberId) {
252
+ const rows = await one(db.from('team_members').select('team_id, teams (name, team_github (agent_commits))').eq('member_id', memberId))
253
+ return rows.map((r) => {
254
+ const g = r.teams && r.teams.team_github
255
+ const mode = Array.isArray(g) ? (g[0] && g[0].agent_commits) : (g && g.agent_commits)
256
+ return mode ? { teamId: r.team_id, name: (r.teams && r.teams.name) || '', agentCommits: mode } : null
257
+ }).filter(Boolean)
258
+ },
259
+ async orgGithub (orgId) {
260
+ const r = await one(db.from('org_github').select('require_github, agent_commits').eq('org_id', orgId).maybeSingle())
261
+ return { requireGithub: !!(r && r.require_github), agentCommits: (r && r.agent_commits) || '' }
262
+ },
263
+ async setOrgGithub (orgId, { requireGithub, agentCommits }) {
264
+ const row = { org_id: orgId, updated_at: new Date().toISOString() }
265
+ if (requireGithub !== undefined) row.require_github = !!requireGithub
266
+ if (agentCommits !== undefined) row.agent_commits = agentCommits || null
267
+ await one(db.from('org_github').upsert(row, { onConflict: 'org_id' }))
268
+ },
269
+ // The API's own settings (app_settings): a value it keeps across starts, such as the webhook secret it set on GitHub.
270
+ // One live session per repository and branch (git_sync_leases, through its locking functions).
271
+ async takeLease ({ repo, branch, room, holder = '', ttlMs }) { return await one(db.rpc('take_git_sync_lease', { p_repo: repo, p_branch: branch, p_room: room, p_holder: holder, p_ttl_ms: ttlMs })) },
272
+ async heartbeatLease ({ repo, branch, room, generation, ttlMs }) { return await one(db.rpc('heartbeat_git_sync_lease', { p_repo: repo, p_branch: branch, p_room: room, p_generation: generation, p_ttl_ms: ttlMs })) },
273
+ async releaseLease ({ repo, branch, room, generation }) { return await one(db.rpc('release_git_sync_lease', { p_repo: repo, p_branch: branch, p_room: room, p_generation: generation })) },
274
+ async getSetting (key) { const r = await one(db.from('app_settings').select('value').eq('key', key).maybeSingle()); return r ? r.value : null },
275
+ async setSetting (key, value) { await one(db.from('app_settings').upsert({ key, value: String(value), updated_at: new Date().toISOString() }, { onConflict: 'key' })) },
276
+ async setSettingIfAbsent (key, value) {
277
+ await one(db.from('app_settings').upsert({ key, value: String(value), updated_at: new Date().toISOString() }, { onConflict: 'key', ignoreDuplicates: true }))
278
+ const r = await one(db.from('app_settings').select('value').eq('key', key).maybeSingle())
279
+ return r ? r.value : String(value)
280
+ },
281
+
282
+ // Relay machines and where each room lives (20261017000000_relay_cluster.sql; src/api/relay-cluster.js).
283
+ async relayMachine (id) { return rowFrom(await one(db.from('relay_machines').select(RELAY_MACHINE).eq('id', id).maybeSingle())) },
284
+ async relayMachines () { return (await one(db.from('relay_machines').select(RELAY_MACHINE).order('created_at').order('id'))).map(rowFrom) },
285
+ async putRelayMachine ({ id, helloAt, reportedAt, ...fields }) {
286
+ const row = toSnake({ id, ...fields, hello_at: ts(helloAt), reported_at: ts(reportedAt) })
287
+ return rowFrom(await one(db.from('relay_machines').upsert(row, { onConflict: 'id' }).select(RELAY_MACHINE).single()))
288
+ },
289
+ async relayRoom (room) { return rowFrom(await one(db.from('relay_rooms').select(RELAY_ROOM).eq('room', room).maybeSingle())) },
290
+ async relayRoomsOn (machineId) { return (await pages(() => db.from('relay_rooms').select(RELAY_ROOM).eq('machine_id', machineId).order('room'))).map(rowFrom) },
291
+ async relayRoomCounts () {
292
+ const out = {}
293
+ for (const r of await one(db.rpc('relay_room_counts'))) out[r.machine_id] = Number(r.rooms)
294
+ return out
295
+ },
296
+ async placeRelayRoom (room, machineId) { return rowFrom((await one(db.rpc('place_relay_room', { p_room: room, p_machine: machineId })))[0]) },
297
+ async setRelayRoom (room, { reportedAt, ...patch }) {
298
+ return rowFrom(await one(db.from('relay_rooms').update(toSnake({ ...patch, reported_at: ts(reportedAt) })).eq('room', room).select(RELAY_ROOM).maybeSingle()))
299
+ },
300
+ async forgetRelayRoom (room, machineId) {
301
+ let q = db.from('relay_rooms').delete().eq('room', room)
302
+ if (machineId) q = q.eq('machine_id', machineId)
303
+ await one(q)
304
+ },
305
+ async takeRelayJob (key, { detail = {}, staleMs, retryMs }) { return !!(await one(db.rpc('take_relay_job', { p_key: key, p_detail: detail, p_stale_ms: staleMs, p_retry_ms: retryMs }))) },
306
+ async finishRelayJob (key, { state, detail }) { await one(db.from('relay_jobs').update(toSnake({ state, detail, finished_at: new Date().toISOString() })).eq('key', key)) },
307
+ async relayJob (key) { return rowFrom(await one(db.from('relay_jobs').select(RELAY_JOB).eq('key', key).maybeSingle())) },
308
+ // Hosted agents across relay machines.
309
+ async hostedRoomsOf (account) { return (await one(db.from('relay_hosted_rooms').select('room').eq('account', account).order('added_at').order('room'))).map((r) => r.room) },
310
+ async addHostedRoom (account, room) { await one(db.from('relay_hosted_rooms').upsert({ account, room }, { onConflict: 'account,room', ignoreDuplicates: true })) },
311
+ async removeHostedRoom (account, room) { await one(db.from('relay_hosted_rooms').delete().eq('account', account).eq('room', room)) },
312
+ async hostedWebhookOf (account) { const r = await one(db.from('relay_hosted_webhooks').select('webhook').eq('account', account).maybeSingle()); return r ? r.webhook : null },
313
+ async putHostedWebhook (account, webhook) {
314
+ if (!webhook) return void await one(db.from('relay_hosted_webhooks').delete().eq('account', account))
315
+ await one(db.from('relay_hosted_webhooks').upsert({ account, webhook, updated_at: new Date().toISOString() }, { onConflict: 'account' }))
316
+ },
317
+ async roomHasAccount (room, account) {
318
+ const s = await one(db.from('relay_sessions').select('owner_account').eq('room', room).maybeSingle())
319
+ if (s && s.owner_account === account) return true
320
+ const v = await one(db.from('session_visits').select('id').eq('room', room).eq('account', account).limit(1))
321
+ return v.length > 0
322
+ },
323
+ async spendResumeSignature (hash, windowMs) {
324
+ const cutoff = new Date(Date.now() - windowMs).toISOString()
325
+ await one(db.from('seen_resume_signatures').delete().lt('seen_at', cutoff))
326
+ const { error } = await db.from('seen_resume_signatures').insert({ signature_hash: hash })
327
+ if (!error) return true
328
+ if (error.code === '23505') return false
329
+ throw error
330
+ },
331
+
332
+ // The address a person signs in with, and whether they've confirmed it.
333
+ async userEmail (userId) {
334
+ const { data, error } = await db.auth.admin.getUserById(userId)
335
+ if (error) { if (error.status === 404) return null; throw error }
336
+ const u = data?.user
337
+ return u ? { email: u.email || '', confirmed: !!u.email_confirmed_at } : null
338
+ },
339
+
340
+ // Session activity (see 20261002000000_session_activity.sql). Event times are epoch ms.
341
+ async ingestPresence (events, receivedAt) {
342
+ return await one(db.rpc('ingest_presence', { p_events: events, p_received_at: ts(receivedAt) }))
343
+ },
344
+ async accountSessions (account, { since, limit }) {
345
+ return (await pages(() => db.rpc('account_sessions', { p_account: account, p_since: ts(since), p_limit: limit }))).map(rowFrom)
346
+ },
347
+ async actionsInRoom (room, { from, to, limit }) {
348
+ // `at` isn't a `*At` column, so it's turned into epoch ms here.
349
+ return (await pages(() => db.rpc('actions_in_room', { p_room: room, p_from: ts(from), p_to: ts(to), p_limit: limit }))).map((r) => ({ ...rowFrom(r), at: ms(r.at) }))
350
+ },
351
+ async sessionByRoom (room) { return rowFrom(await one(db.from('relay_sessions').select(RELAY_SESSION).eq('room', room).maybeSingle())) },
352
+ async visitsInRooms (rooms) {
353
+ if (!rooms.length) return []
354
+ return (await pages(() => db.rpc('visits_in_rooms', { p_rooms: rooms }))).map(rowFrom)
355
+ },
356
+ async renameSession (room, name, at) {
357
+ return rowFrom(await one(db.from('relay_sessions').update({ name, renamed_at: ts(at) }).eq('room', room).select(RELAY_SESSION).maybeSingle()))
358
+ },
359
+
360
+ // Workspaces (see 20261004000000_workspaces.sql).
361
+ async createWorkspace ({ ownerUserId = null, orgId = null, name, description = '', color = '', createdBy }) {
362
+ return rowFrom(await one(db.from('workspaces').insert({ owner_user_id: ownerUserId, org_id: orgId, name, description, color, created_by: createdBy }).select(WORKSPACE).single()))
363
+ },
364
+ async workspaceById (id) { return rowFrom(await one(db.from('workspaces').select(WORKSPACE).eq('id', id).maybeSingle())) },
365
+ async listWorkspacesOwnedBy (userId) { return (await one(db.from('workspaces').select(WORKSPACE).eq('owner_user_id', userId).order('name'))).map(rowFrom) },
366
+ async listWorkspacesOfOrg (orgId) { return (await one(db.from('workspaces').select(WORKSPACE).eq('org_id', orgId).order('name'))).map(rowFrom) },
367
+ async listWorkspacesForMember (account) {
368
+ const rows = await one(db.from('workspace_members').select(`access, workspaces (${WORKSPACE})`).eq('account', account))
369
+ return rows.filter((r) => r.workspaces).map((r) => ({ ...rowFrom(r.workspaces), memberAccess: r.access })).sort((a, b) => a.name.localeCompare(b.name))
370
+ },
371
+ async updateWorkspace (id, { name, description, color, archivedAt }) {
372
+ return rowFrom(await one(db.from('workspaces').update(toSnake({ name, description, color, archivedAt: ts(archivedAt) })).eq('id', id).select(WORKSPACE).maybeSingle()))
373
+ },
374
+ async deleteWorkspace (id) { await one(db.rpc('delete_workspace', { p_id: id })) },
375
+ async workspaceMember (workspaceId, account) { return rowFrom(await one(db.from('workspace_members').select(WORKSPACE_MEMBER).eq('workspace_id', workspaceId).eq('account', account).maybeSingle())) },
376
+ async listWorkspaceMembers (workspaceId) { return (await one(db.from('workspace_members').select(WORKSPACE_MEMBER).eq('workspace_id', workspaceId).order('added_at'))).map(rowFrom) },
377
+ // Keep added_at on a change of access: the upsert only touches access and added_by.
378
+ // sessions is included only when given, so an upsert that omits it never resets an
379
+ // existing member's sessions back to the column default.
380
+ async putWorkspaceMember ({ workspaceId, account, access, addedBy, sessions }) {
381
+ const payload = { workspace_id: workspaceId, account, access, added_by: addedBy }
382
+ if (sessions !== undefined) payload.sessions = sessions
383
+ return rowFrom(await one(db.from('workspace_members').upsert(payload, { onConflict: 'workspace_id,account' }).select(WORKSPACE_MEMBER).single()))
384
+ },
385
+ async removeWorkspaceMember (workspaceId, account) {
386
+ const rows = await one(db.from('workspace_members').delete().eq('workspace_id', workspaceId).eq('account', account).select('account'))
387
+ return rows.length > 0
388
+ },
389
+ // Workspace invites (20261009000000_invite_inbox.sql): one open per address or account
390
+ // (a unique index; a second is a 23505), that key's expired ones deleted first.
391
+ async createWorkspaceInvite ({ workspaceId, email = null, account = null, accountName = '', access, invitedBy, expiresAt, at = Date.now() }) {
392
+ email = email && email.toLowerCase()
393
+ await one(db.from('workspace_invites').delete().eq('workspace_id', workspaceId).eq(email ? 'email' : 'account', email || account)
394
+ .is('accepted_at', null).is('declined_at', null).is('cancelled_at', null).lte('expires_at', ts(at)))
395
+ return rowFrom(await one(db.from('workspace_invites')
396
+ .insert({ workspace_id: workspaceId, email, account, account_name: accountName, access, invited_by: invitedBy, expires_at: ts(expiresAt) })
397
+ .select(WORKSPACE_INVITE).single()))
398
+ },
399
+ async listWorkspaceInvites (workspaceId) {
400
+ return (await one(db.from('workspace_invites').select(WORKSPACE_INVITE).eq('workspace_id', workspaceId).order('created_at', { ascending: false }).limit(50))).map(rowFrom)
401
+ },
402
+ async workspaceInviteById (id) { return rowFrom(await one(db.from('workspace_invites').select(WORKSPACE_INVITE).eq('id', id).maybeSingle())) },
403
+ async openWorkspaceInvite (workspaceId, { email = null, account = null }, at) {
404
+ const rows = await one(db.from('workspace_invites').select(WORKSPACE_INVITE).eq('workspace_id', workspaceId).eq(email ? 'email' : 'account', email || account)
405
+ .is('accepted_at', null).is('declined_at', null).is('cancelled_at', null).gt('expires_at', ts(at)).limit(1))
406
+ return rowFrom(rows[0] || null)
407
+ },
408
+ async workspaceInvitesFor ({ account = null, email = null }, at) {
409
+ const who = orFor({ account, email })
410
+ if (!who) return []
411
+ return (await one(db.from('workspace_invites').select(WORKSPACE_INVITE).or(who)
412
+ .is('accepted_at', null).is('declined_at', null).is('cancelled_at', null).gt('expires_at', ts(at)).order('created_at', { ascending: false }).limit(50))).map(rowFrom)
413
+ },
414
+ // Check-and-set: only a waiting invite is answered. `how` is accepted, declined or cancelled.
415
+ async answerWorkspaceInvite (id, how, by = null) {
416
+ if (!['accepted', 'declined', 'cancelled'].includes(how)) throw new Error('accepted, declined or cancelled')
417
+ const patch = { [`${how}_at`]: new Date().toISOString(), ...(how === 'accepted' ? { accepted_by: by } : {}) }
418
+ const rows = await one(db.from('workspace_invites').update(patch).eq('id', id).is('accepted_at', null).is('declined_at', null).is('cancelled_at', null).select('id'))
419
+ return rows.length > 0
420
+ },
421
+ async setSessionWorkspace (room, workspaceId, { linkedBy = null, at }) {
422
+ return rowFrom(await one(db.rpc('set_session_workspace', { p_room: room, p_workspace: workspaceId || null, p_linked_by: linkedBy || null, p_at: ts(at) })))
423
+ },
424
+ async listWorkspaceSessions (workspaceId) { return (await one(db.from('relay_sessions').select(RELAY_SESSION).eq('workspace_id', workspaceId).order('last_active_at', { ascending: false }))).map(rowFrom) },
425
+
426
+ // Workspace agents (see 20261007000001_workspace_agents.sql). Where an agent works,
427
+ // a workspace's say over one that reaches it, per-session keep-outs, and its webhook.
428
+ async agentPlacement (agentId) { return rowFrom(await one(db.from('agent_placements').select(AGENT_PLACEMENT).eq('agent_id', agentId).maybeSingle())) },
429
+ async putAgentPlacement ({ agentId, reach = 'manual', workspaceIds = [], sessions = 'invited', access = 'edit', scopes = [], updatedBy }) {
430
+ return rowFrom(await one(db.from('agent_placements')
431
+ .upsert({ agent_id: agentId, reach, workspace_ids: workspaceIds, sessions, access, scopes, updated_by: updatedBy, updated_at: new Date().toISOString() }, { onConflict: 'agent_id' })
432
+ .select(AGENT_PLACEMENT).single()))
433
+ },
434
+ async listAgentPlacements (agentIds) {
435
+ if (!agentIds.length) return []
436
+ return (await one(db.from('agent_placements').select(AGENT_PLACEMENT).in('agent_id', agentIds))).map(rowFrom)
437
+ },
438
+ async workspaceAgentOverride (workspaceId, agentId) {
439
+ return rowFrom(await one(db.from('workspace_agent_overrides').select(WORKSPACE_AGENT_OVERRIDE).eq('workspace_id', workspaceId).eq('agent_id', agentId).maybeSingle()))
440
+ },
441
+ async putWorkspaceAgentOverride ({ workspaceId, agentId, sessions = null, excluded = false }) {
442
+ return rowFrom(await one(db.from('workspace_agent_overrides')
443
+ .upsert({ workspace_id: workspaceId, agent_id: agentId, sessions, excluded }, { onConflict: 'workspace_id,agent_id' })
444
+ .select(WORKSPACE_AGENT_OVERRIDE).single()))
445
+ },
446
+ async deleteWorkspaceAgentOverride (workspaceId, agentId) {
447
+ return (await one(db.from('workspace_agent_overrides').delete().eq('workspace_id', workspaceId).eq('agent_id', agentId).select('agent_id'))).length > 0
448
+ },
449
+ async listWorkspaceAgentOverrides (workspaceId) {
450
+ return (await one(db.from('workspace_agent_overrides').select(WORKSPACE_AGENT_OVERRIDE).eq('workspace_id', workspaceId))).map(rowFrom)
451
+ },
452
+ async addSessionAgentExclusion ({ room, agentId, excludedBy }) {
453
+ return rowFrom(await one(db.from('session_agent_exclusions')
454
+ .upsert({ room, agent_id: agentId, excluded_by: excludedBy }, { onConflict: 'room,agent_id' })
455
+ .select(SESSION_AGENT_EXCLUSION).single()))
456
+ },
457
+ async removeSessionAgentExclusion (room, agentId) {
458
+ return (await one(db.from('session_agent_exclusions').delete().eq('room', room).eq('agent_id', agentId).select('agent_id'))).length > 0
459
+ },
460
+ async sessionAgentExcluded (room, agentId) {
461
+ return !!(await one(db.from('session_agent_exclusions').select('agent_id').eq('room', room).eq('agent_id', agentId).maybeSingle()))
462
+ },
463
+ async listSessionAgentExclusions (room) {
464
+ return (await one(db.from('session_agent_exclusions').select(SESSION_AGENT_EXCLUSION).eq('room', room))).map(rowFrom)
465
+ },
466
+ async agentWebhook (agentId) { return rowFrom(await one(db.from('agent_webhooks').select(AGENT_WEBHOOK).eq('agent_id', agentId).maybeSingle())) },
467
+ async putAgentWebhook ({ agentId, url, secret }) {
468
+ return rowFrom(await one(db.from('agent_webhooks')
469
+ .upsert({ agent_id: agentId, url, secret, updated_at: new Date().toISOString() }, { onConflict: 'agent_id' })
470
+ .select(AGENT_WEBHOOK).single()))
471
+ },
472
+ async deleteAgentWebhook (agentId) {
473
+ return (await one(db.from('agent_webhooks').delete().eq('agent_id', agentId).select('agent_id'))).length > 0
474
+ },
475
+
476
+ // A person's cloud AI machine (see 20261017000000_ai_vms.sql): one per person.
477
+ async createAiVm ({ userId, tool, model = '', prompt = '', cap = 0, region = '' }) {
478
+ return rowFrom(await one(db.from('ai_vms').insert({ user_id: userId, tool, model, prompt, cap, region }).select(AI_VM).single()))
479
+ },
480
+ async aiVmOf (userId) { return rowFrom(await one(db.from('ai_vms').select(AI_VM).eq('user_id', userId).maybeSingle())) },
481
+ async aiVmByAgent (agentId) { return agentId ? rowFrom(await one(db.from('ai_vms').select(AI_VM).eq('agent_id', agentId).maybeSingle())) : null },
482
+ async updateAiVm (userId, patch) {
483
+ const set = toSnake({ ...patch, lastReportAt: ts(patch.lastReportAt) })
484
+ return rowFrom(await one(db.from('ai_vms').update({ ...set, updated_at: new Date().toISOString() }).eq('user_id', userId).select(AI_VM).maybeSingle()))
485
+ },
486
+ async deleteAiVm (userId) {
487
+ return (await one(db.from('ai_vms').delete().eq('user_id', userId).select('user_id'))).length > 0
488
+ },
489
+
490
+ // Workspace files (see 20261005000000_workspace_files.sql).
491
+ async createWorkspaceFile ({ workspaceId, path, kind, size = 0, mime = '', sha256 = '', objectKey = '', note = '', uploadedBy }) {
492
+ return rowFrom(await one(db.from('workspace_files').insert({ workspace_id: workspaceId, path, kind, size, mime, sha256, object_key: objectKey, note, uploaded_by: uploadedBy }).select(WORKSPACE_FILE).single()))
493
+ },
494
+ async workspaceFileById (id) { return rowFrom(await one(db.from('workspace_files').select(WORKSPACE_FILE).eq('id', id).maybeSingle())) },
495
+ async workspaceFileByPath (workspaceId, path) { return rowFrom(await one(db.from('workspace_files').select(WORKSPACE_FILE).eq('workspace_id', workspaceId).eq('path', path).is('deleted_at', null).maybeSingle())) },
496
+ async listWorkspaceFiles (workspaceId, { includeDeleted = false } = {}) {
497
+ const rows = await pages(() => { const q = db.from('workspace_files').select(WORKSPACE_FILE).eq('workspace_id', workspaceId).order('kind', { ascending: false }).order('path'); return includeDeleted ? q : q.is('deleted_at', null) })
498
+ return rows.map(rowFrom)
499
+ },
500
+ async newWorkspaceFileVersion (id, { size, mime, sha256, objectKey, note = '', uploadedBy, at, keep = 10 }) {
501
+ const r = await one(db.rpc('new_workspace_file_version', { p_id: id, p_size: size, p_mime: mime, p_sha256: sha256, p_object_key: objectKey, p_note: note, p_uploaded_by: uploadedBy, p_at: ts(at), p_keep: keep }))
502
+ return r ? { file: rowFrom(r.file), droppedKeys: r.dropped_keys || [] } : null
503
+ },
504
+ async listWorkspaceFileVersions (id) { return (await one(db.from('workspace_file_versions').select(WORKSPACE_FILE_VERSION).eq('file_id', id).order('version', { ascending: false }))).map(rowFrom) },
505
+ async confirmWorkspaceFile (id, { size, at }) { return rowFrom(await one(db.from('workspace_files').update({ size, confirmed_at: ts(at) }).eq('id', id).select(WORKSPACE_FILE).maybeSingle())) },
506
+ async updateWorkspaceFile (id, { path, note }) { return rowFrom(await one(db.from('workspace_files').update(toSnake({ path, note })).eq('id', id).select(WORKSPACE_FILE).maybeSingle())) },
507
+ async renameWorkspaceFolder (workspaceId, from, to, at) { return await one(db.rpc('rename_workspace_folder', { p_workspace: workspaceId, p_from: from, p_to: to })) },
508
+ async deleteWorkspaceFile (id, at) { return rowFrom(await one(db.rpc('delete_workspace_file', { p_id: id, p_at: ts(at) }))) },
509
+ async unconfirmedWorkspaceFiles (before) { return (await one(db.from('workspace_files').select(WORKSPACE_FILE).eq('kind', 'file').is('confirmed_at', null).lt('uploaded_at', ts(before)))).map(rowFrom) },
510
+ async removeWorkspaceFile (id) { await one(db.from('workspace_files').delete().eq('id', id)) },
511
+ async sweepDeletedWorkspaceFiles (before) { return (await one(db.rpc('sweep_deleted_workspace_files', { p_before: ts(before) }))) || [] },
512
+ async workspaceUsage (workspaceId) { const [r] = await one(db.rpc('workspace_usage', { p_workspace: workspaceId })); return { usedBytes: Number(r?.used_bytes || 0), fileCount: Number(r?.file_count || 0) } },
513
+ async setWorkspaceUsage (workspaceId, { usedBytes, fileCount }) { await one(db.from('workspaces').update({ used_bytes: usedBytes, file_count: fileCount }).eq('id', workspaceId)) },
514
+ async setWorkspaceFileObjectKey (id, objectKey) { return rowFrom(await one(db.from('workspace_files').update({ object_key: objectKey }).eq('id', id).select(WORKSPACE_FILE).maybeSingle())) },
515
+ async revertWorkspaceFileVersion (id) { return rowFrom(await one(db.rpc('revert_workspace_file_version', { p_id: id }))) },
516
+
517
+ async pruneActivity ({ before, seenBefore }) {
518
+ await one(db.rpc('prune_activity', { p_before: ts(before), p_seen_before: ts(seenBefore) }))
519
+ },
520
+
521
+ // Access types, grants and session invites (see 20261002010000_access_types_and_invites.sql).
522
+ async listAccessTypes (ownerAccount) {
523
+ return (await one(db.from('access_types').select(ACCESS_TYPE).eq('owner_account', ownerAccount).order('created_at'))).map(rowFrom)
524
+ },
525
+ async accessTypeById (id) { return rowFrom(await one(db.from('access_types').select(ACCESS_TYPE).eq('id', id).maybeSingle())) },
526
+ async createAccessType ({ ownerAccount, name, files, folders = [], talk = true }) {
527
+ return rowFrom(await one(db.from('access_types').insert({ owner_account: ownerAccount, name, files, folders, talk }).select(ACCESS_TYPE).single()))
528
+ },
529
+ async updateAccessType (id, { name, files, folders, talk }) {
530
+ return rowFrom(await one(db.from('access_types').update(toSnake({ name, files, folders, talk, updatedAt: new Date().toISOString() })).eq('id', id).select(ACCESS_TYPE).maybeSingle()))
531
+ },
532
+ // Its grants and invites fall back to View only, in the same transaction.
533
+ async deleteAccessType (id, ownerAccount) {
534
+ return await one(db.rpc('delete_access_type', { p_id: id, p_owner: ownerAccount }))
535
+ },
536
+ async grantFor (room, account) { return rowFrom(await one(db.from('session_grants').select(GRANT).eq('room', room).eq('account', account).maybeSingle())) },
537
+ async listGrants (room) { return (await one(db.from('session_grants').select(GRANT).eq('room', room).order('created_at'))).map(rowFrom) },
538
+ async putGrant ({ room, account, typeId, tighten = {}, grantedBy }) {
539
+ return rowFrom(await one(db.from('session_grants')
540
+ .upsert({ room, account, type_id: typeId, tighten, granted_by: grantedBy, updated_at: new Date().toISOString() }, { onConflict: 'room,account' })
541
+ .select(GRANT).single()))
542
+ },
543
+ async deleteGrant (room, account) {
544
+ return (await one(db.from('session_grants').delete().eq('room', room).eq('account', account).select('account'))).length > 0
545
+ },
546
+ // One open invite per address or account (a unique index; a second one is a 23505). That
547
+ // key's expired, unused invites go first, so they never block a new one.
548
+ async createSessionInvite ({ room, email = null, account = null, accountName = '', typeId, invitedBy, expiresAt, link = null, at = Date.now() }) {
549
+ email = email && email.toLowerCase()
550
+ await one(db.from('session_invites').delete().eq('room', room).eq(email ? 'email' : 'account', email || account)
551
+ .is('used_at', null).is('cancelled_at', null).lte('expires_at', ts(at)))
552
+ return rowFrom(await one(db.from('session_invites')
553
+ .insert({ room, email: email && email.toLowerCase(), account, account_name: accountName, type_id: typeId, invited_by: invitedBy, expires_at: ts(expiresAt), link })
554
+ .select(SESSION_INVITE).single()))
555
+ },
556
+ async listSessionInvites (room) {
557
+ return (await one(db.from('session_invites').select(SESSION_INVITE).eq('room', room).order('created_at', { ascending: false }).limit(50))).map(rowFrom)
558
+ },
559
+ async sessionInviteById (room, id) { return rowFrom(await one(db.from('session_invites').select(SESSION_INVITE).eq('room', room).eq('id', id).maybeSingle())) },
560
+ // The open session invites sent to an account or a (confirmed) address, with their links.
561
+ async sessionInvitesFor ({ account = null, email = null }, at) {
562
+ const who = orFor({ account, email })
563
+ if (!who) return []
564
+ const rows = await one(db.from('session_invites').select(`${SESSION_INVITE}, link, relay_sessions (name)`).or(who)
565
+ .is('used_at', null).is('cancelled_at', null).gt('expires_at', ts(at)).order('created_at', { ascending: false }).limit(50))
566
+ return rows.map(({ relay_sessions: s, ...r }) => ({ ...rowFrom(r), sessionName: s?.name || '' }))
567
+ },
568
+ // A session invite, only when it was sent to this account or address.
569
+ async sessionInviteForMe (id, { account = null, email = null }) {
570
+ const i = rowFrom(await one(db.from('session_invites').select(SESSION_INVITE).eq('id', id).maybeSingle()))
571
+ return i && ((account && i.account === account) || (email && i.email === email)) ? i : null
572
+ },
573
+ // The open (unused, not cancelled, not expired) invite for an address or account, but `exceptId`.
574
+ async openSessionInvite (room, { email = null, account = null }, at, exceptId = null) {
575
+ let q = db.from('session_invites').select(SESSION_INVITE).eq('room', room).eq(email ? 'email' : 'account', email || account)
576
+ .is('used_at', null).is('cancelled_at', null).gt('expires_at', ts(at))
577
+ if (exceptId) q = q.neq('id', exceptId)
578
+ return rowFrom((await one(q.limit(1)))[0] || null)
579
+ },
580
+ // Check-and-set: only a waiting invite is cancelled.
581
+ async cancelSessionInvite (id) {
582
+ const rows = await one(db.from('session_invites').update({ cancelled_at: new Date().toISOString() }).eq('id', id).is('used_at', null).is('cancelled_at', null).select('id'))
583
+ return rows.length > 0
584
+ },
585
+ // The grant goes unless an open invite for that address or account still needs it (one statement).
586
+ async deleteUnusedGrant (room, account, at) {
587
+ return await one(db.rpc('delete_unused_grant', { p_room: room, p_account: account, p_now: ts(at) }))
588
+ },
589
+ async claimEmailInvites (room, email, account) {
590
+ return await one(db.rpc('claim_email_invites', { p_room: room, p_email: email, p_account: account, p_now: new Date().toISOString() }))
591
+ },
592
+ async useAccountInvites (room, account) {
593
+ const at = new Date().toISOString()
594
+ await one(db.from('session_invites').update({ used_at: at, used_by: account }).eq('room', room).eq('account', account).is('used_at', null).is('cancelled_at', null).gt('expires_at', at))
595
+ },
596
+
597
+ // Orgs. create_org makes the org, its three built-in roles and its owner in one
598
+ // transaction. first: true is for "a team" sign-ups, where two tabs (or a double
599
+ // click) can both see no org yet; create_org locks on the owner and hands back
600
+ // whichever org they end up in rather than making a second one.
601
+ async createOrg ({ name, slug, ownerId, grants, first = false }) {
602
+ return rowFrom(await one(db.rpc('create_org', {
603
+ p_name: name, p_slug: slug, p_owner: ownerId, p_owner_grants: grants.owner, p_admin_grants: grants.admin, p_member_grants: grants.member, p_first: first
604
+ })))
605
+ },
606
+ async orgBySlug (slug) { return rowFrom(await one(db.from('orgs').select(ORG).eq('slug', slug).maybeSingle())) },
607
+ async orgById (id) { return rowFrom(await one(db.from('orgs').select(ORG).eq('id', id).maybeSingle())) },
608
+ async orgsForUser (userId) {
609
+ const rows = await one(db.from('org_members').select(`role_id, orgs (${ORG})`).eq('user_id', userId))
610
+ return rows.map((r) => ({ ...rowFrom(r.orgs), roleId: r.role_id })).sort((a, b) => a.name.localeCompare(b.name))
611
+ },
612
+ async orgsByDomain (domain) { return (await one(db.from('orgs').select(ORG).eq('domain', domain).eq('domain_requests', true))).map(rowFrom) },
613
+ async updateOrg (id, { name, domain, domainRequests }) {
614
+ return rowFrom(await one(db.from('orgs').update(toSnake({ name, domain, domainRequests })).eq('id', id).select(ORG).single()))
615
+ },
616
+ // Cascades to roles, members, teams, invites and requests.
617
+ async deleteOrg (id) { await one(db.from('orgs').delete().eq('id', id)) },
618
+ async transferOrg (orgId, fromUserId, toUserId) { await one(db.rpc('transfer_org', { p_org: orgId, p_from: fromUserId, p_to: toUserId })) },
619
+
620
+ // Roles.
621
+ async listRoles (orgId) { return (await one(db.from('roles').select(ROLE).eq('org_id', orgId))).map(rowFrom) },
622
+ async roleById (orgId, id) { return rowFrom(await one(db.from('roles').select(ROLE).eq('org_id', orgId).eq('id', id).maybeSingle())) },
623
+ async createRole ({ orgId, name, grants }) {
624
+ return rowFrom(await one(db.from('roles').insert({ org_id: orgId, name, grants }).select(ROLE).single()))
625
+ },
626
+ async updateRole (id, { name, grants }) {
627
+ return rowFrom(await one(db.from('roles').update(toSnake({ name, grants })).eq('id', id).select(ROLE).single()))
628
+ },
629
+ // Closed invites (accepted, cancelled or expired) are cleared first, since
630
+ // org_invites.role_id is NO ACTION, not cascade; an open invite still makes
631
+ // the role delete fail with Postgres's 23503, which `one` rethrows as-is.
632
+ async deleteRole (id) {
633
+ await one(db.from('org_invites').delete().eq('role_id', id)
634
+ .or(`accepted_at.not.is.null,cancelled_at.not.is.null,expires_at.lte.${new Date().toISOString()}`))
635
+ await one(db.from('roles').delete().eq('id', id))
636
+ },
637
+ // In use: someone holds it, or an open (not accepted, not cancelled, not
638
+ // expired) invite would hand it out — the same definition deleteRole uses.
639
+ async roleInUse (id) {
640
+ if ((await one(db.from('org_members').select('id').eq('role_id', id).limit(1))).length) return true
641
+ return (await one(db.from('org_invites').select('id').eq('role_id', id)
642
+ .is('accepted_at', null).is('cancelled_at', null).gt('expires_at', new Date().toISOString()).limit(1))).length > 0
643
+ },
644
+
645
+ // Members.
646
+ memberOf,
647
+ async memberById (orgId, id) { return rowFrom(await one(db.from('org_members').select(MEMBER).eq('org_id', orgId).eq('id', id).maybeSingle())) },
648
+ // A member is a person (profiles) or an agent (agents); each row embeds whichever it is.
649
+ async listMembers (orgId) {
650
+ const rows = await one(db.from('org_members').select(`${MEMBER}, profiles (name), agents (name, provider, type, public_key)`).eq('org_id', orgId).order('joined_at'))
651
+ return rows.map(({ profiles, agents, ...r }) => ({ ...rowFrom(r), name: profiles?.name || agents?.name || '', provider: agents?.provider ?? null, type: agents?.type ?? null, publicKey: agents?.public_key ?? null }))
652
+ },
653
+ // Already a member: the upsert does nothing and we return the existing row.
654
+ async addMember ({ orgId, userId, roleId }) {
655
+ const row = await one(db.from('org_members')
656
+ .upsert({ org_id: orgId, user_id: userId, role_id: roleId }, { onConflict: 'org_id,user_id', ignoreDuplicates: true })
657
+ .select(MEMBER).maybeSingle())
658
+ return row ? rowFrom(row) : memberOf(orgId, userId)
659
+ },
660
+ async setMemberRole (id, roleId) { return rowFrom(await one(db.from('org_members').update({ role_id: roleId }).eq('id', id).select(MEMBER).single())) },
661
+ // Cascades to the member's team memberships.
662
+ async removeMember (id) { await one(db.from('org_members').delete().eq('id', id)) },
663
+ // The composite (agent_id, org_id) foreign key keeps other orgs' and personal agents out.
664
+ async addAgentMember ({ orgId, agentId, roleId = null }) {
665
+ return rowFrom(await one(db.from('org_members').insert({ org_id: orgId, agent_id: agentId, role_id: roleId }).select(MEMBER).single()))
666
+ },
667
+ async memberByAgent (orgId, agentId) { return rowFrom(await one(db.from('org_members').select(MEMBER).eq('org_id', orgId).eq('agent_id', agentId).maybeSingle())) },
668
+
669
+ // Teams.
670
+ async listTeams (orgId) { return (await one(db.from('teams').select(TEAM).eq('org_id', orgId).order('name'))).map(rowFrom) },
671
+ async teamById (orgId, id) { return rowFrom(await one(db.from('teams').select(TEAM).eq('org_id', orgId).eq('id', id).maybeSingle())) },
672
+ async createTeam ({ orgId, name }) { return rowFrom(await one(db.from('teams').insert({ org_id: orgId, name }).select(TEAM).single())) },
673
+ async renameTeam (id, name) { return rowFrom(await one(db.from('teams').update({ name }).eq('id', id).select(TEAM).single())) },
674
+ async deleteTeam (id) { await one(db.from('teams').delete().eq('id', id)) },
675
+ async listTeamMembers (teamId) {
676
+ const rows = await one(db.from('team_members').select(`${TEAM_MEMBER}, org_members (user_id, agent_id, profiles (name), agents (name))`).eq('team_id', teamId).order('added_at'))
677
+ return rows.map(({ org_members: m, ...r }) => ({ ...rowFrom(r), name: m?.profiles?.name || m?.agents?.name || '', kind: m?.agent_id ? 'agent' : 'person' }))
678
+ },
679
+ async teamsOfMember (memberId) {
680
+ return (await one(db.from('team_members').select('team_id, access, scopes').eq('member_id', memberId))).map((r) => ({ teamId: r.team_id, access: r.access, scopes: r.scopes || [] }))
681
+ },
682
+ // team_members.org_id is not null (composite FKs to teams(id, org_id) and
683
+ // org_members(id, org_id)), so look the team's org up first and stamp it
684
+ // on the upsert; a missing team fails the same way the real FK would.
685
+ async addTeamMember ({ teamId, memberId, access, scopes = [] }) {
686
+ const team = await one(db.from('teams').select('org_id').eq('id', teamId).maybeSingle())
687
+ if (!team) throw fkViolation('team')
688
+ return rowFrom(await one(db.from('team_members')
689
+ .upsert({ team_id: teamId, member_id: memberId, access, scopes, org_id: team.org_id }, { onConflict: 'team_id,member_id' })
690
+ .select(TEAM_MEMBER).single()))
691
+ },
692
+ // Folders only change when given, so an access-only change keeps them.
693
+ async setTeamAccess (teamId, memberId, access, scopes) {
694
+ return rowFrom(await one(db.from('team_members').update(scopes === undefined ? { access } : { access, scopes })
695
+ .eq('team_id', teamId).eq('member_id', memberId).select(TEAM_MEMBER).maybeSingle()))
696
+ },
697
+ async removeTeamMember (teamId, memberId) {
698
+ return (await one(db.from('team_members').delete().eq('team_id', teamId).eq('member_id', memberId).select('team_id'))).length > 0
699
+ },
700
+
701
+ // Invites: only the token's hash is stored. org_invites.email must be
702
+ // lowercase (a check constraint), matching the invite flow's
703
+ // case-insensitive match on the invitee's confirmed email.
704
+ async createInvite (i) {
705
+ return rowFrom(await one(db.from('org_invites').insert(toSnake({ ...i, email: i.email.toLowerCase(), expiresAt: ts(i.expiresAt) })).select(INVITE).single()))
706
+ },
707
+ async inviteByToken (h) { return rowFrom(await one(db.from('org_invites').select(INVITE).eq('token_hash', h).maybeSingle())) },
708
+ async inviteById (orgId, id) { return rowFrom(await one(db.from('org_invites').select(INVITE).eq('org_id', orgId).eq('id', id).maybeSingle())) },
709
+ async listInvites (orgId) {
710
+ return (await one(db.from('org_invites').select(INVITE).eq('org_id', orgId).is('accepted_at', null).is('cancelled_at', null).order('created_at'))).map(rowFrom)
711
+ },
712
+ async updateInvite (id, patch) {
713
+ return rowFrom(await one(db.from('org_invites').update(toSnake({ ...patch, expiresAt: ts(patch.expiresAt), cancelledAt: ts(patch.cancelledAt) })).eq('id', id).select(INVITE).single()))
714
+ },
715
+ // Check-and-set, so one invite can't be accepted twice, after it was
716
+ // cancelled, or once it's expired — mirrors roleInUse's open-invite definition.
717
+ async claimInvite (id) {
718
+ const rows = await one(db.from('org_invites').update({ accepted_at: new Date().toISOString() }).eq('id', id)
719
+ .is('accepted_at', null).is('cancelled_at', null).gt('expires_at', new Date().toISOString()).select('id'))
720
+ return rows.length > 0
721
+ },
722
+
723
+ // Domain join requests: the partial unique index (join_requests_one_pending)
724
+ // keeps one pending per person per org. The fast-path select below is only
725
+ // that — a fast path — since two concurrent calls can both pass it; the
726
+ // insert is what's race-safe: if it loses the race, its 23505 means someone
727
+ // else's row won, so re-select and hand that one back instead of throwing.
728
+ // join_requests.email must be lowercase (a check constraint).
729
+ async createJoinRequest ({ orgId, userId, email }) {
730
+ const pending = await one(db.from('join_requests').select(REQUEST).eq('org_id', orgId).eq('user_id', userId).eq('status', 'pending').maybeSingle())
731
+ if (pending) return rowFrom(pending)
732
+ try {
733
+ return rowFrom(await one(db.from('join_requests').insert({ org_id: orgId, user_id: userId, email: email.toLowerCase() }).select(REQUEST).single()))
734
+ } catch (err) {
735
+ if (err.code !== '23505') throw err
736
+ return rowFrom(await one(db.from('join_requests').select(REQUEST).eq('org_id', orgId).eq('user_id', userId).eq('status', 'pending').maybeSingle()))
737
+ }
738
+ },
739
+ async joinRequestById (orgId, id) { return rowFrom(await one(db.from('join_requests').select(REQUEST).eq('org_id', orgId).eq('id', id).maybeSingle())) },
740
+ async listJoinRequests (orgId) {
741
+ const rows = await one(db.from('join_requests').select(`${REQUEST}, profiles (name)`).eq('org_id', orgId).eq('status', 'pending').order('created_at'))
742
+ return rows.map(({ profiles, ...r }) => ({ ...rowFrom(r), name: profiles?.name || '' }))
743
+ },
744
+ async joinRequestsForUser (userId) { return (await one(db.from('join_requests').select(REQUEST).eq('user_id', userId))).map(rowFrom) },
745
+ // Check-and-set: a request is decided once.
746
+ async decideJoinRequest (id, { status, decidedBy }) {
747
+ const rows = await one(db.from('join_requests').update({ status, decided_by: decidedBy, decided_at: new Date().toISOString() }).eq('id', id).eq('status', 'pending').select('id'))
748
+ return rows.length > 0
749
+ },
750
+ // Issue reports: one rpc per batch (record_events opens or counts up issues itself).
751
+ async recordEvents (list) {
752
+ if (!list.length) return 0
753
+ const events = list.map((e) => ({ ...toSnake({ ...e, occurredAt: ts(e.occurredAt) }), fingerprint: e.fingerprint ?? null, status: e.status ?? null, duration_ms: e.durationMs ?? null, user_id: e.userId ?? null, device_id: e.deviceId ?? null }))
754
+ return (await one(db.rpc('record_events', { events }))) ?? list.length
755
+ },
756
+ async pruneEvents (before) {
757
+ await one(db.from('events').delete().lt('occurred_at', ts(before)))
758
+ return 0
759
+ },
760
+ async pruneIssues (before) {
761
+ await one(db.from('issues').delete().lt('last_seen_at', ts(before)))
762
+ return 0
763
+ }
764
+ }
765
+ }