@vellumai/assistant 0.5.6 → 0.5.7

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 (305) hide show
  1. package/.env.example +16 -2
  2. package/ARCHITECTURE.md +6 -75
  3. package/Dockerfile +1 -1
  4. package/README.md +0 -2
  5. package/bun.lock +0 -414
  6. package/docs/architecture/keychain-broker.md +45 -240
  7. package/docs/architecture/security.md +0 -17
  8. package/docs/credential-execution-service.md +2 -2
  9. package/node_modules/@vellumai/ces-contracts/package.json +1 -0
  10. package/node_modules/@vellumai/ces-contracts/src/rpc.ts +119 -0
  11. package/node_modules/@vellumai/credential-storage/package.json +1 -0
  12. package/node_modules/@vellumai/egress-proxy/package.json +1 -0
  13. package/package.json +2 -3
  14. package/src/__tests__/actor-token-service.test.ts +0 -114
  15. package/src/__tests__/assistant-feature-flags-integration.test.ts +30 -29
  16. package/src/__tests__/browser-skill-endstate.test.ts +6 -5
  17. package/src/__tests__/btw-routes.test.ts +0 -39
  18. package/src/__tests__/call-domain.test.ts +0 -128
  19. package/src/__tests__/ces-rpc-credential-backend.test.ts +199 -0
  20. package/src/__tests__/channel-approval-routes.test.ts +0 -5
  21. package/src/__tests__/channel-readiness-service.test.ts +1 -60
  22. package/src/__tests__/checker.test.ts +4 -2
  23. package/src/__tests__/cli-command-risk-guard.test.ts +112 -0
  24. package/src/__tests__/config-schema-cmd.test.ts +0 -1
  25. package/src/__tests__/config-schema.test.ts +1 -1
  26. package/src/__tests__/conversation-attention-telegram.test.ts +0 -5
  27. package/src/__tests__/conversation-init.benchmark.test.ts +0 -2
  28. package/src/__tests__/conversation-skill-tools.test.ts +0 -54
  29. package/src/__tests__/conversation-title-service.test.ts +87 -0
  30. package/src/__tests__/credential-execution-feature-gates.test.ts +28 -14
  31. package/src/__tests__/credential-execution-managed-contract.test.ts +33 -18
  32. package/src/__tests__/credential-security-e2e.test.ts +0 -66
  33. package/src/__tests__/credential-security-invariants.test.ts +4 -45
  34. package/src/__tests__/credentials-cli.test.ts +78 -0
  35. package/src/__tests__/db-migration-rollback.test.ts +2015 -1
  36. package/src/__tests__/docker-signing-key-bootstrap.test.ts +34 -143
  37. package/src/__tests__/dynamic-skill-workflow-prompt.test.ts +6 -4
  38. package/src/__tests__/guardian-routing-state.test.ts +0 -5
  39. package/src/__tests__/host-shell-tool.test.ts +6 -7
  40. package/src/__tests__/http-user-message-parity.test.ts +3 -103
  41. package/src/__tests__/inbound-invite-redemption.test.ts +0 -4
  42. package/src/__tests__/inline-skill-load-permissions.test.ts +6 -8
  43. package/src/__tests__/intent-routing.test.ts +0 -13
  44. package/src/__tests__/jobs-store-qdrant-breaker.test.ts +178 -0
  45. package/src/__tests__/keychain-broker-client.test.ts +161 -22
  46. package/src/__tests__/memory-jobs-worker-backoff.test.ts +150 -0
  47. package/src/__tests__/migration-export-http.test.ts +2 -2
  48. package/src/__tests__/migration-import-commit-http.test.ts +2 -2
  49. package/src/__tests__/migration-import-preflight-http.test.ts +2 -2
  50. package/src/__tests__/migration-validate-http.test.ts +2 -2
  51. package/src/__tests__/non-member-access-request.test.ts +0 -5
  52. package/src/__tests__/notification-decision-fallback.test.ts +4 -0
  53. package/src/__tests__/notification-decision-identity.test.ts +4 -0
  54. package/src/__tests__/permission-types.test.ts +1 -0
  55. package/src/__tests__/provider-managed-proxy-integration.test.ts +5 -6
  56. package/src/__tests__/qdrant-manager.test.ts +28 -2
  57. package/src/__tests__/registry.test.ts +0 -6
  58. package/src/__tests__/runtime-attachment-metadata.test.ts +0 -4
  59. package/src/__tests__/secret-routes-managed-proxy.test.ts +0 -4
  60. package/src/__tests__/secure-keys.test.ts +83 -263
  61. package/src/__tests__/shell-identity.test.ts +96 -6
  62. package/src/__tests__/skill-feature-flags-integration.test.ts +22 -14
  63. package/src/__tests__/skill-feature-flags.test.ts +46 -45
  64. package/src/__tests__/skill-load-feature-flag.test.ts +7 -10
  65. package/src/__tests__/skill-load-inline-command.test.ts +8 -12
  66. package/src/__tests__/skill-load-inline-includes.test.ts +6 -10
  67. package/src/__tests__/skill-load-tool.test.ts +0 -2
  68. package/src/__tests__/skill-projection-feature-flag.test.ts +33 -29
  69. package/src/__tests__/skills.test.ts +0 -2
  70. package/src/__tests__/slack-inbound-verification.test.ts +0 -4
  71. package/src/__tests__/suggestion-routes.test.ts +1 -32
  72. package/src/__tests__/system-prompt.test.ts +0 -1
  73. package/src/__tests__/tool-executor-shell-integration.test.ts +5 -3
  74. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +0 -5
  75. package/src/__tests__/trusted-contact-multichannel.test.ts +0 -4
  76. package/src/__tests__/update-bulletin.test.ts +0 -2
  77. package/src/__tests__/vellum-self-knowledge-inline-command.test.ts +6 -9
  78. package/src/__tests__/voice-scoped-grant-consumer.test.ts +0 -6
  79. package/src/__tests__/workspace-migration-015-migrate-credentials-to-keychain.test.ts +252 -0
  80. package/src/__tests__/workspace-migration-016-migrate-credentials-from-keychain.test.ts +218 -0
  81. package/src/__tests__/workspace-migration-down-functions.test.ts +1009 -0
  82. package/src/__tests__/workspace-migrations-runner.test.ts +114 -0
  83. package/src/calls/audio-store.test.ts +97 -0
  84. package/src/calls/audio-store.ts +205 -0
  85. package/src/calls/call-controller.ts +85 -7
  86. package/src/calls/call-domain.ts +3 -0
  87. package/src/calls/call-store.ts +10 -3
  88. package/src/calls/fish-audio-client.ts +117 -0
  89. package/src/calls/relay-server.ts +27 -0
  90. package/src/calls/twilio-routes.ts +2 -1
  91. package/src/calls/types.ts +1 -0
  92. package/src/calls/voice-ingress-preflight.ts +0 -42
  93. package/src/calls/voice-quality.ts +26 -5
  94. package/src/calls/voice-session-bridge.ts +6 -12
  95. package/src/cli/commands/config.ts +1 -4
  96. package/src/cli/commands/credentials.ts +34 -4
  97. package/src/cli/commands/oauth/index.ts +7 -0
  98. package/src/cli/commands/oauth/platform.ts +179 -0
  99. package/src/cli/commands/platform.ts +3 -3
  100. package/src/config/assistant-feature-flags.ts +186 -5
  101. package/src/config/bundled-skills/messaging/SKILL.md +5 -5
  102. package/src/config/bundled-skills/phone-calls/TOOLS.json +4 -0
  103. package/src/config/bundled-skills/settings/TOOLS.json +2 -2
  104. package/src/config/bundled-skills/settings/tools/voice-config-update.ts +42 -0
  105. package/src/config/bundled-tool-registry.ts +1 -11
  106. package/src/config/env-registry.ts +1 -1
  107. package/src/config/env.ts +8 -14
  108. package/src/config/feature-flag-registry.json +48 -8
  109. package/src/config/loader.ts +98 -31
  110. package/src/config/schema.ts +4 -13
  111. package/src/config/schemas/calls.ts +13 -0
  112. package/src/config/schemas/fish-audio.ts +39 -0
  113. package/src/config/schemas/security.ts +0 -4
  114. package/src/config/types.ts +0 -1
  115. package/src/contacts/contact-store.ts +39 -0
  116. package/src/contacts/types.ts +2 -0
  117. package/src/credential-execution/approval-bridge.ts +1 -0
  118. package/src/credential-execution/executable-discovery.ts +28 -4
  119. package/src/credential-execution/feature-gates.ts +16 -0
  120. package/src/credential-execution/process-manager.ts +38 -0
  121. package/src/daemon/assistant-attachments.ts +9 -0
  122. package/src/daemon/config-watcher.ts +5 -0
  123. package/src/daemon/conversation-tool-setup.ts +0 -105
  124. package/src/daemon/conversation.ts +10 -1
  125. package/src/daemon/handlers/config-vercel.ts +92 -0
  126. package/src/daemon/handlers/skills.ts +2 -15
  127. package/src/daemon/install-symlink.ts +195 -0
  128. package/src/daemon/lifecycle.ts +227 -51
  129. package/src/daemon/message-types/conversations.ts +3 -4
  130. package/src/daemon/message-types/diagnostics.ts +3 -22
  131. package/src/daemon/message-types/messages.ts +0 -2
  132. package/src/daemon/message-types/upgrades.ts +8 -0
  133. package/src/daemon/server.ts +30 -92
  134. package/src/events/domain-events.ts +2 -1
  135. package/src/inbound/platform-callback-registration.ts +3 -3
  136. package/src/instrument.ts +8 -5
  137. package/src/memory/conversation-title-service.ts +50 -1
  138. package/src/memory/db-init.ts +12 -0
  139. package/src/memory/items-extractor.ts +15 -1
  140. package/src/memory/job-handlers/conversation-starters.ts +4 -1
  141. package/src/memory/jobs-store.ts +30 -5
  142. package/src/memory/jobs-worker.ts +31 -7
  143. package/src/memory/migrations/001-job-deferrals.ts +19 -0
  144. package/src/memory/migrations/004-entity-relation-dedup.ts +10 -0
  145. package/src/memory/migrations/005-fingerprint-scope-unique.ts +76 -0
  146. package/src/memory/migrations/006-scope-salted-fingerprints.ts +50 -0
  147. package/src/memory/migrations/007-assistant-id-to-self.ts +10 -0
  148. package/src/memory/migrations/008-remove-assistant-id-columns.ts +34 -0
  149. package/src/memory/migrations/009-llm-usage-events-drop-assistant-id.ts +26 -0
  150. package/src/memory/migrations/014-backfill-inbox-thread-state.ts +10 -0
  151. package/src/memory/migrations/015-drop-active-search-index.ts +17 -0
  152. package/src/memory/migrations/019-notification-tables-schema-migration.ts +12 -0
  153. package/src/memory/migrations/020-rename-macos-ios-channel-to-vellum.ts +121 -0
  154. package/src/memory/migrations/024-embedding-vector-blob.ts +74 -0
  155. package/src/memory/migrations/026a-embeddings-nullable-vector-json.ts +82 -0
  156. package/src/memory/migrations/036-normalize-phone-identities.ts +11 -0
  157. package/src/memory/migrations/116-messages-fts.ts +106 -1
  158. package/src/memory/migrations/126-backfill-guardian-principal-id.ts +52 -0
  159. package/src/memory/migrations/127-guardian-principal-id-not-null.ts +77 -0
  160. package/src/memory/migrations/134-contacts-notes-column.ts +13 -0
  161. package/src/memory/migrations/135-backfill-contact-interaction-stats.ts +20 -0
  162. package/src/memory/migrations/136-drop-assistant-id-columns.ts +52 -0
  163. package/src/memory/migrations/140-backfill-usage-cache-accounting.ts +13 -0
  164. package/src/memory/migrations/141-rename-verification-table.ts +54 -0
  165. package/src/memory/migrations/142-rename-verification-session-id-column.ts +25 -0
  166. package/src/memory/migrations/143-rename-guardian-verification-values.ts +35 -0
  167. package/src/memory/migrations/144-rename-voice-to-phone.ts +136 -0
  168. package/src/memory/migrations/145-drop-accounts-table.ts +32 -0
  169. package/src/memory/migrations/147-migrate-reminders-to-schedules.ts +14 -1
  170. package/src/memory/migrations/148-drop-reminders-table.ts +35 -1
  171. package/src/memory/migrations/150-oauth-apps-client-secret-path.ts +69 -1
  172. package/src/memory/migrations/162-guardian-timestamps-epoch-ms.ts +290 -0
  173. package/src/memory/migrations/169-rename-gmail-provider-key-to-google.ts +51 -1
  174. package/src/memory/migrations/174-rename-thread-starters-table.ts +47 -1
  175. package/src/memory/migrations/176-drop-capability-card-state.ts +13 -0
  176. package/src/memory/migrations/180-backfill-inline-attachments-to-disk.ts +16 -0
  177. package/src/memory/migrations/181-rename-thread-starters-checkpoints.ts +28 -1
  178. package/src/memory/migrations/190-call-session-skip-disclosure.ts +15 -0
  179. package/src/memory/migrations/191-backfill-audio-attachment-mime-types.ts +64 -0
  180. package/src/memory/migrations/192-contacts-user-file-column.ts +15 -0
  181. package/src/memory/migrations/index.ts +4 -0
  182. package/src/memory/migrations/registry.ts +90 -0
  183. package/src/memory/migrations/validate-migration-state.ts +137 -11
  184. package/src/memory/qdrant-circuit-breaker.ts +9 -0
  185. package/src/memory/qdrant-manager.ts +64 -7
  186. package/src/memory/schema/calls.ts +1 -0
  187. package/src/memory/schema/contacts.ts +1 -0
  188. package/src/notifications/decision-engine.ts +4 -1
  189. package/src/oauth/connection-resolver.ts +6 -4
  190. package/src/permissions/checker.ts +0 -38
  191. package/src/permissions/shell-identity.ts +76 -22
  192. package/src/permissions/types.ts +4 -2
  193. package/src/platform/client.ts +35 -7
  194. package/src/prompts/persona-resolver.ts +138 -0
  195. package/src/prompts/system-prompt.ts +36 -4
  196. package/src/prompts/templates/users/default.md +1 -0
  197. package/src/providers/registry.ts +27 -40
  198. package/src/runtime/auth/__tests__/credential-service.test.ts +0 -1
  199. package/src/runtime/auth/__tests__/external-assistant-id.test.ts +13 -68
  200. package/src/runtime/auth/external-assistant-id.ts +13 -59
  201. package/src/runtime/auth/route-policy.ts +15 -1
  202. package/src/runtime/auth/token-service.ts +43 -138
  203. package/src/runtime/channel-readiness-service.ts +1 -16
  204. package/src/runtime/http-server.ts +27 -2
  205. package/src/runtime/middleware/error-handler.ts +1 -9
  206. package/src/runtime/routes/audio-routes.ts +40 -0
  207. package/src/runtime/routes/btw-routes.ts +0 -17
  208. package/src/runtime/routes/conversation-query-routes.ts +63 -1
  209. package/src/runtime/routes/conversation-routes.ts +4 -44
  210. package/src/runtime/routes/diagnostics-routes.ts +1 -477
  211. package/src/runtime/routes/identity-routes.ts +18 -29
  212. package/src/runtime/routes/inbound-stages/secret-ingress-check.ts +4 -33
  213. package/src/runtime/routes/inbound-stages/transcribe-audio.test.ts +1 -1
  214. package/src/runtime/routes/integrations/vercel.ts +89 -0
  215. package/src/runtime/routes/log-export-routes.ts +5 -0
  216. package/src/runtime/routes/memory-item-routes.ts +24 -6
  217. package/src/runtime/routes/migration-rollback-routes.ts +209 -0
  218. package/src/runtime/routes/migration-routes.ts +17 -1
  219. package/src/runtime/routes/notification-routes.ts +58 -0
  220. package/src/runtime/routes/schedule-routes.ts +65 -0
  221. package/src/runtime/routes/settings-routes.ts +41 -1
  222. package/src/runtime/routes/tts-routes.ts +86 -0
  223. package/src/runtime/routes/upgrade-broadcast-routes.ts +26 -2
  224. package/src/runtime/routes/workspace-commit-routes.ts +62 -0
  225. package/src/runtime/routes/workspace-routes.test.ts +22 -1
  226. package/src/runtime/routes/workspace-routes.ts +1 -1
  227. package/src/runtime/routes/workspace-utils.ts +86 -2
  228. package/src/security/ces-credential-client.ts +59 -22
  229. package/src/security/ces-rpc-credential-backend.ts +85 -0
  230. package/src/security/credential-backend.ts +12 -88
  231. package/src/security/keychain-broker-client.ts +10 -2
  232. package/src/security/secure-keys.ts +94 -113
  233. package/src/skills/catalog-install.ts +13 -7
  234. package/src/telemetry/usage-telemetry-reporter.ts +4 -2
  235. package/src/tools/calls/call-start.ts +1 -0
  236. package/src/tools/executor.ts +0 -4
  237. package/src/tools/network/script-proxy/session-manager.ts +19 -4
  238. package/src/tools/network/web-fetch.ts +3 -1
  239. package/src/tools/skills/execute.ts +1 -1
  240. package/src/tools/types.ts +0 -8
  241. package/src/util/errors.ts +0 -12
  242. package/src/util/platform.ts +3 -50
  243. package/src/workspace/git-service.ts +5 -2
  244. package/src/workspace/migrations/001-avatar-rename.ts +15 -0
  245. package/src/workspace/migrations/003-seed-device-id.ts +17 -1
  246. package/src/workspace/migrations/004-extract-collect-usage-data.ts +33 -0
  247. package/src/workspace/migrations/005-add-send-diagnostics.ts +3 -0
  248. package/src/workspace/migrations/006-services-config.ts +49 -0
  249. package/src/workspace/migrations/007-web-search-provider-rename.ts +27 -0
  250. package/src/workspace/migrations/008-voice-timeout-and-max-steps.ts +3 -0
  251. package/src/workspace/migrations/009-backfill-conversation-disk-view.ts +4 -0
  252. package/src/workspace/migrations/010-app-dir-rename.ts +78 -0
  253. package/src/workspace/migrations/011-backfill-installation-id.ts +11 -0
  254. package/src/workspace/migrations/012-rename-conversation-disk-view-dirs.ts +44 -0
  255. package/src/workspace/migrations/013-repair-conversation-disk-view.ts +5 -0
  256. package/src/workspace/migrations/015-migrate-credentials-to-keychain.ts +153 -0
  257. package/src/workspace/migrations/016-extract-feature-flags-to-protected.ts +156 -0
  258. package/src/workspace/migrations/016-migrate-credentials-from-keychain.ts +150 -0
  259. package/src/workspace/migrations/017-seed-persona-dirs.ts +95 -0
  260. package/src/workspace/migrations/migrate-to-workspace-volume.ts +23 -1
  261. package/src/workspace/migrations/registry.ts +8 -0
  262. package/src/workspace/migrations/runner.ts +106 -2
  263. package/src/workspace/migrations/types.ts +4 -0
  264. package/src/__tests__/claude-code-skill-regression.test.ts +0 -206
  265. package/src/__tests__/claude-code-tool-profiles.test.ts +0 -99
  266. package/src/__tests__/diagnostics-export.test.ts +0 -288
  267. package/src/__tests__/local-gateway-health.test.ts +0 -209
  268. package/src/__tests__/secret-ingress-handler.test.ts +0 -120
  269. package/src/__tests__/swarm-conversation-integration.test.ts +0 -358
  270. package/src/__tests__/swarm-dag-pathological.test.ts +0 -547
  271. package/src/__tests__/swarm-orchestrator.test.ts +0 -463
  272. package/src/__tests__/swarm-plan-validator.test.ts +0 -384
  273. package/src/__tests__/swarm-recursion.test.ts +0 -197
  274. package/src/__tests__/swarm-router-planner.test.ts +0 -234
  275. package/src/__tests__/swarm-tool.test.ts +0 -185
  276. package/src/__tests__/swarm-worker-backend.test.ts +0 -144
  277. package/src/__tests__/swarm-worker-runner.test.ts +0 -288
  278. package/src/commands/__tests__/cc-command-registry.test.ts +0 -396
  279. package/src/commands/cc-command-registry.ts +0 -248
  280. package/src/config/bundled-skills/claude-code/SKILL.md +0 -53
  281. package/src/config/bundled-skills/claude-code/TOOLS.json +0 -47
  282. package/src/config/bundled-skills/claude-code/tools/claude-code.ts +0 -12
  283. package/src/config/bundled-skills/orchestration/SKILL.md +0 -33
  284. package/src/config/bundled-skills/orchestration/TOOLS.json +0 -35
  285. package/src/config/bundled-skills/orchestration/tools/swarm-delegate.ts +0 -12
  286. package/src/config/schemas/swarm.ts +0 -82
  287. package/src/logfire.ts +0 -135
  288. package/src/runtime/local-gateway-health.ts +0 -275
  289. package/src/security/secret-ingress.ts +0 -68
  290. package/src/swarm/backend-claude-code.ts +0 -225
  291. package/src/swarm/checkpoint.ts +0 -137
  292. package/src/swarm/graph-utils.ts +0 -53
  293. package/src/swarm/index.ts +0 -55
  294. package/src/swarm/limits.ts +0 -66
  295. package/src/swarm/orchestrator.ts +0 -424
  296. package/src/swarm/plan-validator.ts +0 -117
  297. package/src/swarm/router-planner.ts +0 -162
  298. package/src/swarm/router-prompts.ts +0 -39
  299. package/src/swarm/synthesizer.ts +0 -81
  300. package/src/swarm/types.ts +0 -72
  301. package/src/swarm/worker-backend.ts +0 -131
  302. package/src/swarm/worker-prompts.ts +0 -80
  303. package/src/swarm/worker-runner.ts +0 -170
  304. package/src/tools/claude-code/claude-code.ts +0 -610
  305. package/src/tools/swarm/delegate.ts +0 -205
