toolroll 0.9.53 → 0.9.55

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 (254) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +5 -4
  3. package/dist/agent-onboard.js +4 -1
  4. package/dist/approval-rules-ui.d.ts +3 -2
  5. package/dist/approval-rules-ui.js +15 -20
  6. package/dist/assignment-ui.d.ts +3 -2
  7. package/dist/assignment-ui.js +4 -11
  8. package/dist/backup-ui.d.ts +2 -1
  9. package/dist/backup-ui.js +16 -27
  10. package/dist/brand-mark.d.ts +3 -2
  11. package/dist/brand-mark.js +4 -4
  12. package/dist/browser/workspace.css +1 -1
  13. package/dist/browser/workspace.js +25 -25
  14. package/dist/browser-shell.d.ts +2 -1
  15. package/dist/browser-shell.js +9 -9
  16. package/dist/browser-workspace.d.ts +2 -3
  17. package/dist/browser-workspace.js +0 -3
  18. package/dist/chat-ask.js +8 -8
  19. package/dist/chat-core.d.ts +171 -0
  20. package/dist/chat-core.js +288 -0
  21. package/dist/chat-decide.d.ts +10 -8
  22. package/dist/chat-decide.js +26 -21
  23. package/dist/chat-delivery-state.d.ts +51 -75
  24. package/dist/chat-delivery-state.js +140 -170
  25. package/dist/chat-delivery.d.ts +21 -2
  26. package/dist/chat-delivery.js +313 -194
  27. package/dist/chat-flow.d.ts +29 -1
  28. package/dist/chat-flow.js +24 -50
  29. package/dist/chat-messages.d.ts +319 -0
  30. package/dist/chat-messages.js +841 -0
  31. package/dist/chat-migration.d.ts +30 -0
  32. package/dist/chat-migration.js +802 -0
  33. package/dist/chat-polish.d.ts +5 -4
  34. package/dist/chat-polish.js +9 -10
  35. package/dist/chat-quiet.d.ts +3 -0
  36. package/dist/chat-quiet.js +5 -0
  37. package/dist/check-levels-ui.d.ts +2 -1
  38. package/dist/check-levels-ui.js +26 -53
  39. package/dist/cli.d.ts +3 -1
  40. package/dist/cli.js +9 -3
  41. package/dist/coding-shipping-ui.d.ts +2 -1
  42. package/dist/coding-shipping-ui.js +6 -6
  43. package/dist/coding-ui.d.ts +3 -1
  44. package/dist/coding-ui.js +30 -29
  45. package/dist/contracts/chat-content.d.ts +71 -2
  46. package/dist/contracts/chat-content.js +22 -3
  47. package/dist/contracts/chat-tables.d.ts +11 -4
  48. package/dist/contracts/chat-tables.js +165 -87
  49. package/dist/contracts/cli.d.ts +1 -3
  50. package/dist/contracts/console-api.d.ts +1 -1
  51. package/dist/contracts/console-api.js +6 -5
  52. package/dist/contracts/discord-callback.js +2 -1
  53. package/dist/contracts/health.d.ts +1 -3
  54. package/dist/contracts/slack-callback.js +3 -2
  55. package/dist/contracts/store-json.d.ts +1 -5
  56. package/dist/contracts/store-json.js +1 -4
  57. package/dist/contracts/teams-callback.js +2 -1
  58. package/dist/control-ui.d.ts +5 -4
  59. package/dist/control-ui.js +9 -34
  60. package/dist/credentials-ui.d.ts +5 -4
  61. package/dist/credentials-ui.js +23 -24
  62. package/dist/demo-chat.d.ts +3 -3
  63. package/dist/demo-chat.js +29 -76
  64. package/dist/discord-chat.js +19 -38
  65. package/dist/discord-settings.d.ts +3 -2
  66. package/dist/discord-settings.js +21 -55
  67. package/dist/discord.js +4 -14
  68. package/dist/envelope.js +10 -0
  69. package/dist/evidence-pack.d.ts +2 -1
  70. package/dist/evidence-pack.js +45 -49
  71. package/dist/export-ui.d.ts +7 -2
  72. package/dist/export-ui.js +4 -10
  73. package/dist/export.js +4 -4
  74. package/dist/flow-gallery-ui.d.ts +4 -3
  75. package/dist/flow-gallery-ui.js +28 -43
  76. package/dist/flow-starters-ui.d.ts +2 -1
  77. package/dist/flow-starters-ui.js +12 -15
  78. package/dist/flow-triggers.d.ts +1 -0
  79. package/dist/flow-triggers.js +16 -14
  80. package/dist/flows-ui.d.ts +4 -3
  81. package/dist/flows-ui.js +19 -30
  82. package/dist/flows.js +2 -1
  83. package/dist/guides.js +108 -3
  84. package/dist/html.d.ts +76 -0
  85. package/dist/html.js +173 -0
  86. package/dist/integrations-ui.d.ts +2 -1
  87. package/dist/integrations-ui.js +19 -23
  88. package/dist/kits-ui.d.ts +5 -4
  89. package/dist/kits-ui.js +18 -20
  90. package/dist/knowledge-ui.d.ts +6 -5
  91. package/dist/knowledge-ui.js +26 -25
  92. package/dist/lead-context.d.ts +11 -1
  93. package/dist/lead-context.js +6 -8
  94. package/dist/ledger-view.d.ts +2 -1
  95. package/dist/ledger-view.js +27 -29
  96. package/dist/limits-ui.d.ts +2 -1
  97. package/dist/limits-ui.js +7 -7
  98. package/dist/live-bus.d.ts +50 -8
  99. package/dist/live-bus.js +133 -50
  100. package/dist/mate-tools.js +3 -2
  101. package/dist/mcp-oauth.js +35 -36
  102. package/dist/models-ui.d.ts +2 -1
  103. package/dist/models-ui.js +25 -35
  104. package/dist/modes.d.ts +13 -1
  105. package/dist/modes.js +17 -1
  106. package/dist/monitoring-ui.d.ts +4 -3
  107. package/dist/monitoring-ui.js +13 -26
  108. package/dist/notification-rows.d.ts +30 -0
  109. package/dist/notification-rows.js +102 -0
  110. package/dist/openrouter-models.d.ts +2 -1
  111. package/dist/openrouter-models.js +5 -9
  112. package/dist/operate.js +16 -9
  113. package/dist/people-audit-ui.d.ts +2 -1
  114. package/dist/people-audit-ui.js +13 -13
  115. package/dist/plane-review.js +5 -11
  116. package/dist/policy-ui.d.ts +3 -2
  117. package/dist/policy-ui.js +17 -23
  118. package/dist/project-delete-ui.d.ts +4 -3
  119. package/dist/project-delete-ui.js +15 -27
  120. package/dist/project-delete.js +21 -26
  121. package/dist/pull-request-ui.d.ts +2 -1
  122. package/dist/pull-request-ui.js +17 -32
  123. package/dist/recipe-ui.d.ts +8 -7
  124. package/dist/recipe-ui.js +39 -32
  125. package/dist/release-adapters.d.ts +60 -0
  126. package/dist/release-adapters.js +435 -0
  127. package/dist/release-cli.d.ts +24 -0
  128. package/dist/release-cli.js +181 -0
  129. package/dist/release-gate.d.ts +80 -0
  130. package/dist/release-gate.js +150 -0
  131. package/dist/release-links.d.ts +29 -0
  132. package/dist/release-links.js +102 -0
  133. package/dist/release.d.ts +331 -0
  134. package/dist/release.js +550 -0
  135. package/dist/reply-shape.d.ts +2 -1
  136. package/dist/reply-shape.js +5 -5
  137. package/dist/repository-context-ui.d.ts +2 -1
  138. package/dist/repository-context-ui.js +2 -7
  139. package/dist/request-budget-ui.d.ts +2 -1
  140. package/dist/request-budget-ui.js +16 -16
  141. package/dist/result-review.d.ts +2 -3
  142. package/dist/result-review.js +3 -9
  143. package/dist/result-shots.js +2 -2
  144. package/dist/retention-ui.d.ts +7 -1
  145. package/dist/retention-ui.js +7 -11
  146. package/dist/retention.js +1 -1
  147. package/dist/serve.d.ts +11 -2
  148. package/dist/serve.js +69 -58
  149. package/dist/server/chat.d.ts +3 -6
  150. package/dist/server/chat.js +969 -1006
  151. package/dist/server/chrome.d.ts +291 -0
  152. package/dist/server/chrome.js +747 -0
  153. package/dist/server/console.css +2819 -0
  154. package/dist/server/flows.d.ts +2 -6
  155. package/dist/server/flows.js +840 -766
  156. package/dist/server/guards.d.ts +1 -1
  157. package/dist/server/guards.js +5 -2
  158. package/dist/server/handler-context.d.ts +1 -1
  159. package/dist/server/handler-context.js +1 -1
  160. package/dist/server/handler-registry.d.ts +8 -6
  161. package/dist/server/handler-registry.js +14 -2
  162. package/dist/server/http.d.ts +274 -0
  163. package/dist/server/http.js +239 -0
  164. package/dist/server/live.d.ts +34 -0
  165. package/dist/server/live.js +309 -0
  166. package/dist/server/pages.d.ts +2 -6
  167. package/dist/server/pages.js +808 -789
  168. package/dist/server/people-tokens.d.ts +1 -6
  169. package/dist/server/people-tokens.js +580 -572
  170. package/dist/server/remote.d.ts +4 -7
  171. package/dist/server/remote.js +37 -70
  172. package/dist/server/render-chat.d.ts +402 -0
  173. package/dist/server/render-chat.js +927 -0
  174. package/dist/server/render-pages.d.ts +535 -0
  175. package/dist/server/render-pages.js +1277 -0
  176. package/dist/server/render-people.d.ts +32 -0
  177. package/dist/server/render-people.js +121 -0
  178. package/dist/server/render-results.d.ts +744 -0
  179. package/dist/server/render-results.js +2237 -0
  180. package/dist/server/render-settings.d.ts +147 -0
  181. package/dist/server/render-settings.js +577 -0
  182. package/dist/server/render-tasks.d.ts +632 -0
  183. package/dist/server/render-tasks.js +2345 -0
  184. package/dist/server/request-context.d.ts +46 -0
  185. package/dist/server/request-context.js +17 -0
  186. package/dist/server/route-table.d.ts +25 -3
  187. package/dist/server/route-table.js +163 -75
  188. package/dist/server/runtime.d.ts +15 -7
  189. package/dist/server/runtime.js +7 -1
  190. package/dist/server/session.d.ts +138 -0
  191. package/dist/server/session.js +94 -0
  192. package/dist/server/settings.d.ts +1 -5
  193. package/dist/server/settings.js +2182 -2117
  194. package/dist/server/task-views.d.ts +11 -4
  195. package/dist/server/task-views.js +26 -22
  196. package/dist/server/tasks.d.ts +9 -9
  197. package/dist/server/tasks.js +1486 -1418
  198. package/dist/server-telemetry.d.ts +4 -3
  199. package/dist/server-telemetry.js +4 -8
  200. package/dist/skills-ui.d.ts +4 -3
  201. package/dist/skills-ui.js +14 -17
  202. package/dist/skills.d.ts +1 -1
  203. package/dist/skills.js +2 -1
  204. package/dist/slack-api.d.ts +5 -1
  205. package/dist/slack-api.js +2 -2
  206. package/dist/slack-chat.d.ts +9 -4
  207. package/dist/slack-chat.js +29 -52
  208. package/dist/slack-settings.d.ts +3 -2
  209. package/dist/slack-settings.js +23 -57
  210. package/dist/slack.js +6 -16
  211. package/dist/spend-ui.d.ts +2 -1
  212. package/dist/spend-ui.js +9 -32
  213. package/dist/sso-ui.d.ts +3 -2
  214. package/dist/sso-ui.js +26 -27
  215. package/dist/storage-ui.d.ts +2 -1
  216. package/dist/storage-ui.js +14 -33
  217. package/dist/store.d.ts +97 -364
  218. package/dist/store.js +207 -1531
  219. package/dist/surface.js +14 -3
  220. package/dist/task-composer.d.ts +2 -1
  221. package/dist/task-composer.js +7 -7
  222. package/dist/task-status.d.ts +4 -3
  223. package/dist/task-status.js +15 -16
  224. package/dist/team-http.d.ts +5 -9
  225. package/dist/team-http.js +5 -63
  226. package/dist/team-ui.d.ts +2 -1
  227. package/dist/team-ui.js +2 -5
  228. package/dist/teammate-question.js +12 -12
  229. package/dist/teammates-ui.d.ts +5 -4
  230. package/dist/teammates-ui.js +61 -90
  231. package/dist/teams-chat.js +12 -9
  232. package/dist/teams-settings.d.ts +3 -2
  233. package/dist/teams-settings.js +22 -33
  234. package/dist/teams.js +4 -5
  235. package/dist/telegram-flow.js +5 -5
  236. package/dist/telegram-settings.d.ts +3 -2
  237. package/dist/telegram-settings.js +14 -25
  238. package/dist/telegram.js +4 -4
  239. package/dist/toolroll-update-ui.d.ts +3 -2
  240. package/dist/toolroll-update-ui.js +35 -39
  241. package/dist/toolroll-update.d.ts +10 -0
  242. package/dist/toolroll-update.js +50 -3
  243. package/dist/tools-ui.d.ts +5 -2
  244. package/dist/tools-ui.js +31 -31
  245. package/dist/when-html.d.ts +11 -3
  246. package/dist/when-html.js +5 -6
  247. package/dist/workspace-revision.js +3 -1
  248. package/dist/workspace-ui.d.ts +4 -2
  249. package/dist/workspace-ui.js +10 -10
  250. package/package.json +1 -1
  251. package/dist/server/shared.d.ts +0 -3151
  252. package/dist/server/shared.js +0 -12001
  253. package/dist/slack-state.d.ts +0 -13
  254. package/dist/slack-state.js +0 -10
