@sleep2agi/commhub-server 0.9.0-preview.13 → 0.9.0-preview.131

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 (318) hide show
  1. package/README.md +130 -10
  2. package/bin/commhub.ts +99 -11
  3. package/package.json +7 -7
  4. package/src/ack-create-request-transport.test.ts +182 -0
  5. package/src/ack-create-request.test.ts +50 -7
  6. package/src/admin-delete-network-http.test.ts +228 -0
  7. package/src/admin-networks-http.test.ts +88 -0
  8. package/src/adoption-candidates-http.test.ts +137 -0
  9. package/src/adoption-candidates.ts +158 -0
  10. package/src/agent-access-batch.test.ts +210 -0
  11. package/src/agent-access.ts +487 -0
  12. package/src/agent-acl-groups-http.test.ts +247 -0
  13. package/src/agent-acl-http.test.ts +659 -0
  14. package/src/agent-acl-slice3-http.test.ts +267 -0
  15. package/src/agent-groups-perf.test.ts +223 -0
  16. package/src/agent-notice.ts +48 -0
  17. package/src/agent-team-whoami-http.test.ts +98 -0
  18. package/src/agent-teams-http.test.ts +323 -0
  19. package/src/agent-teams.ts +244 -0
  20. package/src/alias-filter.test.ts +72 -0
  21. package/src/alias-filter.ts +42 -0
  22. package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
  23. package/src/api-host-supervisors-fallback.test.ts +271 -0
  24. package/src/api-host-supervisors-runtime-readiness.test.ts +195 -0
  25. package/src/api-nodes-shape.test.ts +26 -10
  26. package/src/auth-password-change-cli-tokens-http.test.ts +294 -0
  27. package/src/auth-sessions-http.test.ts +250 -0
  28. package/src/auth-validate.test.ts +5 -1
  29. package/src/auth-weak-password-login.test.ts +176 -0
  30. package/src/auth.ts +598 -49
  31. package/src/avatar-validate.test.ts +163 -0
  32. package/src/avatar-validate.ts +103 -0
  33. package/src/binding-generation-http.test.ts +121 -0
  34. package/src/blocked-exit-on-reply.test.ts +122 -0
  35. package/src/boot-side-effect.test.ts +124 -0
  36. package/src/clone-node.test.ts +248 -0
  37. package/src/clone-node.ts +289 -0
  38. package/src/codex-config-flags.ts +77 -0
  39. package/src/codex-fork-contract.ts +23 -0
  40. package/src/codex-fork-migration.test.ts +38 -0
  41. package/src/commhub-bin-args.test.ts +85 -0
  42. package/src/config-apply-sec1.test.ts +107 -0
  43. package/src/config-apply-validate.test.ts +134 -1
  44. package/src/config-apply-validate.ts +111 -5
  45. package/src/create-node-blocked-gate.test.ts +356 -0
  46. package/src/create-node-network-id.test.ts +79 -0
  47. package/src/create-node-tool-schema.test.ts +48 -0
  48. package/src/create-node-unicode-name.test.ts +120 -0
  49. package/src/create-node-validate.test.ts +193 -7
  50. package/src/create-node-validate.ts +227 -15
  51. package/src/create-node-workdir.test.ts +289 -0
  52. package/src/create-node.ts +77 -23
  53. package/src/daemon-provider-snapshots.test.ts +421 -0
  54. package/src/daemon-provider-snapshots.ts +327 -0
  55. package/src/daemon-token-owner-http.test.ts +236 -0
  56. package/src/dashboard-slash-routing-http.test.ts +107 -0
  57. package/src/db-adapter-guard.test.ts +148 -0
  58. package/src/db-adapter-translate.test.ts +128 -0
  59. package/src/db-adapter.ts +323 -87
  60. package/src/db-timestamp.test.ts +38 -0
  61. package/src/db-timestamp.ts +15 -0
  62. package/src/db.ts +933 -36
  63. package/src/delivered-without-push.test.ts +140 -0
  64. package/src/department-groups-http.test.ts +209 -0
  65. package/src/department-groups-sync-http.test.ts +328 -0
  66. package/src/department-groups.ts +434 -0
  67. package/src/department-heads-http.test.ts +330 -0
  68. package/src/department-heads.ts +226 -0
  69. package/src/departments-http.test.ts +185 -0
  70. package/src/departments.ts +260 -0
  71. package/src/desktop-message-seam.test.ts +150 -0
  72. package/src/desktop-message.test.ts +368 -0
  73. package/src/dm-file-scope-http.test.ts +178 -0
  74. package/src/ext-token-shared.test.ts +60 -0
  75. package/src/ext-validation-regression.test.ts +131 -0
  76. package/src/external-schedule-edits-http.test.ts +321 -0
  77. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  78. package/src/external-schedule-edits.ts +327 -0
  79. package/src/file-download-authz-dev-open.test.ts +135 -0
  80. package/src/file-download-authz.test.ts +404 -0
  81. package/src/file-download-date-bucket.test.ts +436 -0
  82. package/src/file-network-scope-dev-open.test.ts +97 -0
  83. package/src/file-network-scope.test.ts +732 -0
  84. package/src/group-membership-events-http.test.ts +264 -0
  85. package/src/group-messages-http.test.ts +482 -0
  86. package/src/group-messages.ts +297 -0
  87. package/src/health-redaction.test.ts +642 -0
  88. package/src/http-gzip.test.ts +94 -0
  89. package/src/http-gzip.ts +112 -0
  90. package/src/hub-provider-config.test.ts +139 -0
  91. package/src/hub-provider-config.ts +105 -0
  92. package/src/hub-timestamp.test.ts +38 -0
  93. package/src/hub-timestamp.ts +27 -0
  94. package/src/human-dm.ts +215 -0
  95. package/src/inbox-caller.ts +49 -0
  96. package/src/inbox-count-sse.test.ts +310 -0
  97. package/src/inbox-count.ts +29 -0
  98. package/src/index.ts +15 -2362
  99. package/src/lifecycle-guard.ts +1 -1
  100. package/src/mcp-context-cost-http.test.ts +123 -0
  101. package/src/mcp-projects-events-http.test.ts +162 -0
  102. package/src/mcp-route-label-http.test.ts +120 -0
  103. package/src/mcp-write-network-resolution.test.ts +432 -0
  104. package/src/member-audit-network-id-http.test.ts +93 -0
  105. package/src/member-presence-http.test.ts +210 -0
  106. package/src/messages-ack-by-agent-http.test.ts +142 -0
  107. package/src/migrate-sqlite-to-pg.test.ts +47 -0
  108. package/src/migrate-sqlite-to-pg.ts +263 -0
  109. package/src/model-auth-notify-http.test.ts +181 -0
  110. package/src/model-auth-notify.ts +116 -0
  111. package/src/network-name-http.test.ts +75 -0
  112. package/src/network-scope.ts +276 -0
  113. package/src/network-token-membership-http.test.ts +139 -0
  114. package/src/node-alias-control.ts +12 -0
  115. package/src/node-attrs-validate.ts +88 -0
  116. package/src/node-attrs.test.ts +217 -0
  117. package/src/node-caller-identity-http.test.ts +781 -0
  118. package/src/node-create-request-status-http.test.ts +105 -0
  119. package/src/node-daemon-bindings.test.ts +132 -0
  120. package/src/node-daemon-bindings.ts +130 -0
  121. package/src/node-files-transport.test.ts +344 -0
  122. package/src/node-health-codex-login.test.ts +120 -0
  123. package/src/node-health-dispatch-http.test.ts +279 -0
  124. package/src/node-health-guard.ts +105 -0
  125. package/src/node-health-passthrough.test.ts +115 -0
  126. package/src/node-health-store.ts +101 -0
  127. package/src/node-id-alias.test.ts +190 -0
  128. package/src/node-identity-conflict-http.test.ts +269 -0
  129. package/src/node-identity-conflict.ts +142 -0
  130. package/src/node-identity-mismatch.test.ts +74 -0
  131. package/src/node-lifecycle-controllable.test.ts +64 -0
  132. package/src/node-lifecycle-controllable.ts +53 -0
  133. package/src/node-lifecycle-read-http.test.ts +245 -0
  134. package/src/node-lifecycle-read.ts +121 -0
  135. package/src/node-logs-transport.test.ts +263 -0
  136. package/src/node-permissions-http.test.ts +276 -0
  137. package/src/node-permissions.ts +285 -0
  138. package/src/node-request-retention.test.ts +281 -0
  139. package/src/node-request-retention.ts +120 -0
  140. package/src/node-skills-transport.test.ts +209 -0
  141. package/src/node-token-ownership.ts +51 -0
  142. package/src/node-token-privilege-http.test.ts +158 -0
  143. package/src/observer-avatar-http.test.ts +431 -0
  144. package/src/observer-push.test.ts +252 -0
  145. package/src/opencode-v2-catalog.test.ts +268 -0
  146. package/src/opencode-v2-catalog.ts +352 -0
  147. package/src/package-version-consistency.test.ts +25 -0
  148. package/src/peer-reply-atomic.test.ts +455 -0
  149. package/src/peer-reply-capability.test.ts +70 -0
  150. package/src/pg-worker.ts +87 -0
  151. package/src/project-dir-warn-clear-http.test.ts +132 -0
  152. package/src/project-viewer-can-http.test.ts +114 -0
  153. package/src/push.ts +429 -10
  154. package/src/redact-tokens.test.ts +65 -0
  155. package/src/redact-tokens.ts +40 -0
  156. package/src/rename.ts +22 -2
  157. package/src/reply-target-derivation.test.ts +96 -0
  158. package/src/report-status-handover-capabilities-http.test.ts +124 -0
  159. package/src/report-status-host-schema.test.ts +235 -0
  160. package/src/report-status-resume-id-handover.test.ts +150 -0
  161. package/src/require-explicit-test-db.ts +9 -0
  162. package/src/requirement-due-reminders-http.test.ts +620 -0
  163. package/src/requirement-due-reminders.ts +416 -0
  164. package/src/requirement-events-http.test.ts +268 -0
  165. package/src/requirement-events.test.ts +45 -0
  166. package/src/requirement-events.ts +144 -0
  167. package/src/requirement-notify.ts +165 -0
  168. package/src/requirement-participant-notify-http.test.ts +309 -0
  169. package/src/requirement-tag-ops-http.test.ts +266 -0
  170. package/src/requirement-tags.ts +89 -0
  171. package/src/requirements-abandoned-http.test.ts +236 -0
  172. package/src/requirements-comment-http.test.ts +167 -0
  173. package/src/requirements-completed-at-migration.test.ts +70 -0
  174. package/src/requirements-errors-mcp-http.test.ts +164 -0
  175. package/src/requirements-errors.ts +143 -0
  176. package/src/requirements-etag-http.test.ts +91 -0
  177. package/src/requirements-http.test.ts +709 -0
  178. package/src/requirements-last-event-http.test.ts +349 -0
  179. package/src/requirements-list-mcp-slim-http.test.ts +155 -0
  180. package/src/requirements-list-slim-http.test.ts +319 -0
  181. package/src/requirements-mcp.test.ts +163 -0
  182. package/src/requirements-migrate.ts +259 -0
  183. package/src/requirements-owner-strict-mcp-http.test.ts +145 -0
  184. package/src/requirements-people-http.test.ts +114 -0
  185. package/src/requirements-people-mcp-http.test.ts +225 -0
  186. package/src/requirements-people.ts +127 -0
  187. package/src/requirements-priority-lowest.test.ts +114 -0
  188. package/src/requirements-search-http.test.ts +182 -0
  189. package/src/requirements-search.test.ts +32 -0
  190. package/src/requirements-search.ts +145 -0
  191. package/src/requirements-seq-http.test.ts +193 -0
  192. package/src/requirements-seq-migration.test.ts +63 -0
  193. package/src/requirements-start-date.test.ts +125 -0
  194. package/src/requirements-stats-http.test.ts +288 -0
  195. package/src/requirements-stats.ts +167 -0
  196. package/src/requirements-status-participants-http.test.ts +222 -0
  197. package/src/requirements-upgrade-e2e.test.ts +126 -0
  198. package/src/requirements-upgrade-migration.test.ts +98 -0
  199. package/src/requirements.ts +1806 -0
  200. package/src/resolve-port.test.ts +57 -0
  201. package/src/resolve-port.ts +44 -0
  202. package/src/response-charset.test.ts +130 -0
  203. package/src/rest-explicit-columns-http.test.ts +222 -0
  204. package/src/rest-identity.test.ts +130 -0
  205. package/src/rest-identity.ts +139 -0
  206. package/src/rest-projections.ts +68 -0
  207. package/src/rest-write-network-resolution.test.ts +133 -0
  208. package/src/restricted-files.ts +76 -0
  209. package/src/retention.ts +15 -3
  210. package/src/route-callers-http.test.ts +120 -0
  211. package/src/route-timing.test.ts +211 -0
  212. package/src/route-timing.ts +258 -0
  213. package/src/rules-file-duplicate-alias.test.ts +234 -0
  214. package/src/rules-file-session-target.test.ts +207 -0
  215. package/src/rules-file-transport.test.ts +237 -0
  216. package/src/runtime-label.test.ts +22 -0
  217. package/src/runtime-label.ts +21 -0
  218. package/src/runtime-readiness.ts +90 -0
  219. package/src/schedule-agent-mcp-http.test.ts +381 -0
  220. package/src/schedule-agent.ts +192 -0
  221. package/src/scheduled-doorbell-nodeless-session.test.ts +134 -0
  222. package/src/scheduled-failures-http.test.ts +233 -0
  223. package/src/scheduled-failures.ts +179 -0
  224. package/src/scheduled-offline.test.ts +131 -0
  225. package/src/scheduled-offline.ts +66 -0
  226. package/src/scheduled-reply-unread-http.test.ts +212 -0
  227. package/src/scheduled-run-terminal.test.ts +268 -0
  228. package/src/scheduled-skip-blocking-task.test.ts +120 -0
  229. package/src/scheduled-stuck-http.test.ts +238 -0
  230. package/src/scheduled-stuck.ts +153 -0
  231. package/src/scheduled-tasks-http.test.ts +638 -0
  232. package/src/scheduled-tasks.ts +766 -0
  233. package/src/scoped-secrets.ts +124 -0
  234. package/src/send-dedup-request-id.test.ts +229 -0
  235. package/src/send-reply-agent-warning.test.ts +202 -0
  236. package/src/send-reply-alias-optional.test.ts +131 -0
  237. package/src/send-reply-attachments.test.ts +458 -0
  238. package/src/send_dedup.test.ts +54 -0
  239. package/src/send_dedup.ts +52 -6
  240. package/src/serve-error.test.ts +38 -0
  241. package/src/serve-error.ts +27 -0
  242. package/src/server.ts +5257 -0
  243. package/src/session-task-on-dispatch.ts +14 -0
  244. package/src/session-token-ownership-http.test.ts +122 -0
  245. package/src/shared/codex-inventory.ts +41 -0
  246. package/src/shared/daemon-provider-login.ts +31 -0
  247. package/src/shared/daemon-provider-snapshot.ts +52 -0
  248. package/src/shared/external-schedule-contract.ts +60 -0
  249. package/src/shared/node-name.test.ts +62 -0
  250. package/src/shared/node-name.ts +161 -0
  251. package/src/shared/provider-application.ts +29 -0
  252. package/src/shared/provider-collection.ts +14 -0
  253. package/src/shared/provider-initialization.ts +8 -0
  254. package/src/shared/reserved-env-drift.test.ts +10 -0
  255. package/src/shared/scoped-secret-contract.ts +5 -0
  256. package/src/shared/task-content-limit-drift.test.ts +28 -0
  257. package/src/shared/task-content-limit.ts +25 -0
  258. package/src/side-thread-command-transport.test.ts +312 -0
  259. package/src/side-thread-command-transport.ts +318 -0
  260. package/src/side-thread-http-integration.test.ts +233 -0
  261. package/src/side-thread-http.ts +504 -0
  262. package/src/side-thread-production.test.ts +72 -0
  263. package/src/side-thread-production.ts +118 -0
  264. package/src/side-thread.test.ts +1067 -0
  265. package/src/side-thread.ts +2499 -0
  266. package/src/skillhub-http.test.ts +108 -0
  267. package/src/sqlite-close-checkpoint.test.ts +78 -0
  268. package/src/start-node.test.ts +264 -0
  269. package/src/status-alias-filter-http.test.ts +106 -0
  270. package/src/status-alias-resolver-http.test.ts +163 -0
  271. package/src/status-alias-resolver.ts +19 -0
  272. package/src/status-node-id-filter-http.test.ts +161 -0
  273. package/src/status-read-cache-http.test.ts +229 -0
  274. package/src/status-read-cache.ts +122 -0
  275. package/src/stop-delete-node.test.ts +509 -1
  276. package/src/task-access-http.test.ts +402 -0
  277. package/src/task-access.ts +420 -0
  278. package/src/task-auth-origin-http.test.ts +93 -0
  279. package/src/task-auth-origin.test.ts +24 -0
  280. package/src/task-auth-origin.ts +7 -0
  281. package/src/task-consumption.test.ts +482 -0
  282. package/src/task-diagnostic-http.test.ts +149 -0
  283. package/src/task-diagnostic.test.ts +115 -0
  284. package/src/task-diagnostic.ts +118 -0
  285. package/src/task-expiry-notice-http.test.ts +287 -0
  286. package/src/task-expiry-notice.ts +315 -0
  287. package/src/task-idempotency-mcp.test.ts +142 -0
  288. package/src/task-idempotency.test.ts +60 -0
  289. package/src/task-idempotency.ts +75 -0
  290. package/src/task-late-replies.ts +54 -0
  291. package/src/task-late-reply-http.test.ts +152 -0
  292. package/src/task-lifecycle-watcher.test.ts +204 -0
  293. package/src/task-lifecycle-watcher.ts +49 -0
  294. package/src/task-network-resolution-http.test.ts +140 -0
  295. package/src/task-orphan-http.test.ts +287 -0
  296. package/src/task-orphan.ts +119 -0
  297. package/src/task-queue-ahead-http.test.ts +309 -0
  298. package/src/task-queue-ahead-scale.test.ts +236 -0
  299. package/src/task-queue-ahead.ts +186 -0
  300. package/src/task-rejection-log-http.test.ts +160 -0
  301. package/src/task-rejection-log.ts +49 -0
  302. package/src/task-sender-http.test.ts +143 -0
  303. package/src/task-stale-open-http.test.ts +290 -0
  304. package/src/task-stale-open.ts +103 -0
  305. package/src/tasks-pagination-http.test.ts +141 -0
  306. package/src/tool-audience-http.test.ts +220 -0
  307. package/src/tool-audience.ts +118 -0
  308. package/src/tools-list-json-schema.test.ts +67 -0
  309. package/src/tools.ts +3940 -325
  310. package/src/upload-early-reject-guard.test.ts +155 -0
  311. package/src/uploads-http.test.ts +97 -15
  312. package/src/uploads.ts +103 -6
  313. package/src/user-avatar-http.test.ts +74 -0
  314. package/src/user-avatar-migration.test.ts +25 -0
  315. package/src/user-inbox-by-agent-http.test.ts +161 -0
  316. package/src/user-inbox-netid-notnull.test.ts +122 -0
  317. package/src/user-inbox-read-http.test.ts +223 -0
  318. package/src/user-inbox-schema.test.ts +54 -0