@@ -31,3 +31,20 @@ export function migrateDropActiveSearchIndex(database: DrizzleDb): void {
31
31
  throw e;
32
32
  }
33
33
  }
34
+
35
+ /**
36
+ * Recreate the old idx_memory_items_active_search index with its original
37
+ * covering columns (before the migration added status and invalid_at as
38
+ * indexed columns).
39
+ */
40
+ export function downDropActiveSearchIndex(database: DrizzleDb): void {
41
+ const raw = getSqliteFrom(database);
42
+
43
+ // Drop the current index if it exists, then recreate with the old column set.
44
+ raw.exec(/*sql*/ `DROP INDEX IF EXISTS idx_memory_items_active_search`);
45
+ raw.exec(/*sql*/ `
46
+ CREATE INDEX IF NOT EXISTS idx_memory_items_active_search
47
+ ON memory_items(last_seen_at DESC, subject, statement, id, kind, confidence, importance, first_seen_at, scope_id)
48
+ WHERE status = 'active' AND invalid_at IS NULL
49
+ `);
50
+ }
@@ -82,3 +82,15 @@ export function migrateNotificationTablesSchema(database: DrizzleDb): void {
82
82
  }
83
83
  }
84
84
  }
85
+
86
+ /**
87
+ * No-op down: the old enum-based notification tables cannot be recreated
88
+ * without the original schema definitions (column names, types, constraints,
89
+ * and enum values). The forward migration dropped these tables entirely.
90
+ * Any data that was in them is permanently lost. The new signal-contract
91
+ * schema tables are structurally incompatible with the old enum-based ones.
92
+ */
93
+ export function downNotificationTablesSchema(_database: DrizzleDb): void {
94
+ // Intentionally empty — old enum-based tables cannot be recreated without
95
+ // the original schema, and any data they contained is permanently lost.
96
+ }
@@ -130,3 +130,124 @@ export function migrateRenameChannelToVellum(database: DrizzleDb): void {
130
130
  throw e;
131
131
  }