@@ -0,0 +1,802 @@
1
+ /**
2
+ * The v116 move onto the one set of chat tables (contracts/chat-tables.ts). Until then Telegram kept 21 tables of its
3
+ * own and Slack, Discord and Teams 16 cloned tables each (`slack_binding`, `discord_part`, ...). An older database
4
+ * still has them: openStore creates the ones its version should have had (exactly as earlier builds did), the older
5
+ * migration steps bring them to their last shape, and `convertChatTables` copies every row into the shared tables in
6
+ * one transaction, checks the counts and foreign keys, and drops the old tables. Nothing else reads them.
7
+ */
8
+ import { CHAT_PROVIDERS, CHAT_TABLES } from "./contracts/chat-tables.js";
9
+ import { readChatMessageBody, readChatPart } from "./contracts/chat-content.js";
10
+ export const LEGACY_CHAT_APPS = ["slack", "discord", "teams"];
11
+ /* The per-app template the Slack, Discord and Teams tables were stamped from (v67-v114), kept verbatim. */
12
+ const LEGACY_APP_SCHEMA = `
13
+ CREATE TABLE IF NOT EXISTS chat_binding (
14
+ id INTEGER PRIMARY KEY AUTOINCREMENT, installation TEXT NOT NULL,
15
+ team TEXT NOT NULL, app TEXT NOT NULL, member TEXT NOT NULL, channel TEXT NOT NULL,
16
+ approver TEXT NOT NULL, generation INTEGER NOT NULL, created TEXT NOT NULL, revoked TEXT
17
+ );
18
+ CREATE UNIQUE INDEX IF NOT EXISTS chat_one_binding_member ON chat_binding(installation, member) WHERE revoked IS NULL;
19
+ CREATE TABLE IF NOT EXISTS chat_pair (
20
+ hash TEXT PRIMARY KEY, installation TEXT NOT NULL, approver TEXT NOT NULL,
21
+ generation INTEGER NOT NULL, expires TEXT NOT NULL, consumed TEXT
22
+ );
23
+ CREATE TABLE IF NOT EXISTS chat_event (
24
+ id TEXT PRIMARY KEY, installation TEXT NOT NULL, binding INTEGER REFERENCES chat_binding(id),
25
+ kind TEXT NOT NULL CHECK(kind IN ('message','action','pair','notice')),
26
+ channel TEXT NOT NULL, member TEXT NOT NULL, ts TEXT NOT NULL, thread TEXT NOT NULL,
27
+ payload TEXT NOT NULL, created TEXT NOT NULL,
28
+ session INTEGER REFERENCES mate_session(id), state TEXT NOT NULL DEFAULT 'queued' CHECK(state IN ('queued','done','dropped')),
29
+ next_at TEXT, problem TEXT
30
+ );
31
+ CREATE INDEX IF NOT EXISTS chat_pending_event ON chat_event(installation,state,next_at);
32
+ CREATE TABLE IF NOT EXISTS chat_part (
33
+ id INTEGER PRIMARY KEY AUTOINCREMENT, event TEXT NOT NULL REFERENCES chat_event(id), ordinal INTEGER NOT NULL,
34
+ payload TEXT NOT NULL, state TEXT NOT NULL DEFAULT 'pending' CHECK(state IN ('pending','sent','dropped')),
35
+ message TEXT, file TEXT, uploaded INTEGER NOT NULL DEFAULT 0, attempts INTEGER NOT NULL DEFAULT 0,
36
+ uncertain INTEGER NOT NULL DEFAULT 0, next_at TEXT, problem TEXT, created TEXT NOT NULL,
37
+ UNIQUE(event,ordinal)
38
+ );
39
+ CREATE TABLE IF NOT EXISTS chat_action (
40
+ token TEXT PRIMARY KEY, part INTEGER NOT NULL REFERENCES chat_part(id),
41
+ proposal INTEGER NOT NULL REFERENCES mate_proposal(id), phase TEXT NOT NULL CHECK(phase IN ('confirm','dismiss','yes','cancel')),
42
+ expires TEXT NOT NULL, consumed TEXT
43
+ );
44
+ CREATE TABLE IF NOT EXISTS chat_progress (
45
+ binding INTEGER NOT NULL REFERENCES chat_binding(id), run INTEGER NOT NULL REFERENCES run(id),
46
+ part INTEGER NOT NULL REFERENCES chat_part(id), digest TEXT NOT NULL,
47
+ PRIMARY KEY(binding,run)
48
+ );
49
+ CREATE TABLE IF NOT EXISTS chat_runtime (
50
+ installation TEXT PRIMARY KEY, owner TEXT, lease_until TEXT, connected TEXT,
51
+ problem TEXT, retry_at TEXT, notification INTEGER NOT NULL DEFAULT 0
52
+ );
53
+ `;
54
+ /** v73: a chat's place in the shared team conversations — a channel follows one
55
+ * conversation, a DM may select one. Rows are revoked, never deleted. */
56
+ const LEGACY_APP_ROOM_SCHEMA = `
57
+ CREATE TABLE IF NOT EXISTS chat_room (
58
+ id INTEGER PRIMARY KEY AUTOINCREMENT, installation TEXT NOT NULL, chat TEXT NOT NULL,
59
+ kind TEXT NOT NULL CHECK(kind IN ('group','private')), conversation TEXT NOT NULL,
60
+ binding INTEGER NOT NULL REFERENCES chat_binding(id),
61
+ bound_by TEXT NOT NULL, bound TEXT NOT NULL, cursor INTEGER NOT NULL DEFAULT 0,
62
+ revoked TEXT, revoked_by TEXT
63
+ );
64
+ CREATE UNIQUE INDEX IF NOT EXISTS chat_room_live ON chat_room(installation, chat) WHERE revoked IS NULL;
65
+ CREATE UNIQUE INDEX IF NOT EXISTS chat_room_group ON chat_room(conversation) WHERE revoked IS NULL AND kind='group';
66
+ CREATE TABLE IF NOT EXISTS chat_meta (
67
+ installation TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, updated TEXT NOT NULL,
68
+ PRIMARY KEY(installation, key)
69
+ );
70
+ `;
71
+ /** v88: flow decisions in the chat app, as on Telegram (telegram-flow.ts).
72
+ * A button is one opaque token for one visit (card, entry) of one card, on
73
+ * the part it rides; Edit and Send back open a prompt the person's next
74
+ * message in their DM answers. */
75
+ const LEGACY_APP_FLOW_SCHEMA = `
76
+ CREATE TABLE IF NOT EXISTS chat_flow_action (
77
+ token TEXT PRIMARY KEY, part INTEGER NOT NULL REFERENCES chat_part(id),
78
+ card INTEGER NOT NULL REFERENCES flow_card(id), entry INTEGER NOT NULL,
79
+ action TEXT NOT NULL CHECK(action IN ('approve','edit','send-back')), expires TEXT NOT NULL, consumed TEXT
80
+ );
81
+ CREATE INDEX IF NOT EXISTS chat_flow_action_visit ON chat_flow_action(card, entry);
82
+ CREATE TABLE IF NOT EXISTS chat_flow_prompt (
83
+ id INTEGER PRIMARY KEY AUTOINCREMENT, binding INTEGER NOT NULL REFERENCES chat_binding(id),
84
+ card INTEGER NOT NULL REFERENCES flow_card(id), entry INTEGER NOT NULL,
85
+ mode TEXT NOT NULL CHECK(mode IN ('edit','send-back')), created TEXT NOT NULL, expires TEXT NOT NULL, consumed TEXT
86
+ );
87
+ CREATE INDEX IF NOT EXISTS chat_flow_prompt_open ON chat_flow_prompt(binding, consumed);
88
+ `;
89
+ /** A flow's "Person chooses" zone in the chat app (flow-send.ts): one button per option (choice is its index, label its words
90
+ * when sent) for one visit of one card, on the part it rides. A reply in that notice's thread is the note; chat_flow_note
91
+ * is "Use this as your note?" Yes / No about one other message (held: its event; words: what it said, kept only until
92
+ * answered, as an event's own payload is cleared once it is handled), asked once per visit.
93
+ * Never in CHAT_TABLES: an older database has no such table until this schema creates it. */
94
+ const LEGACY_APP_FLOW_CHOICE_SCHEMA = `
95
+ CREATE TABLE IF NOT EXISTS chat_flow_choice (
96
+ token TEXT PRIMARY KEY, part INTEGER NOT NULL REFERENCES chat_part(id),
97
+ card INTEGER NOT NULL REFERENCES flow_card(id), entry INTEGER NOT NULL,
98
+ choice INTEGER NOT NULL, label TEXT NOT NULL, expires TEXT NOT NULL, consumed TEXT
99
+ );
100
+ CREATE INDEX IF NOT EXISTS chat_flow_choice_visit ON chat_flow_choice(card, entry);
101
+ CREATE TABLE IF NOT EXISTS chat_flow_note (
102
+ token TEXT PRIMARY KEY, part INTEGER NOT NULL REFERENCES chat_part(id),
103
+ card INTEGER NOT NULL REFERENCES flow_card(id), entry INTEGER NOT NULL,
104
+ held TEXT NOT NULL, words TEXT, answer TEXT NOT NULL CHECK(answer IN ('yes','no')), expires TEXT NOT NULL, consumed TEXT
105
+ );
106
+ CREATE INDEX IF NOT EXISTS chat_flow_note_visit ON chat_flow_note(card, entry);
107
+ `;
108
+ /** v93: a teammate's question in the chat app. One button per option (choice)
109
+ * and one to answer in words (choice NULL); that one opens a prompt the
110
+ * person's next message in their DM answers. Never in CHAT_TABLES: an older
111
+ * database has no such tables until this schema creates them. */
112
+ const LEGACY_APP_QUESTION_SCHEMA = `
113
+ CREATE TABLE IF NOT EXISTS chat_question_action (
114
+ token TEXT PRIMARY KEY, part INTEGER NOT NULL REFERENCES chat_part(id),
115
+ question INTEGER NOT NULL REFERENCES teammate_question(id), choice TEXT, expires TEXT NOT NULL, consumed TEXT
116
+ );
117
+ CREATE INDEX IF NOT EXISTS chat_question_action_question ON chat_question_action(question);
118
+ CREATE TABLE IF NOT EXISTS chat_question_prompt (
119
+ id INTEGER PRIMARY KEY AUTOINCREMENT, binding INTEGER NOT NULL REFERENCES chat_binding(id),
120
+ question INTEGER NOT NULL REFERENCES teammate_question(id), created TEXT NOT NULL, expires TEXT NOT NULL, consumed TEXT
121
+ );
122
+ CREATE INDEX IF NOT EXISTS chat_question_prompt_open ON chat_question_prompt(binding, consumed);
123
+ `;
124
+ /** The lead's question to its owner (ask_owner) in the chat app: one button per option (choice is its index) and one for
125
+ * "Something else" (choice NULL). A tap sends the option as the owner's next message. Never in CHAT_TABLES. */
126
+ const LEGACY_APP_ASK_SCHEMA = `
127
+ CREATE TABLE IF NOT EXISTS chat_ask_action (
128
+ token TEXT PRIMARY KEY, part INTEGER NOT NULL REFERENCES chat_part(id) ON DELETE CASCADE,
129
+ turn INTEGER NOT NULL, choice INTEGER, expires TEXT NOT NULL, consumed TEXT
130
+ );
131
+ CREATE INDEX IF NOT EXISTS chat_ask_action_turn ON chat_ask_action(turn);
132
+ `;
133
+ /** One app's old tables, as an older database has (or should have had) them. */
134
+ export const legacyAppSchema = (app) => (LEGACY_APP_SCHEMA + LEGACY_APP_ROOM_SCHEMA + LEGACY_APP_FLOW_SCHEMA + LEGACY_APP_FLOW_CHOICE_SCHEMA + LEGACY_APP_QUESTION_SCHEMA + LEGACY_APP_ASK_SCHEMA).replaceAll("chat_", `${app}_`);
135
+ const LEGACY_APP_TABLE_SUFFIXES = ["binding", "pair", "event", "part", "action", "progress", "runtime", "room", "meta",
136
+ "flow_action", "flow_prompt", "flow_choice", "flow_note", "question_action", "question_prompt", "ask_action"];
137
+ /** One app's 16 old tables, parents first. */
138
+ export const legacyAppTables = (app) => LEGACY_APP_TABLE_SUFFIXES.map(suffix => `${app}_${suffix}`);
139
+ /** Telegram's own tables (v1-v114) and the bridge's lease, kept verbatim. */
140
+ export const LEGACY_TELEGRAM_SCHEMA = `
141
+ CREATE TABLE IF NOT EXISTS telegram_flow_choice (
142
+ token TEXT PRIMARY KEY,
143
+ binding INTEGER NOT NULL REFERENCES telegram_binding(id) ON DELETE RESTRICT,
144
+ card INTEGER NOT NULL REFERENCES flow_card(id),
145
+ entry INTEGER NOT NULL,
146
+ choice INTEGER NOT NULL,
147
+ label TEXT NOT NULL,
148
+ chat_id TEXT NOT NULL,
149
+ message_id TEXT,
150
+ created_at TEXT NOT NULL,
151
+ expires_at TEXT NOT NULL,
152
+ consumed_at TEXT
153
+ );
154
+ CREATE INDEX IF NOT EXISTS telegram_flow_choice_visit ON telegram_flow_choice (card, entry);
155
+
156
+ -- v79: a Telegram message that showed one task (a /task status, a task
157
+ -- picked from /tasks): a reply to it is about that task. Like chat_focus,
158
+ -- a pointer and never an authority — the turn re-proves the task.
159
+ CREATE TABLE IF NOT EXISTS telegram_task_message (
160
+ binding INTEGER NOT NULL,
161
+ chat_id TEXT NOT NULL,
162
+ message_id TEXT NOT NULL,
163
+ task_id TEXT NOT NULL,
164
+ source_run INTEGER,
165
+ created_at TEXT NOT NULL,
166
+ PRIMARY KEY (binding, chat_id, message_id)
167
+ );
168
+
169
+
170
+ -- Away mode (v34, mate arc §10): the Telegram bridge's digest cadence.
171
+ -- One row. every_ms NULL = off (every fact pages as it lands); set, the
172
+ -- bridge holds ROUTINE rows unclaimed until the window elapses and sends
173
+ -- them as one message — decisions and attention-class facts still page
174
+ -- singly. last_sent_at anchors the window; it never deletes a row.
175
+ CREATE TABLE IF NOT EXISTS telegram_digest (
176
+ id INTEGER PRIMARY KEY CHECK (id = 1),
177
+ every_ms INTEGER,
178
+ set_by TEXT,
179
+ set_at TEXT,
180
+ last_sent_at TEXT
181
+ );
182
+ INSERT OR IGNORE INTO telegram_digest (id, every_ms) VALUES (1, NULL);
183
+
184
+
185
+ -- One Telegram chat speaking as one approver. Bindings are never deleted:
186
+ -- revocation is a stamp, because "who could answer as whom, when" is an
187
+ -- audit question a DELETE cannot answer. The partial unique index (v72) is
188
+ -- the rule that one Telegram user has one live binding per bot: several
189
+ -- teammates pair their own private chats with the same bot.
190
+ CREATE TABLE IF NOT EXISTS telegram_binding (
191
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
192
+ bot_id TEXT NOT NULL,
193
+ chat_id TEXT NOT NULL,
194
+ user_id TEXT NOT NULL,
195
+ approver TEXT NOT NULL REFERENCES approver(name) ON DELETE RESTRICT,
196
+ approver_generation INTEGER NOT NULL,
197
+ paired_at TEXT NOT NULL,
198
+ paired_by TEXT NOT NULL,
199
+ pair_update_id INTEGER,
200
+ revoked_at TEXT,
201
+ revoked_by TEXT
202
+ );
203
+ CREATE UNIQUE INDEX IF NOT EXISTS telegram_binding_live_user
204
+ ON telegram_binding (bot_id, user_id) WHERE revoked_at IS NULL;
205
+
206
+ -- v72: a Telegram chat's place in the shared team conversations. A group
207
+ -- follows exactly one team conversation (and a conversation has at most one
208
+ -- group); a private chat may select one conversation to talk in instead of
209
+ -- its personal assistant. The cursor is the last conversation message this
210
+ -- chat received. Rows are revoked, never deleted.
211
+ CREATE TABLE IF NOT EXISTS telegram_team_chat (
212
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
213
+ bot_id TEXT NOT NULL,
214
+ chat_id TEXT NOT NULL,
215
+ binding INTEGER NOT NULL REFERENCES telegram_binding(id) ON DELETE RESTRICT,
216
+ kind TEXT NOT NULL CHECK (kind IN ('group','private')),
217
+ conversation TEXT NOT NULL REFERENCES team_conversation(id) ON DELETE RESTRICT,
218
+ bound_by TEXT NOT NULL,
219
+ bound_at TEXT NOT NULL,
220
+ cursor INTEGER NOT NULL DEFAULT 0,
221
+ revoked_at TEXT,
222
+ revoked_by TEXT
223
+ );
224
+ CREATE UNIQUE INDEX IF NOT EXISTS telegram_team_chat_live
225
+ ON telegram_team_chat (bot_id, chat_id) WHERE revoked_at IS NULL;
226
+ CREATE UNIQUE INDEX IF NOT EXISTS telegram_team_chat_group
227
+ ON telegram_team_chat (conversation) WHERE revoked_at IS NULL AND kind = 'group';
228
+
229
+
230
+ CREATE TABLE IF NOT EXISTS telegram_outbound_message (
231
+ binding INTEGER NOT NULL REFERENCES telegram_binding(id) ON DELETE RESTRICT,
232
+ bot_id TEXT NOT NULL,
233
+ chat_id TEXT NOT NULL,
234
+ message_id TEXT NOT NULL,
235
+ notification INTEGER NOT NULL REFERENCES notification(id) ON DELETE RESTRICT,
236
+ destination TEXT NOT NULL,
237
+ project TEXT,
238
+ task_ref INTEGER REFERENCES task_ref(id),
239
+ task_id TEXT,
240
+ source_run INTEGER REFERENCES run(id),
241
+ created_at TEXT NOT NULL,
242
+ PRIMARY KEY (binding, chat_id, message_id, notification)
243
+ );
244
+ CREATE TABLE IF NOT EXISTS telegram_retry (
245
+ bot_id TEXT PRIMARY KEY,
246
+ next_attempt_at TEXT NOT NULL
247
+ );
248
+
249
+ -- One-time pairing codes, hashed like every other credential, consumed in
250
+ -- one transaction with the binding they create.
251
+ CREATE TABLE IF NOT EXISTS telegram_pairing (
252
+ code_hash TEXT PRIMARY KEY,
253
+ approver TEXT NOT NULL REFERENCES approver(name) ON DELETE RESTRICT,
254
+ approver_generation INTEGER NOT NULL,
255
+ created_at TEXT NOT NULL,
256
+ created_by TEXT NOT NULL,
257
+ expires_at TEXT NOT NULL,
258
+ consumed_at TEXT,
259
+ consumed_chat TEXT,
260
+ consumed_user TEXT,
261
+ consumed_update INTEGER
262
+ );
263
+
264
+ -- Every Telegram update this installation has applied, exactly once. The
265
+ -- PRIMARY KEY is the idempotency: a replayed batch re-applies nothing.
266
+ CREATE TABLE IF NOT EXISTS telegram_update (
267
+ update_id INTEGER PRIMARY KEY,
268
+ applied_at TEXT NOT NULL,
269
+ result TEXT NOT NULL
270
+ );
271
+
272
+ -- Opaque one-tap actions. callback_data carries only the random token; what
273
+ -- the tap MEANS — which binding, decision, option, and phase — lives here,
274
+ -- where a stolen bot token cannot read or forge it. Confirm challenges are
275
+ -- short-lived rows in the same table, consumed exactly once.
276
+ CREATE TABLE IF NOT EXISTS telegram_action (
277
+ token TEXT PRIMARY KEY,
278
+ binding INTEGER NOT NULL REFERENCES telegram_binding(id) ON DELETE RESTRICT,
279
+ decision INTEGER NOT NULL REFERENCES decision(id) ON DELETE CASCADE,
280
+ option_id TEXT NOT NULL,
281
+ phase TEXT NOT NULL CHECK (phase IN ('choose','confirm','cancel')),
282
+ chat_id TEXT NOT NULL,
283
+ message_id TEXT,
284
+ created_at TEXT NOT NULL,
285
+ expires_at TEXT,
286
+ consumed_at TEXT
287
+ );
288
+ CREATE INDEX IF NOT EXISTS telegram_action_by_decision ON telegram_action (decision);
289
+
290
+ -- v86: a flow card waiting at a "Person decides" zone reaches the decider on
291
+ -- Telegram with Approve / Edit / Send back. Each button is one opaque token
292
+ -- for one visit of one card (card, entry), placed on the message it rides so
293
+ -- a tap on any other message proves itself stale. Edit and Send back ask
294
+ -- for a reply to a prompt: telegram_flow_prompt names which prompt means
295
+ -- what, so the reply becomes the new draft or the note.
296
+ CREATE TABLE IF NOT EXISTS telegram_flow_action (
297
+ token TEXT PRIMARY KEY,
298
+ binding INTEGER NOT NULL REFERENCES telegram_binding(id) ON DELETE RESTRICT,
299
+ card INTEGER NOT NULL REFERENCES flow_card(id),
300
+ entry INTEGER NOT NULL,
301
+ action TEXT NOT NULL CHECK (action IN ('approve','edit','send-back')),
302
+ chat_id TEXT NOT NULL,
303
+ message_id TEXT,
304
+ created_at TEXT NOT NULL,
305
+ expires_at TEXT NOT NULL,
306
+ consumed_at TEXT
307
+ );
308
+ CREATE INDEX IF NOT EXISTS telegram_flow_action_visit ON telegram_flow_action (card, entry);
309
+ -- v93: a teammate's question on Telegram. One button per option (choice)
310
+ -- and one to reply in words (choice NULL), placed on the message it rides;
311
+ -- a reply to the prompt the Reply button sends is the answer.
312
+ CREATE TABLE IF NOT EXISTS telegram_question_action (
313
+ token TEXT PRIMARY KEY,
314
+ binding INTEGER NOT NULL REFERENCES telegram_binding(id) ON DELETE RESTRICT,
315
+ question INTEGER NOT NULL REFERENCES teammate_question(id),
316
+ choice TEXT,
317
+ chat_id TEXT NOT NULL,
318
+ message_id TEXT,
319
+ created_at TEXT NOT NULL,
320
+ expires_at TEXT NOT NULL,
321
+ consumed_at TEXT
322
+ );
323
+ CREATE TABLE IF NOT EXISTS telegram_question_prompt (
324
+ chat_id TEXT NOT NULL,
325
+ message_id TEXT NOT NULL,
326
+ binding INTEGER NOT NULL REFERENCES telegram_binding(id) ON DELETE RESTRICT,
327
+ question INTEGER NOT NULL REFERENCES teammate_question(id),
328
+ created_at TEXT NOT NULL,
329
+ expires_at TEXT NOT NULL,
330
+ consumed_at TEXT,
331
+ PRIMARY KEY (chat_id, message_id)
332
+ );
333
+ CREATE TABLE IF NOT EXISTS telegram_flow_prompt (
334
+ chat_id TEXT NOT NULL,
335
+ message_id TEXT NOT NULL,
336
+ binding INTEGER NOT NULL REFERENCES telegram_binding(id) ON DELETE RESTRICT,
337
+ card INTEGER NOT NULL REFERENCES flow_card(id),
338
+ entry INTEGER NOT NULL,
339
+ mode TEXT NOT NULL CHECK (mode IN ('edit','send-back')),
340
+ created_at TEXT NOT NULL,
341
+ expires_at TEXT NOT NULL,
342
+ consumed_at TEXT,
343
+ PRIMARY KEY (chat_id, message_id)
344
+ );
345
+
346
+ -- Which outbound Telegram message carries which decision (v10). A free-text
347
+ -- reply is routed through the EXACT message it replies to — never "the
348
+ -- latest decision", never "the only open one" (Codex free-text review,
349
+ -- finding 1). Losing the send/record race fails closed: an unrecorded
350
+ -- message routes nothing.
351
+ CREATE TABLE IF NOT EXISTS telegram_decision_message (
352
+ binding INTEGER NOT NULL REFERENCES telegram_binding(id) ON DELETE CASCADE,
353
+ chat_id TEXT NOT NULL,
354
+ message_id TEXT NOT NULL,
355
+ decision INTEGER NOT NULL REFERENCES decision(id) ON DELETE CASCADE,
356
+ created_at TEXT NOT NULL,
357
+ PRIMARY KEY (binding, chat_id, message_id)
358
+ );
359
+
360
+ -- The free-text draft (v10): an operator's note, held immutable and
361
+ -- expiring until a TAP commits it with the choice. One live draft per
362
+ -- (binding, decision); a newer valid reply SUPERSEDES, never edits.
363
+ CREATE TABLE IF NOT EXISTS telegram_note_draft (
364
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
365
+ binding INTEGER NOT NULL REFERENCES telegram_binding(id) ON DELETE CASCADE,
366
+ decision INTEGER NOT NULL REFERENCES decision(id) ON DELETE CASCADE,
367
+ update_id INTEGER NOT NULL,
368
+ message_id TEXT NOT NULL,
369
+ reply_to TEXT NOT NULL,
370
+ note TEXT NOT NULL,
371
+ state TEXT NOT NULL DEFAULT 'pending' CHECK (state IN ('pending','armed','superseded','consumed','discarded')),
372
+ created_at TEXT NOT NULL,
373
+ expires_at TEXT NOT NULL
374
+ );
375
+
376
+ CREATE UNIQUE INDEX IF NOT EXISTS telegram_note_draft_live
377
+ ON telegram_note_draft (binding, decision) WHERE state IN ('pending','armed');
378
+
379
+ -- v62: the durable inbound conversation queue. An ordinary paired message
380
+ -- becomes a row in the SAME transaction that marks its update applied, so
381
+ -- the poll cursor never moves past text nobody holds. The row carries the
382
+ -- exact binding, sender, update, message and the request identity the
383
+ -- shared mate engine receipts it under; the async model turn runs OUTSIDE
384
+ -- any transaction under a short claim, and a replay or restart finds the
385
+ -- receipt instead of dispatching the provider again.
386
+ CREATE TABLE IF NOT EXISTS telegram_conversation (
387
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
388
+ binding INTEGER NOT NULL REFERENCES telegram_binding(id) ON DELETE RESTRICT,
389
+ bot_id TEXT NOT NULL,
390
+ chat_id TEXT NOT NULL,
391
+ user_id TEXT NOT NULL,
392
+ approver TEXT NOT NULL,
393
+ approver_generation INTEGER NOT NULL,
394
+ update_id INTEGER NOT NULL UNIQUE,
395
+ message_id TEXT NOT NULL,
396
+ reply_to TEXT,
397
+ request TEXT NOT NULL UNIQUE,
398
+ text TEXT NOT NULL,
399
+ context TEXT,
400
+ task_id TEXT,
401
+ source_run INTEGER,
402
+ state TEXT NOT NULL CHECK (state IN ('queued','running','done','failed')),
403
+ claim_owner TEXT,
404
+ claim_expires_at TEXT,
405
+ attempts INTEGER NOT NULL DEFAULT 0,
406
+ next_attempt_at TEXT,
407
+ session INTEGER,
408
+ turn INTEGER,
409
+ outcome TEXT,
410
+ reply_message_id TEXT,
411
+ created_at TEXT NOT NULL,
412
+ started_at TEXT,
413
+ finished_at TEXT
414
+ );
415
+ CREATE INDEX IF NOT EXISTS telegram_conversation_queue ON telegram_conversation (bot_id, state, id);
416
+
417
+ -- v62: opaque one-tap tokens for the mate's proposal cards, the same shape
418
+ -- as telegram_action for decisions: callback_data carries only the token,
419
+ -- and what a tap MEANS (which binding, which proposal, which phase) lives
420
+ -- here. Consumed exactly once; an irreversible answer arms a yes/cancel
421
+ -- pair first, exactly as a decision button does.
422
+ CREATE TABLE IF NOT EXISTS telegram_proposal_action (
423
+ token TEXT PRIMARY KEY,
424
+ binding INTEGER NOT NULL REFERENCES telegram_binding(id) ON DELETE RESTRICT,
425
+ proposal INTEGER NOT NULL REFERENCES mate_proposal(id) ON DELETE CASCADE,
426
+ phase TEXT NOT NULL CHECK (phase IN ('confirm','dismiss','yes','cancel')),
427
+ chat_id TEXT NOT NULL,
428
+ message_id TEXT,
429
+ created_at TEXT NOT NULL,
430
+ expires_at TEXT,
431
+ consumed_at TEXT
432
+ );
433
+ CREATE INDEX IF NOT EXISTS telegram_proposal_action_by_proposal ON telegram_proposal_action (proposal);
434
+
435
+ -- v63: the outbound half of a conversation, durable BEFORE any send. Once
436
+ -- the engine's turn is answered (or recovered from its receipt), the exact
437
+ -- reply text, split into Telegram-sized parts, and one card per pending
438
+ -- proposal are written here in one transaction; the bridge then sends
439
+ -- them in order under the row's claim, marking a part sent only when
440
+ -- Telegram confirmed a message id. A crash, an outage, a rate limit or a
441
+ -- restart resumes from the first unsent part — never another model call,
442
+ -- proposal, task or revision. A card's buttons are minted with the part,
443
+ -- so a resend carries the same tokens. A part whose network answer was
444
+ -- lost is counted as uncertain: a resend may duplicate it, and the row
445
+ -- says so instead of claiming an exactly-once Telegram cannot provide.
446
+ -- v64: an 'image' part is one verified screenshot of one exact result,
447
+ -- named by typed columns — task, run, artifact and the hash its record
448
+ -- carried when the part was planned — never by JSON inside text or a
449
+ -- keyboard, and never by bytes: every send re-reads the artifact from the
450
+ -- evidence root and re-verifies it against these columns first. Its text
451
+ -- is the short caption. A confirmed image message binds replies to that
452
+ -- exact task and run, exactly as an outbox fact's message does.
453
+ CREATE TABLE IF NOT EXISTS telegram_conversation_part (
454
+ conversation INTEGER NOT NULL REFERENCES telegram_conversation(id) ON DELETE CASCADE,
455
+ ordinal INTEGER NOT NULL,
456
+ kind TEXT NOT NULL CHECK (kind IN ('reply','card','image')),
457
+ text TEXT NOT NULL,
458
+ reply_to TEXT,
459
+ proposal INTEGER REFERENCES mate_proposal(id) ON DELETE SET NULL,
460
+ keyboard_json TEXT,
461
+ state TEXT NOT NULL CHECK (state IN ('pending','sent','dropped')),
462
+ message_id TEXT,
463
+ attempts INTEGER NOT NULL DEFAULT 0,
464
+ uncertain INTEGER NOT NULL DEFAULT 0,
465
+ next_attempt_at TEXT,
466
+ last_error TEXT,
467
+ created_at TEXT NOT NULL,
468
+ sent_at TEXT,
469
+ task_id TEXT,
470
+ source_run INTEGER REFERENCES run(id),
471
+ artifact INTEGER,
472
+ sha256 TEXT,
473
+ PRIMARY KEY (conversation, ordinal),
474
+ CHECK ((state = 'sent') = (message_id IS NOT NULL)),
475
+ CHECK ((state = 'sent') = (sent_at IS NOT NULL)),
476
+ CHECK ((kind = 'image') = (task_id IS NOT NULL AND source_run IS NOT NULL AND artifact IS NOT NULL AND sha256 IS NOT NULL))
477
+ );
478
+
479
+
480
+ -- The bridge's poll lease and cursor, per bot. One live poller at a time;
481
+ -- the cursor only ever moves forward, and only under a live generation.
482
+ CREATE TABLE IF NOT EXISTS bridge_lease (
483
+ bot_id TEXT PRIMARY KEY,
484
+ owner TEXT NOT NULL,
485
+ generation INTEGER NOT NULL,
486
+ cursor INTEGER NOT NULL DEFAULT 0,
487
+ expires_at TEXT NOT NULL,
488
+ heartbeat_at TEXT NOT NULL,
489
+ push_url TEXT,
490
+ push_at TEXT,
491
+ push_problem TEXT
492
+ );
493
+ -- v98: updates Telegram pushed to /hooks/telegram, kept until the bridge
494
+ -- applies them (in update order, through the same door as polled ones).
495
+ CREATE TABLE IF NOT EXISTS telegram_inbox (
496
+ update_id INTEGER PRIMARY KEY,
497
+ bot_id TEXT NOT NULL,
498
+ payload TEXT NOT NULL,
499
+ received_at TEXT NOT NULL
500
+ );
501
+
502
+
503
+ CREATE TABLE IF NOT EXISTS telegram_flow_confirm (
504
+ token TEXT PRIMARY KEY,
505
+ binding INTEGER NOT NULL,
506
+ chat_id TEXT NOT NULL,
507
+ message_id TEXT NOT NULL,
508
+ card INTEGER NOT NULL,
509
+ entry INTEGER NOT NULL,
510
+ phase TEXT NOT NULL CHECK (phase IN ('yes','cancel')),
511
+ created_at TEXT NOT NULL,
512
+ expires_at TEXT NOT NULL,
513
+ consumed_at TEXT
514
+ );
515
+ CREATE INDEX IF NOT EXISTS telegram_flow_confirm_visit ON telegram_flow_confirm (card, entry);
516
+ `;
517
+ /** Telegram's 21 old tables and the bridge's lease, children first. */
518
+ export const LEGACY_TELEGRAM_TABLES = [
519
+ "telegram_note_draft", "telegram_decision_message", "telegram_task_message", "telegram_outbound_message",
520
+ "telegram_action", "telegram_proposal_action", "telegram_flow_action", "telegram_flow_confirm", "telegram_flow_choice",
521
+ "telegram_flow_prompt", "telegram_question_action", "telegram_question_prompt", "telegram_conversation_part",
522
+ "telegram_conversation", "telegram_team_chat", "telegram_pairing", "telegram_update", "telegram_inbox",
523
+ "telegram_retry", "telegram_digest", "telegram_binding", "bridge_lease",
524
+ ];
525
+ const tableExists = (db, table) => db.prepare("SELECT 1 AS hit FROM sqlite_master WHERE type = 'table' AND name = ?").get(table) !== undefined;
526
+ const columnsOf = (db, table) => new Set(db.prepare(`PRAGMA table_info(${table})`).all().map(row => String(row["name"])));
527
+ /** Whether this database already keeps its chats in the shared tables (v116 ran, or it was made at v116 or later). */
528
+ export function chatTablesShared(db) {
529
+ return CHAT_TABLES.every(table => tableExists(db, table)) && columnsOf(db, "chat_binding").has("provider");
530
+ }
531
+ /** Whether any of the old per-app tables is still here. */
532
+ export function legacyChatTablesPresent(db) {
533
+ return [...LEGACY_TELEGRAM_TABLES, ...LEGACY_CHAT_APPS.flatMap(legacyAppTables)].some(table => tableExists(db, table));
534
+ }
535
+ /**
536
+ * The columns every shape of each old table has had since it first existed (later steps add the rest). An old table
537
+ * without them is a shape this build cannot name: openStore refuses before it stamps the migration epoch.
538
+ */
539
+ const LEGACY_BASE_COLUMNS = {
540
+ telegram_binding: ["id", "bot_id", "chat_id", "user_id", "approver", "approver_generation", "paired_at", "paired_by", "revoked_at", "revoked_by"],
541
+ telegram_pairing: ["code_hash", "approver", "approver_generation", "created_at", "created_by", "expires_at", "consumed_at"],
542
+ telegram_update: ["update_id", "applied_at", "result"],
543
+ telegram_action: ["token", "binding", "decision", "option_id", "phase", "chat_id", "message_id", "created_at", "expires_at", "consumed_at"],
544
+ telegram_decision_message: ["binding", "chat_id", "message_id", "decision", "created_at"],
545
+ telegram_note_draft: ["id", "binding", "decision", "update_id", "message_id", "reply_to", "note", "state", "created_at", "expires_at"],
546
+ telegram_outbound_message: ["binding", "chat_id", "message_id", "notification", "destination", "project", "task_ref", "task_id", "source_run", "created_at"],
547
+ telegram_retry: ["bot_id", "next_attempt_at"],
548
+ telegram_conversation: ["id", "binding", "bot_id", "chat_id", "user_id", "approver", "approver_generation", "update_id", "message_id", "reply_to", "request", "text", "context", "task_id", "source_run", "state", "claim_owner", "claim_expires_at", "attempts", "next_attempt_at", "session", "turn", "outcome", "reply_message_id", "created_at", "started_at", "finished_at"],
549
+ telegram_proposal_action: ["token", "binding", "proposal", "phase", "chat_id", "message_id", "created_at", "expires_at", "consumed_at"],
550
+ telegram_conversation_part: ["conversation", "ordinal", "kind", "text", "reply_to", "proposal", "keyboard_json", "state", "message_id", "attempts", "uncertain", "next_attempt_at", "last_error", "created_at", "sent_at"],
551
+ bridge_lease: ["bot_id", "owner", "generation", "cursor", "expires_at", "heartbeat_at"],
552
+ telegram_digest: ["id", "every_ms", "set_by", "set_at", "last_sent_at"],
553
+ telegram_team_chat: ["id", "bot_id", "chat_id", "binding", "kind", "conversation", "bound_by", "bound_at", "cursor", "revoked_at", "revoked_by"],
554
+ telegram_task_message: ["binding", "chat_id", "message_id", "task_id", "source_run", "created_at"],
555
+ telegram_flow_action: ["token", "binding", "card", "entry", "action", "chat_id", "message_id", "created_at", "expires_at", "consumed_at"],
556
+ telegram_flow_prompt: ["chat_id", "message_id", "binding", "card", "entry", "mode", "created_at", "expires_at", "consumed_at"],
557
+ telegram_flow_choice: ["token", "binding", "card", "entry", "choice", "label", "chat_id", "message_id", "created_at", "expires_at", "consumed_at"],
558
+ telegram_flow_confirm: ["token", "binding", "chat_id", "message_id", "card", "entry", "phase", "created_at", "expires_at", "consumed_at"],
559
+ telegram_question_action: ["token", "binding", "question", "choice", "chat_id", "message_id", "created_at", "expires_at", "consumed_at"],
560
+ telegram_question_prompt: ["chat_id", "message_id", "binding", "question", "created_at", "expires_at", "consumed_at"],
561
+ telegram_inbox: ["update_id", "bot_id", "payload", "received_at"],
562
+ };
563
+ const LEGACY_APP_COLUMNS = {
564
+ binding: ["id", "installation", "team", "app", "member", "channel", "approver", "generation", "created", "revoked"],
565
+ pair: ["hash", "installation", "approver", "generation", "expires", "consumed"],
566
+ event: ["id", "installation", "binding", "kind", "channel", "member", "ts", "thread", "payload", "created", "session", "state", "next_at", "problem"],
567
+ part: ["id", "event", "ordinal", "payload", "state", "message", "file", "uploaded", "attempts", "uncertain", "next_at", "problem", "created"],
568
+ action: ["token", "part", "proposal", "phase", "expires", "consumed"],
569
+ progress: ["binding", "run", "part", "digest"],
570
+ runtime: ["installation", "owner", "lease_until", "connected", "problem", "retry_at", "notification"],
571
+ room: ["id", "installation", "chat", "kind", "conversation", "binding", "bound_by", "bound", "cursor", "revoked", "revoked_by"],
572
+ meta: ["installation", "key", "value", "updated"],
573
+ flow_action: ["token", "part", "card", "entry", "action", "expires", "consumed"],
574
+ flow_prompt: ["id", "binding", "card", "entry", "mode", "created", "expires", "consumed"],
575
+ flow_choice: ["token", "part", "card", "entry", "choice", "label", "expires", "consumed"],
576
+ flow_note: ["token", "part", "card", "entry", "held", "words", "answer", "expires", "consumed"],
577
+ question_action: ["token", "part", "question", "choice", "expires", "consumed"],
578
+ question_prompt: ["id", "binding", "question", "created", "expires", "consumed"],
579
+ ask_action: ["token", "part", "turn", "choice", "expires", "consumed"],
580
+ };
581
+ /** The columns the copy reads, beyond the base ones: added by migration steps that run before it. */
582
+ const LEGACY_LATER_COLUMNS = {
583
+ telegram_action: ["note_digest"],
584
+ telegram_conversation_part: ["task_id", "source_run", "artifact", "sha256"],
585
+ telegram_binding: ["pair_update_id"],
586
+ telegram_pairing: ["consumed_chat", "consumed_user", "consumed_update"],
587
+ bridge_lease: ["push_url", "push_at", "push_problem"],
588
+ };
589
+ function expectedColumns(table, withLater) {
590
+ const base = LEGACY_BASE_COLUMNS[table];
591
+ if (base !== undefined)
592
+ return withLater ? [...base, ...(LEGACY_LATER_COLUMNS[table] ?? [])] : base;
593
+ const app = LEGACY_CHAT_APPS.find(one => table.startsWith(`${one}_`));
594
+ if (app === undefined)
595
+ return null;
596
+ return LEGACY_APP_COLUMNS[table.slice(app.length + 1)] ?? null;
597
+ }
598
+ /**
599
+ * What is wrong with the old chat tables this database still has, before anything is changed: one that lacks a column
600
+ * its every shape has had is not a table this build knows how to carry. Null when they are fine (or already gone).
601
+ */
602
+ export function legacyChatProblem(db) {
603
+ for (const table of [...LEGACY_TELEGRAM_TABLES, ...LEGACY_CHAT_APPS.flatMap(legacyAppTables)]) {
604
+ if (!tableExists(db, table))
605
+ continue;
606
+ const have = columnsOf(db, table);
607
+ const missing = (expectedColumns(table, false) ?? []).filter(column => !have.has(column));
608
+ if (missing.length > 0)
609
+ return `the ${table} table has an unknown shape (no ${missing.join(", ")})`;
610
+ }
611
+ return null;
612
+ }
613
+ const P = (app) => `'${app}'`;
614
+ function appCopies(app) {
615
+ const t = (suffix) => `${app}_${suffix}`;
616
+ const one = (suffix, target, columns, select, where = `provider = ${P(app)}`) => ({
617
+ source: t(suffix), target,
618
+ insert: `INSERT INTO ${target} (provider, ${columns}) SELECT ${P(app)}, ${select} FROM ${t(suffix)}`,
619
+ expect: `SELECT COUNT(*) AS n FROM ${t(suffix)}`, found: `SELECT COUNT(*) AS n FROM ${target} WHERE ${where}`,
620
+ });
621
+ return [
622
+ one("binding", "chat_binding", "id, installation, team, app, member, channel, approver, generation, created, revoked", "id, installation, team, app, member, channel, approver, generation, created, revoked"),
623
+ one("pair", "chat_pair", "hash, installation, approver, generation, expires, consumed", "hash, installation, approver, generation, expires, consumed"),
624
+ one("event", "chat_event", "id, installation, binding, kind, channel, member, ts, thread, payload, created, session, state, next_at, problem", "id, installation, binding, kind, channel, member, ts, thread, payload, created, session, state, next_at, problem"),
625
+ one("part", "chat_part", "id, event, ordinal, payload, state, message, file, uploaded, attempts, uncertain, next_at, problem, created", "id, event, ordinal, payload, state, message, file, uploaded, attempts, uncertain, next_at, problem, created"),
626
+ one("action", "chat_action", "token, part, proposal, phase, expires, consumed", "token, part, proposal, phase, expires, consumed", `provider = ${P(app)} AND part IS NOT NULL`),
627
+ one("progress", "chat_progress", "binding, run, part, digest", "binding, run, part, digest"),
628
+ one("runtime", "chat_runtime", "installation, owner, lease_until, connected, problem, retry_at, notification", "installation, owner, lease_until, connected, problem, retry_at, notification"),
629
+ one("room", "chat_room", "id, installation, chat, kind, conversation, binding, bound_by, bound, cursor, revoked, revoked_by", "id, installation, chat, kind, conversation, binding, bound_by, bound, cursor, revoked, revoked_by"),
630
+ one("meta", "chat_meta", "installation, key, value, updated", "installation, key, value, updated"),
631
+ one("flow_action", "chat_flow_action", "token, part, card, entry, action, expires, consumed", "token, part, card, entry, action, expires, consumed"),
632
+ one("flow_prompt", "chat_flow_prompt", "id, binding, card, entry, mode, created, expires, consumed", "id, binding, card, entry, mode, created, expires, consumed"),
633
+ one("flow_choice", "chat_flow_choice", "token, part, card, entry, choice, label, expires, consumed", "token, part, card, entry, choice, label, expires, consumed"),
634
+ one("flow_note", "chat_flow_note", "token, part, card, entry, held, words, answer, expires, consumed", "token, part, card, entry, held, words, answer, expires, consumed"),
635
+ one("question_action", "chat_question_action", "token, part, question, choice, expires, consumed", "token, part, question, choice, expires, consumed"),
636
+ one("question_prompt", "chat_question_prompt", "id, binding, question, created, expires, consumed", "id, binding, question, created, expires, consumed"),
637
+ one("ask_action", "chat_ask_action", "token, part, turn, choice, expires, consumed", "token, part, turn, choice, expires, consumed"),
638
+ ];
639
+ }
640
+ const TG = P("telegram");
641
+ /** A JSON object of the fields that are not null (top level only: a nested object keeps its own nulls). */
642
+ const withoutNulls = (base, optional) => `json_patch(${base}, ${optional})`;
643
+ /** Telegram's rows, each table into its shared one. Every copy names the count it must reach. */
644
+ function telegramCopies() {
645
+ const copy = (source, target, insert, found, expect = `SELECT COUNT(*) AS n FROM ${source}`) => ({ source, target, insert, expect, found: `SELECT COUNT(*) AS n FROM ${target} WHERE provider = ${TG} AND ${found}` });
646
+ const conversationBody = withoutNulls("json_object('version', 1, 'text', c.text, 'request', c.request)", "json_object('context', c.context)");
647
+ const partBody = withoutNulls("json_object('version', 1, 'text', p.text)", `json_object('replyTo', p.reply_to, 'proposal', p.proposal, 'keyboard', json(p.keyboard_json),
648
+ 'card', CASE WHEN p.kind = 'card' THEN json('true') END,
649
+ 'image', CASE WHEN p.kind = 'image' THEN json_object('taskId', p.task_id, 'run', p.source_run, 'artifact', p.artifact, 'sha256', p.sha256) END)`);
650
+ return [
651
+ copy("telegram_binding", "chat_binding", `INSERT INTO chat_binding (provider, id, installation, team, app, member, channel, approver, generation, created, created_by, pair_event, revoked, revoked_by)
652
+ SELECT ${TG}, id, bot_id, bot_id, bot_id, user_id, chat_id, approver, approver_generation, paired_at, paired_by, CAST(pair_update_id AS TEXT), revoked_at, revoked_by FROM telegram_binding`, "1"),
653
+ copy("telegram_pairing", "chat_pair", `INSERT INTO chat_pair (provider, hash, installation, approver, generation, created, created_by, expires, consumed, consumed_channel, consumed_member, consumed_event)
654
+ SELECT ${TG}, code_hash, NULL, approver, approver_generation, created_at, created_by, expires_at, consumed_at, consumed_chat, consumed_user, CAST(consumed_update AS TEXT) FROM telegram_pairing`, "1"),
655
+ copy("telegram_team_chat", "chat_room", `INSERT INTO chat_room (provider, id, installation, chat, kind, conversation, binding, bound_by, bound, cursor, revoked, revoked_by)
656
+ SELECT ${TG}, id, bot_id, chat_id, kind, conversation, binding, bound_by, bound_at, cursor, revoked_at, revoked_by FROM telegram_team_chat`, "1"),
657
+ // Every applied update is a receipt; a pushed one still waiting is queued with its body. One row per update id.
658
+ copy("telegram_update", "chat_event", `INSERT INTO chat_event (provider, id, installation, kind, channel, member, ts, thread, payload, created, state, result)
659
+ SELECT ${TG}, CAST(update_id AS TEXT), '', 'update', '', '', '', '', '{}', applied_at, 'done', result FROM telegram_update`, "kind = 'update' AND state = 'done'"),
660
+ copy("telegram_inbox", "chat_event", `INSERT INTO chat_event (provider, id, installation, kind, channel, member, ts, thread, payload, created, state)
661
+ SELECT ${TG}, CAST(update_id AS TEXT), bot_id, 'update', '', '', '', '', payload, received_at, 'queued' FROM telegram_inbox
662
+ WHERE update_id NOT IN (SELECT update_id FROM telegram_update)`, "kind = 'update' AND state = 'queued'", "SELECT COUNT(*) AS n FROM telegram_inbox WHERE update_id NOT IN (SELECT update_id FROM telegram_update)"),
663
+ // A message the lead answers: its event is m<update id>, and its words, server context and request ride the body.
664
+ copy("telegram_conversation", "chat_event", `INSERT INTO chat_event (provider, id, installation, binding, kind, channel, member, ts, thread, payload, created, session, state,
665
+ next_at, claim_owner, claim_until, attempts, turn, started, finished, result, reply)
666
+ SELECT ${TG}, 'm' || c.update_id, c.bot_id, c.binding, 'message', c.chat_id, c.user_id, c.message_id, COALESCE(c.reply_to, ''),
667
+ CASE WHEN c.task_id IS NULL THEN ${conversationBody} ELSE json_set(${conversationBody}, '$.about', json_object('task', c.task_id, 'run', c.source_run)) END,
668
+ c.created_at, c.session, c.state, c.next_attempt_at, c.claim_owner, c.claim_expires_at, c.attempts, c.turn, c.started_at, c.finished_at,
669
+ c.outcome, c.reply_message_id
670
+ FROM telegram_conversation c`, "kind = 'message'"),
671
+ copy("telegram_conversation_part", "chat_part", `INSERT INTO chat_part (provider, id, event, ordinal, payload, state, message, attempts, uncertain, next_at, problem, created, sent)
672
+ SELECT ${TG}, ROW_NUMBER() OVER (ORDER BY p.conversation, p.ordinal), 'm' || c.update_id, p.ordinal, ${partBody},
673
+ p.state, p.message_id, p.attempts, p.uncertain, p.next_attempt_at, p.last_error, p.created_at, p.sent_at
674
+ FROM telegram_conversation_part p JOIN telegram_conversation c ON c.id = p.conversation`, "1"),
675
+ copy("telegram_proposal_action", "chat_action", `INSERT INTO chat_action (provider, token, binding, chat, message, proposal, phase, created, expires, consumed)
676
+ SELECT ${TG}, token, binding, chat_id, message_id, proposal, phase, created_at, expires_at, consumed_at FROM telegram_proposal_action`, "proposal IS NOT NULL"),
677
+ copy("telegram_action", "chat_action", `INSERT INTO chat_action (provider, token, binding, chat, message, decision, option_id, note_digest, phase, created, expires, consumed)
678
+ SELECT ${TG}, token, binding, chat_id, message_id, decision, option_id, note_digest, phase, created_at, expires_at, consumed_at FROM telegram_action`, "decision IS NOT NULL"),
679
+ copy("telegram_flow_action", "chat_flow_action", `INSERT INTO chat_flow_action (provider, token, binding, chat, message, card, entry, action, created, expires, consumed)
680
+ SELECT ${TG}, token, binding, chat_id, message_id, card, entry, action, created_at, expires_at, consumed_at FROM telegram_flow_action`, "action IN ('approve','edit','send-back')"),
681
+ copy("telegram_flow_confirm", "chat_flow_action", `INSERT INTO chat_flow_action (provider, token, binding, chat, message, card, entry, action, created, expires, consumed)
682
+ SELECT ${TG}, token, binding, chat_id, message_id, card, entry, phase, created_at, expires_at, consumed_at FROM telegram_flow_confirm`, "action IN ('yes','cancel')"),
683
+ copy("telegram_flow_choice", "chat_flow_choice", `INSERT INTO chat_flow_choice (provider, token, binding, chat, message, card, entry, choice, label, created, expires, consumed)
684
+ SELECT ${TG}, token, binding, chat_id, message_id, card, entry, choice, label, created_at, expires_at, consumed_at FROM telegram_flow_choice`, "1"),
685
+ copy("telegram_flow_prompt", "chat_flow_prompt", `INSERT INTO chat_flow_prompt (provider, id, binding, chat, message, card, entry, mode, created, expires, consumed)
686
+ SELECT ${TG}, ROW_NUMBER() OVER (ORDER BY created_at, rowid), binding, chat_id, message_id, card, entry, mode, created_at, expires_at, consumed_at FROM telegram_flow_prompt`, "1"),
687
+ copy("telegram_question_action", "chat_question_action", `INSERT INTO chat_question_action (provider, token, binding, chat, message, question, choice, created, expires, consumed)
688
+ SELECT ${TG}, token, binding, chat_id, message_id, question, choice, created_at, expires_at, consumed_at FROM telegram_question_action`, "1"),
689
+ copy("telegram_question_prompt", "chat_question_prompt", `INSERT INTO chat_question_prompt (provider, id, binding, chat, message, question, created, expires, consumed)
690
+ SELECT ${TG}, ROW_NUMBER() OVER (ORDER BY created_at, rowid), binding, chat_id, message_id, question, created_at, expires_at, consumed_at FROM telegram_question_prompt`, "1"),
691
+ copy("telegram_decision_message", "chat_message_ref", `INSERT INTO chat_message_ref (provider, binding, chat, message, kind, decision, created)
692
+ SELECT ${TG}, binding, chat_id, message_id, 'decision', decision, created_at FROM telegram_decision_message`, "kind = 'decision'"),
693
+ copy("telegram_task_message", "chat_message_ref", `INSERT INTO chat_message_ref (provider, binding, chat, message, kind, task_id, run, created)
694
+ SELECT ${TG}, binding, chat_id, message_id, 'task', task_id, source_run, created_at FROM telegram_task_message`, "kind = 'task'"),
695
+ copy("telegram_outbound_message", "chat_message_ref", `INSERT INTO chat_message_ref (provider, binding, chat, message, kind, notification, destination, project, task_ref, task_id, run, created)
696
+ SELECT ${TG}, binding, chat_id, message_id, 'notification', notification, destination, project, task_ref, task_id, source_run, created_at FROM telegram_outbound_message`, "kind = 'notification'"),
697
+ copy("telegram_note_draft", "chat_note_draft", `INSERT INTO chat_note_draft (provider, id, binding, decision, event, message, reply_to, note, state, created, expires)
698
+ SELECT ${TG}, id, binding, decision, update_id, message_id, reply_to, note, state, created_at, expires_at FROM telegram_note_draft`, "1"),
699
+ copy("bridge_lease", "chat_runtime", `INSERT INTO chat_runtime (provider, installation, owner, lease_until, generation, cursor, heartbeat, push_url, push_at, push_problem)
700
+ SELECT ${TG}, bot_id, owner, expires_at, generation, cursor, heartbeat_at, push_url, push_at, push_problem FROM bridge_lease`, "lease_until IS NOT NULL"),
701
+ // A bot with a rate-limit wait but no lease yet gets a runtime row of its own.
702
+ copy("telegram_retry", "chat_runtime", `INSERT INTO chat_runtime (provider, installation, retry_at)
703
+ SELECT ${TG}, bot_id, next_attempt_at FROM telegram_retry WHERE true
704
+ ON CONFLICT (provider, installation) DO UPDATE SET retry_at = excluded.retry_at`, "retry_at IS NOT NULL"),
705
+ copy("telegram_digest", "chat_digest", `INSERT INTO chat_digest (provider, every_ms, set_by, set_at, last_sent_at)
706
+ SELECT ${TG}, every_ms, set_by, set_at, last_sent_at FROM telegram_digest WHERE id = 1`, "1", "SELECT COUNT(*) AS n FROM telegram_digest WHERE id = 1"),
707
+ ];
708
+ }
709
+ /**
710
+ * v116: every old chat table's rows into the shared ones, in one transaction. Each copy is one INSERT ... SELECT (no
711
+ * row passes through this process), then every count is checked against its source, every Telegram message's words
712
+ * against the person its binding names, and every foreign key; only then are the old tables dropped. Anything that
713
+ * doesn't add up rolls the whole move back and the open fails, with the old tables as they were. Run again (a resumed
714
+ * upgrade) it finds no old tables and does nothing.
715
+ */
716
+ export function convertChatTables(db) {
717
+ const apps = LEGACY_CHAT_APPS.filter(app => tableExists(db, `${app}_binding`));
718
+ const telegram = tableExists(db, "telegram_binding");
719
+ const legacy = [...LEGACY_TELEGRAM_TABLES, ...LEGACY_CHAT_APPS.flatMap(legacyAppTables)].filter(table => tableExists(db, table));
720
+ if (legacy.length === 0)
721
+ return { copied: 0 };
722
+ const copies = [...(telegram ? telegramCopies() : []), ...apps.flatMap(appCopies)].filter(copy => tableExists(db, copy.source));
723
+ db.exec("PRAGMA foreign_keys = OFF");
724
+ try {
725
+ db.exec("BEGIN IMMEDIATE");
726
+ try {
727
+ for (const table of legacy) {
728
+ const expected = expectedColumns(table, true);
729
+ if (expected === null)
730
+ continue;
731
+ const have = columnsOf(db, table);
732
+ const missing = expected.filter(column => !have.has(column));
733
+ if (missing.length > 0)
734
+ throw new Error(`the ${table} table has an unknown shape (no ${missing.join(", ")}) — refusing to move it`);
735
+ }
736
+ // A table the shared one already has rows for (a copy made by hand?) is not ours to merge into.
737
+ for (const provider of CHAT_PROVIDERS) {
738
+ const present = provider === "telegram" ? telegram : apps.includes(provider);
739
+ if (!present)
740
+ continue;
741
+ const held = Number(db.prepare("SELECT COUNT(*) AS n FROM chat_binding WHERE provider = ?").get(provider)?.["n"] ?? 0);
742
+ if (held > 0)
743
+ throw new Error(`the shared chat tables already hold ${provider} pairings — refusing to merge the old ${provider} tables into them`);
744
+ }
745
+ if (telegram) {
746
+ // A message speaks for the person its pairing names, at the generation it was paired under, and every reply
747
+ // part belongs to a message: what doesn't is named, never dropped or guessed.
748
+ const strays = Number(db.prepare(`SELECT COUNT(*) AS n FROM telegram_conversation c JOIN telegram_binding b ON b.id = c.binding
749
+ WHERE c.approver <> b.approver OR c.approver_generation <> b.approver_generation`).get()?.["n"] ?? 0);
750
+ if (strays > 0)
751
+ throw new Error(`${strays} Telegram messages name a person their pairing does not — refusing to move them`);
752
+ const orphans = Number(db.prepare("SELECT COUNT(*) AS n FROM telegram_conversation_part p WHERE NOT EXISTS (SELECT 1 FROM telegram_conversation c WHERE c.id = p.conversation)").get()?.["n"] ?? 0);
753
+ if (orphans > 0)
754
+ throw new Error(`${orphans} Telegram reply parts belong to no message — refusing to move them`);
755
+ }
756
+ let copied = 0;
757
+ for (const copy of copies) {
758
+ const before = Number(db.prepare(copy.found).get()?.["n"] ?? 0);
759
+ db.exec(copy.insert);
760
+ const want = Number(db.prepare(copy.expect).get()?.["n"] ?? 0);
761
+ const got = Number(db.prepare(copy.found).get()?.["n"] ?? 0) - (copy.target === "chat_runtime" && copy.source === "telegram_retry" ? 0 : before);
762
+ // telegram_retry merges into the runtime rows the lease made; every other copy adds exactly its source's rows.
763
+ if (copy.source === "telegram_retry" ? got < want : got !== want)
764
+ throw new Error(`moving ${copy.source} into ${copy.target} copied ${got} of ${want} rows — refusing to drop it`);
765
+ copied += want;
766
+ }
767
+ // Every moved Telegram message and reply part reads by the shared contracts (a page at a time).
768
+ if (telegram) {
769
+ const unreadable = (sql, read) => {
770
+ let after = 0, bad = 0;
771
+ for (;;) {
772
+ const page = db.prepare(sql).all(after);
773
+ for (const row of page)
774
+ if (!read(String(row["payload"])).ok)
775
+ bad++;
776
+ if (page.length < 500)
777
+ return bad;
778
+ after = Number(page[page.length - 1]["rowid"]);
779
+ }
780
+ };
781
+ const parts = unreadable("SELECT rowid, payload FROM chat_part WHERE provider = 'telegram' AND rowid > ? ORDER BY rowid LIMIT 500", readChatPart);
782
+ const messages = unreadable("SELECT rowid, payload FROM chat_event WHERE provider = 'telegram' AND kind = 'message' AND rowid > ? ORDER BY rowid LIMIT 500", readChatMessageBody);
783
+ if (parts + messages > 0)
784
+ throw new Error(`${parts} Telegram reply parts and ${messages} Telegram messages would not read in the shared chat tables — refusing to move them`);
785
+ }
786
+ const broken = db.prepare("PRAGMA foreign_key_check").all().filter(row => CHAT_TABLES.includes(String(row["table"])));
787
+ if (broken.length > 0)
788
+ throw new Error(`the shared chat tables would have ${broken.length} broken references (first: ${String(broken[0]["table"])} → ${String(broken[0]["parent"])}) — refusing to drop the old tables`);
789
+ for (const table of legacy)
790
+ db.exec(`DROP TABLE ${table}`);
791
+ db.exec("COMMIT");
792
+ return { copied };
793
+ }
794
+ catch (error) {
795
+ db.exec("ROLLBACK");
796
+ throw error;
797
+ }
798
+ }
799
+ finally {
800
+ db.exec("PRAGMA foreign_keys = ON");
801
+ }
802
+ }