@@ -0,0 +1,210 @@
1
+ // 人员列表的在线状态 —— HTTP 集成测试(真实 Bun.serve,私有端口,临时库)。
2
+ //
3
+ // GET /api/networks/:id/humans 给每个人加 online / last_seen_at(判据 = 此刻有没有活着的用户流
4
+ // /events/users/me);用户流上推 member_presence。两个方向都测:
5
+ // 看得到:普通成员、受限成员、viewer;连上 → online=true,断开 → online=false + last_seen_at。
6
+ // 看不到:非成员 403;节点令牌只拿身份字段(含头像),无在线状态;别的网络的人收不到推送;自己收不到自己的。
7
+
8
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
9
+ import { mkdtempSync, rmSync } from "fs";
10
+ import { join } from "path";
11
+ import { tmpdir } from "os";
12
+ import { register } from "./auth.js";
13
+ import { db, hashToken, generateNetworkToken, generateId } from "./db.js";
14
+
15
+ const DIR = mkdtempSync(join(tmpdir(), "anet-member-presence-"));
16
+ let BASE = "";
17
+ let hub: any = null;
18
+
19
+ const PW = "PresenceTestPassw0rd!x";
20
+ let ownerToken = "";
21
+ let NET = "";
22
+ const users: Record<string, { token: string; userId: string }> = {};
23
+ let nodeToken = "";
24
+
25
+ const auth = (token: string) => ({ Authorization: `Bearer ${token}` });
26
+
27
+ async function get(token: string, path: string): Promise<{ status: number; body: any }> {
28
+ const res = await fetch(`${BASE}${path}`, { headers: auth(token) });
29
+ const text = await res.text();
30
+ let body: any = text;
31
+ try { body = JSON.parse(text); } catch {}
32
+ return { status: res.status, body };
33
+ }
34
+ async function post(token: string, path: string, payload: unknown): Promise<{ status: number; body: any }> {
35
+ const res = await fetch(`${BASE}${path}`, { method: "POST", headers: { ...auth(token), "Content-Type": "application/json" }, body: JSON.stringify(payload) });
36
+ const text = await res.text();
37
+ let body: any = text;
38
+ try { body = JSON.parse(text); } catch {}
39
+ return { status: res.status, body };
40
+ }
41
+ async function login(username: string): Promise<{ token: string; userId: string }> {
42
+ const r = await post("", "/api/auth/login", { username, password: PW });
43
+ expect(r.status).toBe(200);
44
+ return { token: r.body.token, userId: r.body.user.user_id };
45
+ }
46
+
47
+ /** 一条用户流:收集 data: 帧,close() 模拟 app 退出。 */
48
+ async function openUserStream(token: string, networkId: string) {
49
+ const ctrl = new AbortController();
50
+ const res = await fetch(`${BASE}/events/users/me?network_id=${encodeURIComponent(networkId)}`, { headers: auth(token), signal: ctrl.signal });
51
+ expect(res.status).toBe(200);
52
+ const events: any[] = [];
53
+ const reader = res.body!.getReader();
54
+ const decoder = new TextDecoder();
55
+ let buf = "";
56
+ void (async () => {
57
+ try {
58
+ for (;;) {
59
+ const { done, value } = await reader.read();
60
+ if (done) break;
61
+ buf += decoder.decode(value);
62
+ let i: number;
63
+ while ((i = buf.indexOf("\n\n")) >= 0) {
64
+ const frame = buf.slice(0, i);
65
+ buf = buf.slice(i + 2);
66
+ for (const line of frame.split("\n")) if (line.startsWith("data: ")) events.push(JSON.parse(line.slice(6)));
67
+ }
68
+ }
69
+ } catch { /* aborted */ }
70
+ })();
71
+ await waitFor(() => events.some((e) => e.type === "connected"));
72
+ return { events, close: () => ctrl.abort(), presence: () => events.filter((e) => e.type === "member_presence") };
73
+ }
74
+
75
+ async function waitFor(cond: () => boolean | Promise<boolean>, ms = 3000): Promise<void> {
76
+ const until = Date.now() + ms;
77
+ while (Date.now() < until) {
78
+ if (await cond()) return;
79
+ await new Promise((r) => setTimeout(r, 20));
80
+ }
81
+ throw new Error("waitFor timed out");
82
+ }
83
+
84
+ const humanOf = async (token: string, userId: string) =>
85
+ (await get(token, `/api/networks/${NET}/humans`)).body.humans.find((h: any) => h.user_id === userId);
86
+
87
+ beforeAll(async () => {
88
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
89
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
90
+ process.env.HOST = "127.0.0.1";
91
+
92
+ const owner = register(`pres_owner_${Date.now()}`, PW, undefined, "Owner");
93
+ expect(owner.ok).toBe(true);
94
+ ownerToken = owner.token!;
95
+ NET = owner.network_id!;
96
+ db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [owner.user!.user_id]);
97
+ users.owner = { token: ownerToken, userId: owner.user!.user_id };
98
+
99
+ const mod: any = await import("./server.js");
100
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
101
+ BASE = `http://127.0.0.1:${hub.port}`;
102
+
103
+ // 受限成员(默认 agent_access=granted)、viewer、普通成员;外人自己一个网络。
104
+ for (const [name, role] of [["pres_alpha", "member"], ["pres_beta", "member"], ["pres_gamma", "viewer"]] as const) {
105
+ const r = await post(ownerToken, "/api/admin/users", { username: name, password: PW, network_id: NET, role });
106
+ expect(r.status).toBe(200);
107
+ users[name] = await login(name);
108
+ }
109
+ const outsider = register(`pres_out_${Date.now()}`, PW);
110
+ expect(outsider.ok).toBe(true);
111
+ users.outsider = { token: outsider.token!, userId: outsider.user!.user_id };
112
+ users.outsider_net = { token: outsider.token!, userId: outsider.network_id! };
113
+
114
+ nodeToken = generateNetworkToken();
115
+ db.run("INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope) VALUES (?1, ?2, ?3, ?4, 'pres-node', 'network')",
116
+ [generateId("tok"), hashToken(nodeToken), users.owner.userId, NET]);
117
+ }, 30_000);
118
+
119
+ afterAll(() => {
120
+ try { hub?.stop?.(true); } catch {}
121
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
122
+ });
123
+
124
+ describe("GET /api/networks/:id/humans — online / last_seen_at", () => {
125
+ test("never connected since hub start → online=false, last_seen_at=null (unknown, not 'long ago')", async () => {
126
+ const r = await get(users.pres_alpha.token, `/api/networks/${NET}/humans`);
127
+ expect(r.status).toBe(200);
128
+ expect(Object.keys(r.body.humans[0]).sort()).toEqual(["avatar_url", "display_name", "last_seen_at", "online", "user_id", "username"]);
129
+ expect(r.body.humans.every((h: any) => h.avatar_url === null)).toBe(true);
130
+ const beta = r.body.humans.find((h: any) => h.user_id === users.pres_beta.userId);
131
+ expect(beta.online).toBe(false);
132
+ expect(beta.last_seen_at).toBeNull();
133
+ });
134
+
135
+ test("user stream open → online for member, restricted member and viewer; closed → offline with last_seen_at", async () => {
136
+ const watcher = await openUserStream(users.pres_alpha.token, NET);
137
+ const before = Date.now();
138
+ const beta = await openUserStream(users.pres_beta.token, NET);
139
+
140
+ for (const viewer of ["pres_alpha", "pres_gamma", "owner"]) {
141
+ const h = await humanOf(users[viewer].token, users.pres_beta.userId);
142
+ expect(h.online).toBe(true);
143
+ expect(Date.parse(h.last_seen_at)).toBeGreaterThanOrEqual(before - 1000);
144
+ }
145
+ // 推送:alpha 收到 beta 上线;beta 收不到自己的。
146
+ await waitFor(() => watcher.presence().some((e) => e.member_user_id === users.pres_beta.userId && e.online === true));
147
+ const up = watcher.presence().find((e) => e.member_user_id === users.pres_beta.userId)!;
148
+ expect(up.network_id).toBe(NET);
149
+ expect(up.user_id).toBe(users.pres_alpha.userId); // 收件人
150
+ expect(beta.presence().filter((e) => e.member_user_id === users.pres_beta.userId)).toEqual([]);
151
+
152
+ beta.close();
153
+ await waitFor(async () => (await humanOf(users.pres_alpha.token, users.pres_beta.userId)).online === false);
154
+ const off = await humanOf(users.pres_gamma.token, users.pres_beta.userId);
155
+ expect(off.online).toBe(false);
156
+ expect(Date.parse(off.last_seen_at)).toBeGreaterThanOrEqual(before - 1000);
157
+ await waitFor(() => watcher.presence().some((e) => e.member_user_id === users.pres_beta.userId && e.online === false));
158
+ const down = watcher.presence().find((e) => e.member_user_id === users.pres_beta.userId && e.online === false)!;
159
+ expect(typeof down.last_seen_at).toBe("string");
160
+ watcher.close();
161
+ });
162
+
163
+ test("two streams for one user: closing one keeps them online, no offline event", async () => {
164
+ const watcher = await openUserStream(users.pres_alpha.token, NET);
165
+ const a = await openUserStream(users.pres_beta.token, NET);
166
+ const b = await openUserStream(users.pres_beta.token, NET);
167
+ await waitFor(() => watcher.presence().some((e) => e.member_user_id === users.pres_beta.userId && e.online));
168
+ a.close();
169
+ await new Promise((r) => setTimeout(r, 200));
170
+ expect((await humanOf(users.pres_alpha.token, users.pres_beta.userId)).online).toBe(true);
171
+ expect(watcher.presence().filter((e) => e.member_user_id === users.pres_beta.userId && !e.online)).toEqual([]);
172
+ b.close();
173
+ await waitFor(async () => (await humanOf(users.pres_alpha.token, users.pres_beta.userId)).online === false);
174
+ watcher.close();
175
+ });
176
+ });
177
+
178
+ describe("who must NOT see presence", () => {
179
+ test("non-member → 403, no humans at all", async () => {
180
+ const r = await get(users.outsider.token, `/api/networks/${NET}/humans`);
181
+ expect(r.status).toBe(403);
182
+ expect(r.body.humans).toBeUndefined();
183
+ });
184
+
185
+ test("node token → identity fields including avatar, no online / last_seen_at", async () => {
186
+ const beta = await openUserStream(users.pres_beta.token, NET);
187
+ const r = await get(nodeToken, `/api/networks/${NET}/humans`);
188
+ expect(r.status).toBe(200);
189
+ for (const h of r.body.humans) {
190
+ expect(Object.keys(h).sort()).toEqual(["avatar_url", "display_name", "user_id", "username"]);
191
+ expect(h.avatar_url).toBeNull();
192
+ }
193
+ // 节点令牌也不能订阅用户流(既有行为,这里钉住:不能借它收 member_presence)。
194
+ expect((await fetch(`${BASE}/events/users/me?network_id=${NET}`, { headers: auth(nodeToken) })).status).toBe(403);
195
+ beta.close();
196
+ await waitFor(async () => (await humanOf(users.pres_alpha.token, users.pres_beta.userId)).online === false);
197
+ });
198
+
199
+ test("a user connected in another network gets no member_presence about this network's people", async () => {
200
+ const outsiderStream = await openUserStream(users.outsider.token, users.outsider_net.userId);
201
+ const watcher = await openUserStream(users.pres_alpha.token, NET);
202
+ const beta = await openUserStream(users.pres_beta.token, NET);
203
+ await waitFor(() => watcher.presence().some((e) => e.member_user_id === users.pres_beta.userId && e.online));
204
+ beta.close();
205
+ await waitFor(() => watcher.presence().some((e) => e.member_user_id === users.pres_beta.userId && !e.online));
206
+ expect(outsiderStream.presence()).toEqual([]);
207
+ watcher.close();
208
+ outsiderStream.close();
209
+ });
210
+ });
@@ -0,0 +1,142 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "fs";
3
+ import { join } from "path";
4
+ import { tmpdir } from "os";
5
+ import { register } from "./auth.js";
6
+ import { db } from "./db.js";
7
+
8
+ // #1909 —— POST /api/messages/ack 的 `{ agent }` 形态:把调用者收到的、来自该 agent 的
9
+ // 两张表(user_inbox + inbox reply/task/message)全部标已读。角标分母 unread_by_agent
10
+ // 数的是全集,按 id 只能 ack 拉到的那一页 ⇒ 老行永远清不掉,这一形态补上。
11
+ const PRIVATE_DB_DIR = mkdtempSync(join(tmpdir(), "anet-ack-by-agent-http-"));
12
+ let server: any;
13
+ let base = "";
14
+ let tokenA = "";
15
+ let tokenB = "";
16
+ let userA = "";
17
+ let userB = "";
18
+ let userIdA = "";
19
+ let userIdB = "";
20
+ let netA = "";
21
+ let netB = "";
22
+
23
+ const AGENT = "ack-agent-x";
24
+ const OTHER = "ack-agent-y";
25
+
26
+ function seed() {
27
+ const ui = (id: string, net: string, uid: string, from: string) =>
28
+ db.run(
29
+ `INSERT INTO user_inbox (message_id, network_id, user_id, from_session, kind, content) VALUES (?1, ?2, ?3, ?4, 'agent_message', 'x')`,
30
+ [id, net, uid, from],
31
+ );
32
+ const ib = (id: string, net: string, to: string, from: string, type: string) =>
33
+ db.run(
34
+ `INSERT INTO inbox (id, session_name, type, content, from_session, network_id) VALUES (?1, ?2, ?3, 'x', ?4, ?5)`,
35
+ [id, to, type, from, net],
36
+ );
37
+ ui("ui_a_x_1", netA, userIdA, AGENT);
38
+ ui("ui_a_x_2", netA, userIdA, AGENT);
39
+ ui("ui_a_y_1", netA, userIdA, OTHER);
40
+ ui("ui_b_x_1", netB, userIdB, AGENT);
41
+ ib("ib_a_x_reply", netA, userA, AGENT, "reply");
42
+ ib("ib_a_x_task", netA, userA, AGENT, "task");
43
+ ib("ib_a_x_status", netA, userA, AGENT, "status"); // 不在 reply/task/message 内,不该被 ack
44
+ ib("ib_a_y_reply", netA, userA, OTHER, "reply");
45
+ ib("ib_b_x_reply", netB, userB, AGENT, "reply");
46
+ }
47
+
48
+ beforeAll(async () => {
49
+ process.env.COMMHUB_DB = process.env.COMMHUB_DB || join(PRIVATE_DB_DIR, "hub.db");
50
+ const stamp = `${Date.now()}_${process.pid}`;
51
+ userA = `ack_agent_a_${stamp}`;
52
+ userB = `ack_agent_b_${stamp}`;
53
+ const a = register(userA, "AckAgent123!", undefined, "seed");
54
+ const b = register(userB, "AckAgent123!", undefined, "seed");
55
+ expect(a.ok).toBe(true);
56
+ expect(b.ok).toBe(true);
57
+ tokenA = a.token!; netA = a.network_id!;
58
+ tokenB = b.token!; netB = b.network_id!;
59
+ userIdA = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", [userA])!.user_id;
60
+ userIdB = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", [userB])!.user_id;
61
+ seed();
62
+ const mod: any = await import("./server.js");
63
+ server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
64
+ base = `http://127.0.0.1:${server.port}`;
65
+ }, 30_000);
66
+
67
+ afterAll(() => {
68
+ try { server?.stop?.(true); } catch {}
69
+ try { rmSync(PRIVATE_DB_DIR, { recursive: true, force: true }); } catch {}
70
+ });
71
+
72
+ async function ack(token: string, body: unknown): Promise<{ status: number; body: any }> {
73
+ const response = await fetch(`${base}/api/messages/ack`, {
74
+ method: "POST",
75
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
76
+ body: JSON.stringify(body),
77
+ });
78
+ return { status: response.status, body: await response.json() };
79
+ }
80
+
81
+ const uiAcked = (id: string) => db.get<{ acked: number }>("SELECT acked FROM user_inbox WHERE message_id = ?1", [id])!.acked;
82
+ const ibAcked = (id: string) => db.get<{ acked: number }>("SELECT acked FROM inbox WHERE id = ?1", [id])!.acked;
83
+
84
+ async function unreadByAgent(token: string): Promise<Record<string, number>> {
85
+ const response = await fetch(`${base}/api/messages?scope=user&limit=5`, { headers: { Authorization: `Bearer ${token}` } });
86
+ expect(response.status).toBe(200);
87
+ return ((await response.json()) as any).unread_by_agent ?? {};
88
+ }
89
+
90
+ describe("POST /api/messages/ack { agent } (#1909)", () => {
91
+ test("missing everything → message_id_required; agent + ids → ambiguous_ack", async () => {
92
+ const none = await ack(tokenA, {});
93
+ expect(none.status).toBe(400);
94
+ expect(none.body.error).toBe("message_id_required");
95
+ const both = await ack(tokenA, { agent: AGENT, message_ids: ["ui_a_x_1"] });
96
+ expect(both.status).toBe(400);
97
+ expect(both.body.error).toBe("ambiguous_ack");
98
+ // 拒绝的那次不能有副作用
99
+ expect(uiAcked("ui_a_x_1")).toBe(0);
100
+ });
101
+
102
+ test("unread_by_agent counts the seeded rows before ack", async () => {
103
+ const by = await unreadByAgent(tokenA);
104
+ expect(by[AGENT]).toBe(4); // 2 user_inbox + reply + task (status 不计)
105
+ expect(by[OTHER]).toBe(2);
106
+ });
107
+
108
+ test("agent form acks both tables, only that agent, only the caller", async () => {
109
+ const r = await ack(tokenA, { agent: ` ${AGENT} ` });
110
+ expect(r.status).toBe(200);
111
+ expect(r.body).toMatchObject({ ok: true, scope: "agent", agent: AGENT, acked: 4, acked_user_inbox: 2, acked_inbox: 2 });
112
+ expect(uiAcked("ui_a_x_1")).toBe(1);
113
+ expect(uiAcked("ui_a_x_2")).toBe(1);
114
+ expect(ibAcked("ib_a_x_reply")).toBe(1);
115
+ expect(ibAcked("ib_a_x_task")).toBe(1);
116
+ // 不该动的:别的 agent、非 reply/task/message 类型、别的用户
117
+ expect(uiAcked("ui_a_y_1")).toBe(0);
118
+ expect(ibAcked("ib_a_x_status")).toBe(0);
119
+ expect(ibAcked("ib_a_y_reply")).toBe(0);
120
+ expect(uiAcked("ui_b_x_1")).toBe(0);
121
+ expect(ibAcked("ib_b_x_reply")).toBe(0);
122
+ const by = await unreadByAgent(tokenA);
123
+ expect(by[AGENT] ?? 0).toBe(0);
124
+ expect(by[OTHER]).toBe(2);
125
+ expect((await unreadByAgent(tokenB))[AGENT]).toBe(2);
126
+ });
127
+
128
+ test("agent form is idempotent: second call changes 0 rows", async () => {
129
+ const r = await ack(tokenA, { agent: AGENT });
130
+ expect(r.status).toBe(200);
131
+ expect(r.body.acked).toBe(0);
132
+ });
133
+
134
+ test("id form still works and now reports scope ids", async () => {
135
+ const r = await ack(tokenA, { message_ids: ["ui_a_y_1", "ib_a_y_reply", "ui_b_x_1"] });
136
+ expect(r.status).toBe(200);
137
+ expect(r.body).toMatchObject({ ok: true, scope: "ids", acked: 2, acked_user_inbox: 1, acked_inbox: 1 });
138
+ expect(uiAcked("ui_a_y_1")).toBe(1);
139
+ expect(ibAcked("ib_a_y_reply")).toBe(1);
140
+ expect(uiAcked("ui_b_x_1")).toBe(0); // 别人的 id 匹配不到
141
+ });
142
+ });
@@ -0,0 +1,47 @@
1
+ import { afterAll, describe, expect, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { MigrateRefused, migrateSqliteToPg } from "./migrate-sqlite-to-pg";
6
+
7
+ // Only the refusals that happen before any connection is attempted; the full
8
+ // copy runs against a real PostgreSQL in test2123 (migrate-stage.sh).
9
+ const home = mkdtempSync(join(tmpdir(), "anet-migrate-home-"));
10
+ const commhub = join(home, ".commhub");
11
+ mkdirSync(commhub);
12
+ writeFileSync(join(commhub, "commhub.db"), "");
13
+ const elsewhere = join(home, "copies");
14
+ mkdirSync(elsewhere);
15
+ symlinkSync(join(commhub, "commhub.db"), join(elsewhere, "link.db"));
16
+ const PG = "postgres://user:s3cret@127.0.0.1:1/anet_never_test";
17
+
18
+ afterAll(() => rmSync(home, { recursive: true, force: true }));
19
+
20
+ async function refusal(opts: Parameters<typeof migrateSqliteToPg>[0]): Promise<string> {
21
+ try {
22
+ await migrateSqliteToPg({ ...opts, home, log: () => {} });
23
+ } catch (e) {
24
+ expect(e).toBeInstanceOf(MigrateRefused);
25
+ return String((e as Error).message);
26
+ }
27
+ throw new Error("expected a refusal");
28
+ }
29
+
30
+ describe("RFC-039 S5 migrate-to-pg refusals", () => {
31
+ test("target must be a postgres URL", async () => {
32
+ expect(await refusal({ from: join(elsewhere, "x.db"), to: "mysql://h/db" })).toContain("--to must be a postgres");
33
+ });
34
+
35
+ test("missing source", async () => {
36
+ expect(await refusal({ from: join(elsewhere, "missing.db"), to: PG })).toContain("does not exist");
37
+ });
38
+
39
+ test("the live Hub directory ~/.commhub is refused, also through a symlink", async () => {
40
+ for (const from of [join(commhub, "commhub.db"), join(elsewhere, "link.db")]) {
41
+ const message = await refusal({ from, to: PG });
42
+ expect(message).toContain("live Hub's directory");
43
+ expect(message).toContain("--i-know-this-is-a-copy");
44
+ expect(message).not.toContain("s3cret");
45
+ }
46
+ });
47
+ });
@@ -0,0 +1,263 @@
1
+ /**
2
+ * RFC-039 S5 — offline copy of a Hub's SQLite database into an EMPTY
3
+ * PostgreSQL database.
4
+ *
5
+ * commhub-server migrate-to-pg --from <sqlite file> --to <postgres url>
6
+ * [--dry-run] [--i-know-this-is-a-copy]
7
+ *
8
+ * Contract:
9
+ * - offline: no running process may have the source (or its -wal/-shm)
10
+ * open (checked through /proc on Linux). A stopped Hub normally leaves a
11
+ * -wal behind — that is expected, and its contents are included: the
12
+ * source is opened read-only and read through a `VACUUM INTO` snapshot in
13
+ * a private temp dir.
14
+ * - never the live Hub's database: a source whose real path is under
15
+ * ~/.commhub is refused unless --i-know-this-is-a-copy is given.
16
+ * - empty target only: the target's `public` schema must hold no tables,
17
+ * views, sequences or functions. The schema is then built by the Hub's
18
+ * own module graph (no second copy of the DDL).
19
+ * - one transaction: every table is copied and verified — row counts plus
20
+ * a SHA-256 over canonicalised rows — before COMMIT; any mismatch or
21
+ * error rolls everything back.
22
+ * - --dry-run does all of the above, rolls back, and drops what it created,
23
+ * leaving the target as it found it.
24
+ * - the target URL is printed with credentials removed.
25
+ */
26
+ import { Database } from "bun:sqlite";
27
+ import { createHash } from "node:crypto";
28
+ import { existsSync, mkdtempSync, readdirSync, readlinkSync, realpathSync, rmSync, statSync } from "node:fs";
29
+ import { tmpdir } from "node:os";
30
+ import { join, resolve, sep } from "node:path";
31
+ import { PgAdapter, redactPgUrl, type DbAdapter } from "./db-adapter.js";
32
+
33
+ export type MigrateOptions = {
34
+ from: string;
35
+ to: string;
36
+ dryRun?: boolean;
37
+ allowCommhubCopy?: boolean;
38
+ home?: string;
39
+ log?: (line: string) => void;
40
+ };
41
+
42
+ export type TableReport = { table: string; rows: number; sha256: string };
43
+ export type MigrateReport = { ok: true; dryRun: boolean; tables: TableReport[] };
44
+
45
+ class DryRunRollback extends Error {}
46
+
47
+ /** Refusals are thrown as MigrateRefused so the CLI can print them without a stack. */
48
+ export class MigrateRefused extends Error {}
49
+
50
+ function refuse(message: string): never {
51
+ throw new MigrateRefused(`[migrate-to-pg] REFUSING: ${message}`);
52
+ }
53
+
54
+ const quoteIdent = (name: string) => `"${name.replace(/"/g, '""')}"`;
55
+
56
+ /** One canonical text per value, identical for bun:sqlite and PgAdapter output. */
57
+ function canonical(value: unknown): unknown {
58
+ if (value === null || value === undefined) return null;
59
+ if (typeof value === "bigint") return `i:${value.toString()}`;
60
+ if (typeof value === "number") return Number.isInteger(value) ? `i:${value}` : `f:${value}`;
61
+ if (value instanceof Uint8Array) return `b:${Buffer.from(value).toString("hex")}`;
62
+ if (typeof value === "string" && /^-?\d+$/.test(value) && value.length > 15) return `i:${value}`; // unsafe int8 comes back as text
63
+ return `s:${String(value)}`;
64
+ }
65
+
66
+ /** Order-independent digest: hash each row, sort the row hashes, hash the list. */
67
+ function tableDigest(rows: Iterable<Record<string, unknown>>, cols: string[]): { rows: number; sha256: string } {
68
+ const rowHashes: string[] = [];
69
+ for (const row of rows) {
70
+ rowHashes.push(createHash("sha256").update(JSON.stringify(cols.map((c) => canonical(row[c])))).digest("hex"));
71
+ }
72
+ rowHashes.sort();
73
+ return { rows: rowHashes.length, sha256: createHash("sha256").update(rowHashes.join("\n")).digest("hex") };
74
+ }
75
+
76
+ function bindValue(value: unknown): unknown {
77
+ if (typeof value === "bigint") {
78
+ const n = Number(value);
79
+ return Number.isSafeInteger(n) ? n : value.toString();
80
+ }
81
+ return value;
82
+ }
83
+
84
+ function checkSource(from: string, home: string, allowCommhubCopy: boolean): string {
85
+ const abs = resolve(from);
86
+ if (!existsSync(abs)) refuse(`source ${abs} does not exist`);
87
+ const real = realpathSync(abs);
88
+ if (!statSync(real).isFile()) refuse(`source ${real} is not a file`);
89
+ const commhubDir = join(home, ".commhub");
90
+ const guarded = existsSync(commhubDir) ? realpathSync(commhubDir) : resolve(commhubDir);
91
+ if ((real === guarded || real.startsWith(guarded + sep)) && !allowCommhubCopy) {
92
+ refuse(`source ${real} is under ${guarded}, the live Hub's directory. Stop the Hub, copy the database elsewhere, ` +
93
+ "and migrate the copy — or pass --i-know-this-is-a-copy if this file already is one");
94
+ }
95
+ const holders = processesHolding([real, `${real}-wal`, `${real}-shm`]);
96
+ if (holders === null) {
97
+ console.warn("[migrate-to-pg] cannot inspect open files on this platform — make sure the Hub using this database is stopped");
98
+ } else if (holders.length) {
99
+ refuse(`${real} is open in running process(es) ${holders.join(", ")} — stop the Hub first`);
100
+ }
101
+ return real;
102
+ }
103
+
104
+ /** PIDs (other than ours) with any of `paths` open; null where /proc is unavailable. */
105
+ function processesHolding(paths: string[]): number[] | null {
106
+ if (!existsSync("/proc/self/fd")) return null;
107
+ const wanted = new Set(paths);
108
+ const holders: number[] = [];
109
+ for (const entry of readdirSync("/proc")) {
110
+ const pid = Number(entry);
111
+ if (!Number.isInteger(pid) || pid === process.pid) continue;
112
+ let fds: string[];
113
+ try { fds = readdirSync(`/proc/${pid}/fd`); } catch { continue; } // gone, or another user's
114
+ for (const fd of fds) {
115
+ let target = "";
116
+ try { target = readlinkSync(`/proc/${pid}/fd/${fd}`); } catch { continue; }
117
+ if (wanted.has(target.replace(/ \(deleted\)$/, ""))) { holders.push(pid); break; }
118
+ }
119
+ }
120
+ return holders;
121
+ }
122
+
123
+ function publicObjectCount(pg: PgAdapter): number {
124
+ const row = pg.get<{ n: number }>(
125
+ `SELECT (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public')
126
+ + (SELECT COUNT(*) FROM information_schema.sequences WHERE sequence_schema = 'public')
127
+ + (SELECT COUNT(*) FROM information_schema.routines WHERE routine_schema = 'public') AS n`,
128
+ );
129
+ return Number(row?.n ?? 0);
130
+ }
131
+
132
+ function dropEverythingInPublic(pg: DbAdapter) {
133
+ // Only ever called on a target that was verified empty before we started,
134
+ // so everything here was created by this run.
135
+ pg.exec(`DO $$ DECLARE r record; BEGIN
136
+ FOR r IN SELECT tablename FROM pg_tables WHERE schemaname = 'public' LOOP
137
+ EXECUTE 'DROP TABLE IF EXISTS public.' || quote_ident(r.tablename) || ' CASCADE';
138
+ END LOOP;
139
+ FOR r IN SELECT p.oid::regprocedure AS sig FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace WHERE n.nspname = 'public' LOOP
140
+ EXECUTE 'DROP FUNCTION IF EXISTS ' || r.sig || ' CASCADE';
141
+ END LOOP;
142
+ FOR r IN SELECT sequencename FROM pg_sequences WHERE schemaname = 'public' LOOP
143
+ EXECUTE 'DROP SEQUENCE IF EXISTS public.' || quote_ident(r.sequencename) || ' CASCADE';
144
+ END LOOP;
145
+ END $$`);
146
+ }
147
+
148
+ export async function migrateSqliteToPg(opts: MigrateOptions): Promise<MigrateReport> {
149
+ const log = opts.log ?? ((line: string) => console.log(line));
150
+ const home = opts.home ?? process.env.HOME ?? "";
151
+ if (!/^postgres(ql)?:\/\//.test(opts.to)) refuse("--to must be a postgres:// or postgresql:// URL");
152
+ const source = checkSource(opts.from, home, !!opts.allowCommhubCopy);
153
+ log(`[migrate-to-pg] source ${source}`);
154
+ log(`[migrate-to-pg] target ${redactPgUrl(opts.to)}${opts.dryRun ? " (dry run)" : ""}`);
155
+
156
+ // Empty-target check on a connection of our own, before the schema exists.
157
+ const probe = new PgAdapter(opts.to);
158
+ const existing = publicObjectCount(probe);
159
+ probe.close();
160
+ if (existing > 0) refuse(`target schema "public" is not empty (${existing} tables/sequences/functions). Migrate only into a new, empty database`);
161
+
162
+ // Snapshot the source: a consistent read that cannot touch the original.
163
+ const snapDir = mkdtempSync(join(tmpdir(), "anet-migrate-"));
164
+ const snapshot = join(snapDir, "snapshot.db");
165
+ try {
166
+ const original = new Database(source, { readonly: true });
167
+ original.exec(`VACUUM INTO '${snapshot.replace(/'/g, "''")}'`);
168
+ original.close();
169
+ const src = new Database(snapshot, { readonly: true, safeIntegers: true });
170
+
171
+ // Build the target schema exactly as the Hub does: import its module graph
172
+ // with DATABASE_URL pointing at the target.
173
+ delete process.env.COMMHUB_DB;
174
+ process.env.DATABASE_URL = opts.to;
175
+ await import("./server.js");
176
+ const { db } = await import("./db.js");
177
+ if (db.dialect !== "postgres") throw new Error("schema build did not select PostgreSQL");
178
+
179
+ const tables = (src.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name").all() as { name: string }[])
180
+ .map((r) => r.name);
181
+ const report: TableReport[] = [];
182
+
183
+ try {
184
+ db.transaction(() => {
185
+ for (const table of tables) {
186
+ const srcCols = (src.query(`PRAGMA table_info(${quoteIdent(table)})`).all() as { name: string }[]).map((c) => c.name);
187
+ const tgtCols = new Set(db.all<{ column_name: string }>(
188
+ "SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = ?1", table,
189
+ ).map((c) => c.column_name));
190
+ const srcCount = Number((src.query(`SELECT COUNT(*) AS n FROM ${quoteIdent(table)}`).get() as { n: bigint | number }).n);
191
+ if (tgtCols.size === 0) {
192
+ if (srcCount > 0) throw new Error(`table ${table} (${srcCount} rows) does not exist in the Hub's PostgreSQL schema`);
193
+ log(`[migrate-to-pg] skip ${table}: not in the PostgreSQL schema, and empty in the source`);
194
+ continue;
195
+ }
196
+ const missing = srcCols.filter((c) => !tgtCols.has(c));
197
+ if (missing.length && srcCount > 0) throw new Error(`table ${table}: column(s) ${missing.join(", ")} do not exist in PostgreSQL`);
198
+ const cols = srcCols.filter((c) => tgtCols.has(c));
199
+ // SQLite's implicit rowid carries insertion order; PostgreSQL keeps it
200
+ // in a real `rowid` column on the tables that sort by it (RFC-039 F3).
201
+ const copyRowid = tgtCols.has("rowid") && !srcCols.includes("rowid");
202
+ const selectCols = [...(copyRowid ? ["rowid AS rowid"] : []), ...cols.map(quoteIdent)].join(", ");
203
+ const insertCols = [...(copyRowid ? ["rowid"] : []), ...cols];
204
+
205
+ // Triggers are off only while this table is being filled. Re-enabled
206
+ // in `finally` so no path — including a failed batch — leaves them
207
+ // off; a rollback also restores them (DDL is transactional in PG).
208
+ db.exec(`ALTER TABLE ${quoteIdent(table)} DISABLE TRIGGER USER`);
209
+ try {
210
+ db.run(`DELETE FROM ${quoteIdent(table)}`); // rows the schema bootstrap seeded; the source is authoritative
211
+ const perBatch = Math.max(1, Math.floor(30000 / insertCols.length));
212
+ const stmt = src.query(`SELECT ${selectCols} FROM ${quoteIdent(table)} ORDER BY rowid LIMIT ?1 OFFSET ?2`);
213
+ for (let offset = 0; offset < srcCount; offset += perBatch) {
214
+ const rows = stmt.all(perBatch, offset) as Record<string, unknown>[];
215
+ if (!rows.length) break;
216
+ const params: unknown[] = [];
217
+ const tuples = rows.map((row) => `(${insertCols.map((c) => { params.push(bindValue(row[c])); return `?${params.length}`; }).join(", ")})`);
218
+ db.run(`INSERT INTO ${quoteIdent(table)} (${insertCols.map(quoteIdent).join(", ")}) VALUES ${tuples.join(", ")}`, params);
219
+ }
220
+ } finally {
221
+ db.exec(`ALTER TABLE ${quoteIdent(table)} ENABLE TRIGGER USER`);
222
+ }
223
+
224
+ // Sequences behind BIGSERIAL columns continue after the copied ids.
225
+ for (const seq of db.all<{ column_name: string }>(
226
+ "SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = ?1 AND column_default LIKE 'nextval(%'", table,
227
+ )) {
228
+ db.get(`SELECT setval(pg_get_serial_sequence(?1, ?2), COALESCE((SELECT MAX(${quoteIdent(seq.column_name)}) FROM ${quoteIdent(table)}), 0) + 1, false)`,
229
+ table, seq.column_name);
230
+ }
231
+
232
+ // Verify before COMMIT.
233
+ const hashCols = copyRowid ? ["rowid", ...cols] : cols;
234
+ const srcDigest = tableDigest(src.query(`SELECT ${selectCols} FROM ${quoteIdent(table)}`).all() as Record<string, unknown>[], hashCols);
235
+ const tgtDigest = tableDigest(db.all(`SELECT ${hashCols.map(quoteIdent).join(", ")} FROM ${quoteIdent(table)}`), hashCols);
236
+ if (srcDigest.rows !== tgtDigest.rows || srcDigest.sha256 !== tgtDigest.sha256) {
237
+ throw new Error(`verification failed for ${table}: source ${srcDigest.rows} rows ${srcDigest.sha256.slice(0, 12)}, ` +
238
+ `target ${tgtDigest.rows} rows ${tgtDigest.sha256.slice(0, 12)}`);
239
+ }
240
+ report.push({ table, rows: srcDigest.rows, sha256: srcDigest.sha256 });
241
+ log(`[migrate-to-pg] ${table}: ${srcDigest.rows} rows, sha256 ${srcDigest.sha256.slice(0, 16)} ✓`);
242
+ }
243
+ if (opts.dryRun) throw new DryRunRollback("dry run");
244
+ });
245
+ } catch (e) {
246
+ const rows = report.reduce((n, t) => n + t.rows, 0);
247
+ dropEverythingInPublic(db);
248
+ src.close();
249
+ if (!(e instanceof DryRunRollback)) {
250
+ const verify = e instanceof Error && e.message.startsWith("verification failed") ? "MISMATCH" : "NOT-REACHED";
251
+ log(`[migrate-to-pg] SUMMARY result=FAILED tables_verified=${report.length}/${tables.length} rows=${rows} verify=${verify} — rolled back, target left empty`);
252
+ throw e;
253
+ }
254
+ log(`[migrate-to-pg] SUMMARY result=DRY-RUN tables=${report.length} rows=${rows} verify=OK — rolled back, target left empty`);
255
+ return { ok: true, dryRun: true, tables: report };
256
+ }
257
+ src.close();
258
+ log(`[migrate-to-pg] SUMMARY result=COMMITTED tables=${report.length} rows=${report.reduce((n, t) => n + t.rows, 0)} verify=OK`);
259
+ return { ok: true, dryRun: false, tables: report };
260
+ } finally {
261
+ rmSync(snapDir, { recursive: true, force: true });
262
+ }
263
+ }