132
132
  }
133
+
134
+ /**
135
+ * Reverse the channel rename by changing "vellum" back to "macos" in all tables.
136
+ *
137
+ * NOTE: The forward migration renamed both "macos" and "ios" to "vellum", so we
138
+ * cannot distinguish which rows were originally "ios". This down migration
139
+ * conservatively maps all "vellum" values back to "macos" since that was the
140
+ * primary desktop channel identifier.
141
+ */
142
+ export function downRenameChannelToVellum(database: DrizzleDb): void {
143
+ const raw = getSqliteFrom(database);
144
+
145
+ try {
146
+ raw.exec("BEGIN");
147
+
148
+ // guardian_action_deliveries.destination_channel
149
+ const gadExists = raw
150
+ .query(
151
+ `SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'guardian_action_deliveries'`,
152
+ )
153
+ .get();
154
+ if (gadExists) {
155
+ raw
156
+ .query(
157
+ `UPDATE guardian_action_deliveries SET destination_channel = 'macos' WHERE destination_channel = 'vellum'`,
158
+ )
159
+ .run();
160
+ }
161
+
162
+ // messages.user_message_channel / assistant_message_channel
163
+ const msgsExists = raw
164
+ .query(
165
+ `SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'messages'`,
166
+ )
167
+ .get();
168
+ if (msgsExists) {
169
+ const hasUserMsgChannel = raw
170
+ .query(
171
+ `SELECT 1 FROM pragma_table_info('messages') WHERE name = 'user_message_channel'`,
172
+ )
173
+ .get();
174
+ if (hasUserMsgChannel) {
175
+ raw
176
+ .query(
177
+ `UPDATE messages SET user_message_channel = 'macos' WHERE user_message_channel = 'vellum'`,
178
+ )
179
+ .run();
180
+ }
181
+ const hasAssistantMsgChannel = raw
182
+ .query(
183
+ `SELECT 1 FROM pragma_table_info('messages') WHERE name = 'assistant_message_channel'`,
184
+ )
185
+ .get();
186
+ if (hasAssistantMsgChannel) {
187
+ raw
188
+ .query(
189
+ `UPDATE messages SET assistant_message_channel = 'macos' WHERE assistant_message_channel = 'vellum'`,
190
+ )
191
+ .run();
192
+ }
193
+ }
194
+
195
+ // external_conversation_bindings.source_channel
196
+ const ecbExists = raw
197
+ .query(
198
+ `SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'external_conversation_bindings'`,
199
+ )
200
+ .get();
201
+ if (ecbExists) {
202
+ raw
203
+ .query(
204
+ `UPDATE external_conversation_bindings SET source_channel = 'macos' WHERE source_channel = 'vellum'`,
205
+ )
206
+ .run();
207
+ }
208
+
209
+ // assistant_inbox_thread_state.source_channel
210
+ const aitsExists = raw
211
+ .query(
212
+ `SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'assistant_inbox_thread_state'`,
213
+ )
214
+ .get();
215
+ if (aitsExists) {
216
+ raw
217
+ .query(
218
+ `UPDATE assistant_inbox_thread_state SET source_channel = 'macos' WHERE source_channel = 'vellum'`,
219
+ )
220
+ .run();
221
+ }
222
+
223
+ // conversations.origin_channel
224
+ const convExists = raw
225
+ .query(
226
+ `SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'conversations'`,
227
+ )
228
+ .get();
229
+ if (convExists) {
230
+ const hasOriginChannel = raw
231
+ .query(
232
+ `SELECT 1 FROM pragma_table_info('conversations') WHERE name = 'origin_channel'`,
233
+ )
234
+ .get();
235
+ if (hasOriginChannel) {
236
+ raw
237
+ .query(
238
+ `UPDATE conversations SET origin_channel = 'macos' WHERE origin_channel = 'vellum'`,
239
+ )
240
+ .run();
241
+ }
242
+ }
243
+
244
+ raw.exec("COMMIT");
245
+ } catch (e) {
246
+ try {
247
+ raw.exec("ROLLBACK");
248
+ } catch {
249
+ /* no active transaction */
250
+ }
251
+ throw e;
252
+ }
253
+ }
@@ -71,3 +71,77 @@ export function migrateEmbeddingVectorBlob(database: DrizzleDb): void {
71
71
  .run(checkpointKey, Date.now());
72
72
  }
73
73
  }
74
+
75
+ /**
76
+ * Drop the vector_blob column from memory_embeddings.
77
+ *
78
+ * NOTE: Binary embedding data stored in vector_blob is lost on rollback.
79
+ * Rows that still have vector_json will continue to work; rows that only
80
+ * had vector_blob will lose their embedding vectors.
81
+ *
82
+ * SQLite does not support DROP COLUMN on all versions, so we rebuild the table.
83
+ */
84
+ export function downEmbeddingVectorBlob(database: DrizzleDb): void {
85
+ const raw = getSqliteFrom(database);
86
+
87
+ // Check if vector_blob column exists
88
+ const hasColumn = raw
89
+ .query(
90
+ `SELECT 1 FROM pragma_table_info('memory_embeddings') WHERE name = 'vector_blob'`,
91
+ )
92
+ .get();
93
+ if (!hasColumn) return;
94
+
95
+ raw.exec("PRAGMA foreign_keys = OFF");
96
+ try {
97
+ raw.exec("BEGIN");
98
+
99
+ // Get the current columns minus vector_blob
100
+ const columns = raw
101
+ .query(`SELECT name FROM pragma_table_info('memory_embeddings')`)
102
+ .all() as Array<{ name: string }>;
103
+ const keepColumns = columns
104
+ .map((c) => c.name)
105
+ .filter((n) => n !== "vector_blob");
106
+
107
+ // Get the current DDL to understand the table structure
108
+ const ddl = raw
109
+ .query(
110
+ `SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'memory_embeddings'`,
111
+ )
112
+ .get() as { sql: string } | null;
113
+ if (!ddl) {
114
+ raw.exec("ROLLBACK");
115
+ return;
116
+ }
117
+
118
+ // Remove the vector_blob column definition from the DDL
119
+ const newDdl = ddl.sql
120
+ .replace(/,\s*vector_blob\s+BLOB/i, "")
121
+ .replace("memory_embeddings", "memory_embeddings_new");
122
+
123
+ raw.exec(newDdl);
124
+
125
+ const colList = keepColumns.join(", ");
126
+ raw.exec(/*sql*/ `
127
+ INSERT INTO memory_embeddings_new (${colList})
128
+ SELECT ${colList} FROM memory_embeddings
129
+ `);
130
+
131
+ raw.exec(/*sql*/ `DROP TABLE memory_embeddings`);
132
+ raw.exec(
133
+ /*sql*/ `ALTER TABLE memory_embeddings_new RENAME TO memory_embeddings`,
134
+ );
135
+
136
+ raw.exec("COMMIT");
137
+ } catch (e) {
138
+ try {
139
+ raw.exec("ROLLBACK");
140
+ } catch {
141
+ /* no active transaction */
142
+ }
143
+ throw e;
144
+ } finally {
145
+ raw.exec("PRAGMA foreign_keys = ON");
146
+ }
147
+ }
@@ -105,6 +105,88 @@ export function migrateEmbeddingsNullableVectorJson(database: DrizzleDb): void {
105
105
  }
106
106
  }
107
107
 
108
+ /**
109
+ * Reverse v13: rebuild memory_embeddings with NOT NULL on vector_json.
110
+ *
111
+ * WARNING: Any rows with NULL vector_json will be lost — they cannot satisfy
112
+ * the NOT NULL constraint. This is acceptable because the forward migration
113
+ * only relaxed the constraint; rows written after the forward migration may
114
+ * have NULL vector_json (relying on vector_blob instead).
115
+ */
116
+ export function downEmbeddingsNullableVectorJson(database: DrizzleDb): void {
117
+ const raw = getSqliteFrom(database);
118
+
119
+ const tableExists = raw
120
+ .query(
121
+ `SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'memory_embeddings'`,
122
+ )
123
+ .get();
124
+ if (!tableExists) return;
125
+
126
+ // Check if vector_json already has NOT NULL — already rolled back
127
+ const ddl = raw
128
+ .query(
129
+ `SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'memory_embeddings'`,
130
+ )
131
+ .get() as { sql: string } | null;
132
+ if (ddl && isColumnNotNull(ddl.sql, "vector_json")) return;
133
+
134
+ raw.exec("PRAGMA foreign_keys = OFF");
135
+ try {
136
+ raw.exec("BEGIN");
137
+
138
+ raw.exec(/*sql*/ `
139
+ CREATE TABLE memory_embeddings_new (
140
+ id TEXT PRIMARY KEY,
141
+ target_type TEXT NOT NULL,
142
+ target_id TEXT NOT NULL,
143
+ provider TEXT NOT NULL,
144
+ model TEXT NOT NULL,
145
+ dimensions INTEGER NOT NULL,
146
+ vector_json TEXT NOT NULL,
147
+ vector_blob BLOB,
148
+ content_hash TEXT,
149
+ created_at INTEGER NOT NULL,
150
+ updated_at INTEGER NOT NULL,
151
+ UNIQUE (target_type, target_id, provider, model)
152
+ )
153
+ `);
154
+ // Only copy rows where vector_json is NOT NULL — rows with NULL cannot
155
+ // satisfy the restored constraint and are lost.
156
+ raw.exec(/*sql*/ `
157
+ INSERT OR IGNORE INTO memory_embeddings_new (
158
+ id, target_type, target_id, provider, model, dimensions,
159
+ vector_json, vector_blob, content_hash, created_at, updated_at
160
+ )
161
+ SELECT
162
+ id, target_type, target_id, provider, model, dimensions,
163
+ vector_json, vector_blob, content_hash, created_at, updated_at
164
+ FROM memory_embeddings
165
+ WHERE vector_json IS NOT NULL
166
+ ORDER BY updated_at DESC
167
+ `);
168
+ raw.exec(/*sql*/ `DROP TABLE memory_embeddings`);
169
+ raw.exec(
170
+ /*sql*/ `ALTER TABLE memory_embeddings_new RENAME TO memory_embeddings`,
171
+ );
172
+
173
+ raw.exec(
174
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_memory_embeddings_content_hash ON memory_embeddings(content_hash, provider, model)`,
175
+ );
176
+
177
+ raw.exec("COMMIT");
178
+ } catch (e) {
179
+ try {
180
+ raw.exec("ROLLBACK");
181
+ } catch {
182
+ /* no active transaction */
183
+ }
184
+ throw e;
185
+ } finally {
186
+ raw.exec("PRAGMA foreign_keys = ON");
187
+ }
188
+ }
189
+
108
190
  /** Check whether a column is declared NOT NULL in a CREATE TABLE DDL string. */
109
191
  function isColumnNotNull(ddl: string, column: string): boolean {
110
192
  const pattern = new RegExp(`${column}\\s+\\w+.*?NOT\\s+NULL`, "i");
@@ -336,3 +336,14 @@ export function migrateNormalizePhoneIdentities(database: DrizzleDb): void {
336
336
  throw e;
337
337
  }
338
338
  }
339
+
340
+ /**
341
+ * Reverse v14: no-op — original non-E.164 phone formats are not recoverable.
342
+ *
343
+ * The forward migration normalised phone numbers to E.164. The original
344
+ * formatting (parentheses, dashes, spaces, country-code variants) was
345
+ * discarded during normalisation and cannot be reconstructed.
346
+ */
347
+ export function downNormalizePhoneIdentities(_database: DrizzleDb): void {
348
+ // Lossy — original phone formats are not recoverable.
349
+ }
@@ -1,4 +1,7 @@
1
- import type { DrizzleDb } from "../db-connection.js";
1
+ import { getLogger } from "../../util/logger.js";
2
+ import { type DrizzleDb, getSqliteFrom } from "../db-connection.js";
3
+
4
+ const logger = getLogger("messages-fts");
2
5
 
3
6
  /**
4
7
  * FTS5 virtual table for full-text search over messages.content.
@@ -21,7 +24,81 @@ import type { DrizzleDb } from "../db-connection.js";
21
24
  * ALL writes to the messages table to fail until the FTS table is rebuilt.
22
25
  * If this happens, `messages_fts` should be dropped and recreated, then
23
26
  * backfilled via `migrateMessagesFtsBackfill`.
27
+ *
28
+ * ## Auto-recovery from corruption
29
+ *
30
+ * After creating (or finding an existing) messages_fts table, we probe it
31
+ * with a lightweight MATCH query that exercises the FTS inverted index
32
+ * in O(1). If the probe throws SQLITE_CORRUPT_VTAB or SQLITE_CORRUPT,
33
+ * we force-remove all shadow tables and the vtable entry (falling back
34
+ * to `PRAGMA writable_schema` if DROP TABLE itself fails on the corrupt
35
+ * vtable) and recreate it from scratch. The subsequent
36
+ * `migrateMessagesFtsBackfill` call in db-init.ts will repopulate the
37
+ * index from the messages table — no message data is lost.
38
+ */
39
+ function isSqliteCorruptionError(err: unknown): boolean {
40
+ const code =
41
+ err != null && typeof err === "object" && "code" in err
42
+ ? (err as { code: string }).code
43
+ : undefined;
44
+ return code === "SQLITE_CORRUPT_VTAB" || code === "SQLITE_CORRUPT";
45
+ }
46
+
47
+ /**
48
+ * Force-remove all FTS5 shadow tables, triggers, and the vtable entry.
49
+ *
50
+ * We drop each artifact individually so that a corrupt shadow table
51
+ * doesn't block cleanup of the others. If `DROP TABLE messages_fts`
52
+ * itself fails (FTS5's xDestroy hits a corrupt shadow table), we fall
53
+ * back to `PRAGMA writable_schema` to delete the vtable entry directly
54
+ * from `sqlite_schema`. Without this fallback, `CREATE VIRTUAL TABLE
55
+ * IF NOT EXISTS` would be a no-op and the crash loop would persist.
24
56
  */
57
+ function dropFtsShadowTables(raw: ReturnType<typeof getSqliteFrom>): void {
58
+ const drops = [
59
+ `DROP TRIGGER IF EXISTS messages_fts_ai`,
60
+ `DROP TRIGGER IF EXISTS messages_fts_ad`,
61
+ `DROP TRIGGER IF EXISTS messages_fts_au`,
62
+ `DROP TABLE IF EXISTS messages_fts_config`,
63
+ `DROP TABLE IF EXISTS messages_fts_docsize`,
64
+ `DROP TABLE IF EXISTS messages_fts_content`,
65
+ `DROP TABLE IF EXISTS messages_fts_idx`,
66
+ `DROP TABLE IF EXISTS messages_fts_data`,
67
+ ];
68
+ for (const sql of drops) {
69
+ try {
70
+ raw.exec(sql);
71
+ } catch {
72
+ // Shadow table may itself be corrupt — ignore and continue
73
+ }
74
+ }
75
+
76
+ // Try the normal DROP TABLE path first (lets FTS5 clean up properly).
77
+ try {
78
+ raw.exec(`DROP TABLE IF EXISTS messages_fts`);
79
+ } catch {
80
+ // FTS5's xDestroy failed — force-remove the vtable entry from
81
+ // sqlite_schema so CREATE VIRTUAL TABLE isn't a no-op.
82
+ logger.warn(
83
+ "[messages-fts] DROP TABLE messages_fts failed — removing vtable entry via writable_schema",
84
+ );
85
+ raw.exec(`PRAGMA writable_schema = ON`);
86
+ try {
87
+ raw.exec(
88
+ `DELETE FROM sqlite_schema WHERE type = 'table' AND name = 'messages_fts'`,
89
+ );
90
+ } catch (schemaErr) {
91
+ logger.error(
92
+ { err: schemaErr },
93
+ "[messages-fts] Failed to remove vtable entry from sqlite_schema",
94
+ );
95
+ throw schemaErr;
96
+ } finally {
97
+ raw.exec(`PRAGMA writable_schema = OFF`);
98
+ }
99
+ }
100
+ }
101
+
25
102
  export function createMessagesFts(database: DrizzleDb): void {
26
103
  database.run(/*sql*/ `
27
104
  CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
@@ -30,6 +107,34 @@ export function createMessagesFts(database: DrizzleDb): void {
30
107
  )
31
108
  `);
32
109
 
110
+ // Probe the FTS inverted index for corruption. A MATCH query exercises
111
+ // the index structures (not just the content store), so it catches
112
+ // corruption in shadow tables like _idx and _data. On empty tables
113
+ // this returns null gracefully. O(1) with LIMIT 1.
114
+ const raw = getSqliteFrom(database);
115
+ try {
116
+ raw
117
+ .query(`SELECT * FROM messages_fts WHERE messages_fts MATCH 'a' LIMIT 1`)
118
+ .get();
119
+ } catch (err: unknown) {
120
+ if (!isSqliteCorruptionError(err)) {
121
+ throw err;
122
+ }
123
+ logger.warn(
124
+ { err },
125
+ "[messages-fts] Detected corrupt messages_fts virtual table — dropping and recreating",
126
+ );
127
+ // DROP TABLE on a corrupt vtable can itself throw, so drop the
128
+ // FTS5 shadow tables directly to guarantee cleanup.
129
+ dropFtsShadowTables(raw);
130
+ database.run(/*sql*/ `
131
+ CREATE VIRTUAL TABLE messages_fts USING fts5(
132
+ message_id UNINDEXED,
133
+ content
134
+ )
135
+ `);
136
+ }
137
+
33
138
  database.run(/*sql*/ `
34
139
  CREATE TRIGGER IF NOT EXISTS messages_fts_ai
35
140
  AFTER INSERT ON messages
@@ -1,6 +1,58 @@
1
1
  import { type DrizzleDb, getSqliteFrom } from "../db-connection.js";
2
2
  import { withCrashRecovery } from "./validate-migration-state.js";
3
3
 
4
+ /**
5
+ * Reverse v15: set guardian_principal_id back to NULL on all rows in
6
+ * channel_guardian_bindings and canonical_guardian_requests.
7
+ *
8
+ * Also un-expires requests that the forward migration expired (sets them
9
+ * back to 'pending'). This is a best-effort reversal — the original status
10
+ * of expired requests cannot be perfectly reconstructed if they were already
11
+ * expired before the forward migration ran, but the forward migration only
12
+ * expired requests that had NULL guardian_principal_id and status = 'pending'.
13
+ */
14
+ export function downBackfillGuardianPrincipalId(database: DrizzleDb): void {
15
+ const raw = getSqliteFrom(database);
16
+
17
+ // Null out guardian_principal_id on channel_guardian_bindings
18
+ const bindingsExists = raw
19
+ .query(
20
+ `SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'channel_guardian_bindings'`,
21
+ )
22
+ .get();
23
+ if (bindingsExists) {
24
+ const colExists = raw
25
+ .query(
26
+ `SELECT 1 FROM pragma_table_info('channel_guardian_bindings') WHERE name = 'guardian_principal_id'`,
27
+ )
28
+ .get();
29
+ if (colExists) {
30
+ raw.exec(
31
+ /*sql*/ `UPDATE channel_guardian_bindings SET guardian_principal_id = NULL`,
32
+ );
33
+ }
34
+ }
35
+
36
+ // Null out guardian_principal_id on canonical_guardian_requests
37
+ const requestsExists = raw
38
+ .query(
39
+ `SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'canonical_guardian_requests'`,
40
+ )
41
+ .get();
42
+ if (requestsExists) {
43
+ const colExists = raw
44
+ .query(
45
+ `SELECT 1 FROM pragma_table_info('canonical_guardian_requests') WHERE name = 'guardian_principal_id'`,
46
+ )
47
+ .get();
48
+ if (colExists) {
49
+ raw.exec(
50
+ /*sql*/ `UPDATE canonical_guardian_requests SET guardian_principal_id = NULL`,
51
+ );
52
+ }
53
+ }
54
+ }
55
+
4
56
  /**
5
57
  * Backfill guardianPrincipalId for existing channel_guardian_bindings and
6
58
  * canonical_guardian_requests rows.
@@ -1,6 +1,83 @@
1
1
  import { type DrizzleDb, getSqliteFrom } from "../db-connection.js";
2
2
  import { withCrashRecovery } from "./validate-migration-state.js";
3
3
 
4
+ /**
5
+ * Reverse v16: rebuild channel_guardian_bindings to make guardian_principal_id
6
+ * nullable again (removing the NOT NULL constraint added by the forward migration).
7
+ */
8
+ export function downGuardianPrincipalIdNotNull(database: DrizzleDb): void {
9
+ const raw = getSqliteFrom(database);
10
+
11
+ const tableExists = raw
12
+ .query(
13
+ `SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'channel_guardian_bindings'`,
14
+ )
15
+ .get();
16
+ if (!tableExists) return;
17
+
18
+ // Check if guardian_principal_id has NOT NULL — if not, already rolled back
19
+ const colInfo = raw
20
+ .query(
21
+ `SELECT "notnull" FROM pragma_table_info('channel_guardian_bindings') WHERE name = 'guardian_principal_id'`,
22
+ )
23
+ .get() as { notnull: number } | null;
24
+ if (!colInfo || colInfo.notnull === 0) return;
25
+
26
+ raw.exec("PRAGMA foreign_keys = OFF");
27
+ try {
28
+ raw.exec("BEGIN");
29
+
30
+ raw.exec(/*sql*/ `
31
+ CREATE TABLE channel_guardian_bindings_new (
32
+ id TEXT PRIMARY KEY,
33
+ assistant_id TEXT NOT NULL,
34
+ channel TEXT NOT NULL,
35
+ guardian_external_user_id TEXT NOT NULL,
36
+ guardian_delivery_chat_id TEXT NOT NULL,
37
+ guardian_principal_id TEXT,
38
+ status TEXT NOT NULL DEFAULT 'active',
39
+ verified_at INTEGER NOT NULL,
40
+ verified_via TEXT NOT NULL DEFAULT 'challenge',
41
+ metadata_json TEXT,
42
+ created_at INTEGER NOT NULL,
43
+ updated_at INTEGER NOT NULL
44
+ )
45
+ `);
46
+
47
+ raw.exec(/*sql*/ `
48
+ INSERT INTO channel_guardian_bindings_new
49
+ SELECT id, assistant_id, channel, guardian_external_user_id,
50
+ guardian_delivery_chat_id, guardian_principal_id,
51
+ status, verified_at, verified_via, metadata_json,
52
+ created_at, updated_at
53
+ FROM channel_guardian_bindings
54
+ `);
55
+
56
+ raw.exec(/*sql*/ `DROP TABLE channel_guardian_bindings`);
57
+ raw.exec(
58
+ /*sql*/ `ALTER TABLE channel_guardian_bindings_new RENAME TO channel_guardian_bindings`,
59
+ );
60
+
61
+ // Recreate the unique index for active bindings
62
+ raw.exec(/*sql*/ `
63
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_channel_guardian_bindings_active
64
+ ON channel_guardian_bindings(assistant_id, channel)
65
+ WHERE status = 'active'
66
+ `);
67
+
68
+ raw.exec("COMMIT");
69
+ } catch (e) {
70
+ try {
71
+ raw.exec("ROLLBACK");
72
+ } catch {
73
+ /* no active transaction */
74
+ }
75
+ throw e;
76
+ } finally {
77
+ raw.exec("PRAGMA foreign_keys = ON");
78
+ }
79
+ }
80
+
4
81
  /**
5
82
  * Enforce NOT NULL on channel_guardian_bindings.guardian_principal_id.
6
83
  *
@@ -2,6 +2,19 @@ import { getLogger } from "../../util/logger.js";
2
2
  import { type DrizzleDb, getSqliteFrom } from "../db-connection.js";
3
3
  import { withCrashRecovery } from "./validate-migration-state.js";
4
4
 
5
+ /**
6
+ * Reverse v17: no-op — the original separate columns (relationship, importance,
7
+ * response_expectation, preferred_tone) cannot be reliably restored from the
8
+ * consolidated notes TEXT column.
9
+ *
10
+ * The forward migration concatenated multiple typed fields into a single
11
+ * free-text notes field and then dropped the original columns. Parsing the
12
+ * notes back into structured fields would be lossy and error-prone.
13
+ */
14
+ export function downContactsNotesColumn(_database: DrizzleDb): void {
15
+ // Lossy — original structured columns cannot be restored from notes text.
16
+ }
17
+
5
18
  const log = getLogger("migration-134");
6
19
 
7
20
  export function migrateContactsNotesColumn(database: DrizzleDb): void {
@@ -1,6 +1,26 @@
1
1
  import { type DrizzleDb, getSqliteFrom } from "../db-connection.js";
2
2
  import { withCrashRecovery } from "./validate-migration-state.js";
3
3
 
4
+ /**
5
+ * Reverse v18: set contacts.last_interaction back to NULL.
6
+ *
7
+ * The forward migration backfilled last_interaction from channel data.
8
+ * Rolling back simply clears the column — the data can be re-derived by
9
+ * re-running the forward migration.
10
+ */
11
+ export function downBackfillContactInteractionStats(database: DrizzleDb): void {
12
+ const raw = getSqliteFrom(database);
13
+
14
+ const colExists = raw
15
+ .query(
16
+ `SELECT 1 FROM pragma_table_info('contacts') WHERE name = 'last_interaction'`,
17
+ )
18
+ .get();
19
+ if (!colExists) return;
20
+
21
+ raw.exec(/*sql*/ `UPDATE contacts SET last_interaction = NULL`);
22
+ }
23
+
4
24
  /**
5
25
  * Backfill contacts.last_interaction from the max lastSeenAt across each
6
26
  * contact's channels. interactionCount cannot be reliably derived from