memhouse 0.8.0 → 0.11.0

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 (36) hide show
  1. package/README.md +84 -47
  2. package/bin/memhouse.js +993 -89
  3. package/editors/antigravity.js +7 -15
  4. package/editors/cursor.js +18 -24
  5. package/editors/goose.js +9 -21
  6. package/editors/index.js +19 -70
  7. package/editors/opencode.js +6 -15
  8. package/editors/sqlite.js +74 -0
  9. package/editors/windsurf.js +3 -3
  10. package/editors/zed.js +10 -15
  11. package/memhouse/COMPETITION.md +9 -5
  12. package/memhouse/DESIGN.md +37 -12
  13. package/memhouse/RESUME-AND-UPDATE.md +7 -10
  14. package/memhouse/delivery/AGENT-INSTALL-PROMPT.md +10 -2
  15. package/memhouse/delivery/AGENT-INSTALL.md +56 -32
  16. package/memhouse/delivery/PROMPT.md +37 -13
  17. package/memhouse/delivery/kernel-install.md +4 -4
  18. package/memhouse/delivery/plugin/.claude-plugin/plugin.json +14 -5
  19. package/memhouse/delivery/plugin/skills/ask/SKILL.md +112 -0
  20. package/memhouse/delivery/plugin/skills/hello/SKILL.md +76 -0
  21. package/memhouse/delivery/plugin/skills/invite/SKILL.md +65 -0
  22. package/memhouse/delivery/plugin/skills/search/SKILL.md +1 -1
  23. package/memhouse/delivery/plugin/skills/sessions/SKILL.md +3 -3
  24. package/memhouse/delivery/plugin/skills/share/SKILL.md +130 -0
  25. package/memhouse/delivery/plugin/skills/sql/SKILL.md +34 -11
  26. package/memhouse/delivery/plugin/skills/status/SKILL.md +114 -0
  27. package/memhouse/delivery/plugin/skills/users/SKILL.md +97 -0
  28. package/memhouse/house/HOUSE.md +40 -5
  29. package/memhouse/house/house.js +191 -10
  30. package/memhouse/house/migrate.js +277 -0
  31. package/memhouse/house/migrations/0100-epoch-key.js +68 -0
  32. package/memhouse/house/schema.sql.tpl +96 -25
  33. package/memhouse/self-update.js +4 -4
  34. package/memhouse/service.js +7 -2
  35. package/memhouse/shipper/ship.js +471 -131
  36. package/package.json +3 -6
@@ -33,6 +33,100 @@
33
33
 
34
34
  const ROOM_TYPES = ['sessions', 'messages', 'tool_calls'];
35
35
 
36
+ /**
37
+ * The house's own record of itself — not rooms. Nothing about a conversation lives in
38
+ * them, the shipper's guards do not require them, and a member who cannot create them
39
+ * still ships normally.
40
+ */
41
+ const META_TYPES = ['house_meta', 'house_events'];
42
+
43
+ /**
44
+ * What generation of the schema a house is at. Bumped only when existing rooms have to be
45
+ * REBUILT to keep working — a new column is not a version, because ensureSchema rolls one
46
+ * out in place.
47
+ *
48
+ * 1 0.4.x–0.9.x shared rooms, origin in the transcript keys
49
+ * 2 0.10.0 epoch in the transcript keys; the shipper became insert-only
50
+ *
51
+ * A house at 1 is not broken, it is un-migrated: its rooms cannot hold two parses of a
52
+ * session, so the shipper refuses to write rather than overwrite. `memhouse migrate-rooms`
53
+ * moves it to 2.
54
+ */
55
+ const SCHEMA_VERSION = 2;
56
+ const MIGRATIONS = {
57
+ 2: '0100-epoch-key',
58
+ };
59
+
60
+ /**
61
+ * Which schema generations THIS release may write. The degenerate compatibility matrix:
62
+ * a release supports exactly its own generation, and the array exists for the rare
63
+ * release that is wire-compatible across two (write both, migrate at leisure).
64
+ *
65
+ * The check that reads this (ship.js assertWriterSupported) is the forward half of the
66
+ * mixed-fleet story: every release from 0.10.0 on REFUSES a house whose recorded schema
67
+ * it does not support, in both directions — a house too new says "update memhouse", a
68
+ * house too old says "memhouse migrate". The backward half cannot be code: releases
69
+ * already on the registry read none of this, so for them the enforcement is the pilot's
70
+ * GRANTs (revoke the mutation privileges after migrating, and a pre-0.10 shipper's
71
+ * delete fails loudly instead of destroying retained parses). `memhouse migrate` prints
72
+ * that advice.
73
+ */
74
+ const SUPPORTED_SCHEMAS = [2];
75
+
76
+ /**
77
+ * The floor a house may set under its writers, recorded by `memhouse migrate` in
78
+ * house_meta['min_writer_schema']. Today it equals SCHEMA_VERSION; a future
79
+ * back-compatible generation can hold it one step lower for a grace window.
80
+ */
81
+ const MIN_WRITER_SCHEMA = 2;
82
+
83
+ /**
84
+ * The sorting key each room MUST have, as the column list ClickHouse reports in
85
+ * `system.tables.sorting_key`. Canonical here because three places check it — the shipper
86
+ * before every pass, `doctor`, and the room rebuild — and they disagreed once already: the
87
+ * shipper's guard lived only in `--ensure-schema`, so 137 sessions shipped straight past
88
+ * it into a stale-key house.
89
+ *
90
+ * Two of the five columns are the whole data-safety story, and both were bought with
91
+ * measured losses:
92
+ *
93
+ * origin — an imported row and a shipped row at the same seq must be TWO rows.
94
+ * Without it RMT collapses them and the import loses; 27,948 messages went
95
+ * that way on a real house.
96
+ * epoch — a superseded parse and its replacement must be TWO rows, so a re-parse that
97
+ * shrinks or diverges can be written WITHOUT deleting what it replaces. This is
98
+ * what removed the shipper's DELETE.
99
+ *
100
+ * `sessions` is deliberately keyed on neither: it holds exactly one metadata row per
101
+ * session, and a second one makes sessions_v join messages twice and over-report (measured
102
+ * on a real house — one duplicate row inflated the totals by 1,764 messages and 655M
103
+ * tokens).
104
+ */
105
+ const ROOM_KEYS = {
106
+ sessions: ['session_id', 'user_id'],
107
+ messages: ['session_id', 'user_id', 'origin', 'epoch', 'seq'],
108
+ tool_calls: ['session_id', 'user_id', 'origin', 'epoch', 'idx'],
109
+ };
110
+
111
+ /**
112
+ * What is wrong with a room's sorting key, or null when nothing is. `sortingKey` is the
113
+ * raw `system.tables.sorting_key` value; pass '' for a table that reports none.
114
+ *
115
+ * An EXISTING room reporting no sorting key at all is not a MergeTree — a Merge, a View, a
116
+ * Log engine standing where a room belongs. Skipping that case let a ship pass sail past
117
+ * its own guard and die later on `DELETE query is not supported for table …`, and let
118
+ * doctor print "✓ sorting keys carry origin correctly (2/3 rooms)" over a broken house.
119
+ */
120
+ function keyProblem(type, sortingKey) {
121
+ const want = ROOM_KEYS[type];
122
+ if (!want) return `'${type}' is not a room type`;
123
+ const key = String(sortingKey || '').trim();
124
+ if (!key) return 'not a MergeTree, so it cannot be shipped to';
125
+ const have = key.split(',').map((s) => s.trim()).filter(Boolean);
126
+ if (have.length === want.length && have.every((c, i) => c === want[i])) return null;
127
+ return `(${key}) — expected (${want.join(', ')})`;
128
+ }
129
+
36
130
  // The one server-side setting a member carries, applied directly on the user with
37
131
  // ADD SETTING (bare `ALTER USER … SETTINGS` REPLACES the user's whole list — measured, it
38
132
  // wiped an operator-set ceiling; ADD SETTING merges and upserts). There is no settings
@@ -50,14 +144,19 @@ const MEMBER_PIN = 'async_insert = 0 CONST';
50
144
  /**
51
145
  * The global-install command that will actually work HERE.
52
146
  *
53
- * Every site that printed `npm install -g memhouse --allow-scripts=better-sqlite3` printed
54
- * a line that fails with EACCES on any distro-packaged Node, because the global prefix is
55
- * root-owned — measured on a clean Ubuntu machine, exit 243. `sudo` is right for exactly
56
- * one of the two causes and makes the other worse, and what tells them apart is who owns
57
- * the prefix — so look, rather than guess.
147
+ * The flag half of this is gone: the command carried `--allow-scripts=better-sqlite3`
148
+ * for as long as SQLite was a native module, and omitting it silently cost five
149
+ * adapters. `node:sqlite` needs no install script, so the base command is now the plain
150
+ * one everybody already types.
151
+ *
152
+ * The `sudo` half stays, because it was never about the flag. A bare install fails with
153
+ * EACCES on any distro-packaged Node, because the global prefix is root-owned — measured
154
+ * on a clean Ubuntu machine, exit 243. `sudo` is right for exactly one of the two causes
155
+ * and makes the other worse, and what tells them apart is who owns the prefix — so look,
156
+ * rather than guess.
58
157
  */
59
158
  function installCommand() {
60
- const base = 'npm install -g memhouse --allow-scripts=better-sqlite3';
159
+ const base = 'npm install -g memhouse';
61
160
  try {
62
161
  const { execFileSync } = require('child_process');
63
162
  const prefix = execFileSync('npm', ['prefix', '-g'], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
@@ -83,6 +182,71 @@ function assertUsableName(name, what = 'name') {
83
182
  }
84
183
  }
85
184
 
185
+ /**
186
+ * One table's CREATE statement, lifted verbatim out of the schema template and renamed.
187
+ *
188
+ * Used by the room rebuild, which cannot construct the DDL from a column list: the
189
+ * sorting key IS the thing being changed, and the text indexes on `messages` carry a
190
+ * syntax (`TYPE text(tokenizer = ngrams(3))`) that no column-level reconstruction would
191
+ * reproduce. Taking the statement whole means a migrated room is byte-for-byte the room a
192
+ * fresh install would create.
193
+ */
194
+ function createStatement(tpl, table, asName) {
195
+ // `(?:--[^\n]*\n\s*)*` between the column list and ENGINE: the template comments the
196
+ // engine choice on some tables, and a pattern demanding `) ENGINE` silently matched
197
+ // nothing for those — which reads as "the template has no such table".
198
+ const m = tpl.match(new RegExp(
199
+ `CREATE TABLE IF NOT EXISTS ${table}\\s*\\([\\s\\S]*?\\n\\)\\s*(?:--[^\\n]*\\n\\s*)*ENGINE[\\s\\S]*?;`, 'm'));
200
+ if (!m) throw new Error(`the schema template declares no table '${table}'`);
201
+ return m[0].replace(`CREATE TABLE IF NOT EXISTS ${table}`, `CREATE TABLE ${asName || table}`);
202
+ }
203
+
204
+ /**
205
+ * A transcript room restricted to the CURRENT parse of each session — SQL text, usable
206
+ * wherever the table name would go.
207
+ *
208
+ * The shipper keeps superseded parses (see the epoch column in schema.sql.tpl), so the
209
+ * physical room can hold a session twice: five messages at epoch 0, three at epoch 1.
210
+ * Reading that raw does not merely show stale rows, it makes every aggregate wrong —
211
+ * `total_msgs`, token sums, cost, model counts — which is a worse failure than the stale
212
+ * tail the epoch replaced. The filter is therefore not optional, and it is not left to
213
+ * each query to remember: `roomNames()` hands out THIS for `messages` / `tool_calls`, and
214
+ * the bare table only under `messages_raw` / `tool_calls_raw`. A read path nobody thought
215
+ * to update is correct; a write path nobody updated fails loudly on an insert into a
216
+ * subquery.
217
+ *
218
+ * `origin != 'ship'` first, and it carries the weight: epochs belong to the shipper, and
219
+ * an imported row sits at epoch 0 forever. Filtering it against the shipper's current
220
+ * epoch would hide the entire import behind any session that had been compacted once —
221
+ * the same class of loss as the delete this design removed, arriving as a read that
222
+ * silently returns less.
223
+ *
224
+ * The projection is deliberate: `*` omits MATERIALIZED columns, and the ones this schema
225
+ * has are all load-bearing. `user_id` is what every consumer scopes by (the rollup JOINs
226
+ * on it) and `text_ngram` / `text_word` are what `memhouse search` matches against — a
227
+ * bare `SELECT *` drops all three, and the search fails with `Unknown identifier`.
228
+ * Listing them costs nothing where they are unused: ClickHouse prunes unread columns out
229
+ * of a subquery.
230
+ */
231
+ const ROOM_MATERIALIZED = {
232
+ messages: ['user_id', 'text_ngram', 'text_word'],
233
+ tool_calls: ['user_id'],
234
+ };
235
+
236
+ function currentParse(table, epochSource = table) {
237
+ const extra = (ROOM_MATERIALIZED[table] || ['user_id']).join(', ');
238
+ return `(
239
+ SELECT *, ${extra}
240
+ FROM ${table} FINAL
241
+ WHERE origin != 'ship'
242
+ OR (session_id, user_id, epoch) IN (
243
+ SELECT session_id, user_id, max(epoch)
244
+ FROM ${epochSource}
245
+ WHERE origin = 'ship'
246
+ GROUP BY session_id, user_id)
247
+ )`;
248
+ }
249
+
86
250
  /**
87
251
  * The session rollup as SQL text, usable in the same `FROM ... AS c` position a view name
88
252
  * would occupy. Groups by (session_id, user_id): two housemates' rows never merge, even
@@ -118,7 +282,7 @@ function sessionsRollup({ sessions, messages }) {
118
282
  coalesce(sumIf(length(m.text), m.role = 'assistant'), 0) AS assistant_chars,
119
283
  coalesce(substring(argMinIf(m.text, m.seq, m.role = 'user' AND m.text != ''), 1, 200), '') AS first_prompt
120
284
  FROM ${sessions} AS s FINAL
121
- LEFT JOIN ${messages} AS m FINAL ON m.session_id = s.session_id AND m.user_id = s.user_id
285
+ LEFT JOIN ${messages} AS m ON m.session_id = s.session_id AND m.user_id = s.user_id
122
286
  GROUP BY s.session_id, s.user_id
123
287
  SETTINGS join_use_nulls = 1
124
288
  )`;
@@ -140,7 +304,22 @@ const READ_SETTINGS = { final: 1, join_use_nulls: 1 };
140
304
  */
141
305
  function roomNames(user) {
142
306
  const out = { member: user, user };
143
- for (const t of ROOM_TYPES) out[t] = t;
307
+ // The raw table names — for INSERT, for DDL, and for the shipper's own bookkeeping
308
+ // reads, which have to see every epoch to decide which one to write next.
309
+ for (const t of ROOM_TYPES) out[`${t}_raw`] = t;
310
+ // What everything else gets. `sessions` is unfiltered: it is one metadata row per
311
+ // session by construction, latest-wins, and carries no epoch anyone may read.
312
+ out.sessions = 'sessions';
313
+ out.messages = currentParse('messages');
314
+ // tool_calls takes its epoch from MESSAGES, not from itself. A parse that produces
315
+ // messages but NO tool calls is ordinary — a compaction can remove every assistant turn
316
+ // that called something — and it writes zero rows into this room at the new epoch. Asked
317
+ // for its own max(epoch), the room would answer with the SUPERSEDED epoch and serve the
318
+ // old parse's tool calls beside the new parse's messages. Both rooms are written by the
319
+ // same pass at the same epoch, so messages is the authority for both.
320
+ out.tool_calls = currentParse('tool_calls', 'messages');
321
+ // The house's own record of itself — plain names, nothing to filter.
322
+ for (const t of META_TYPES) { out[t] = t; out[`${t}_raw`] = t; }
144
323
  out.sessions_v = sessionsRollup(out);
145
324
  return out;
146
325
  }
@@ -159,7 +338,9 @@ async function resolveRooms(client) {
159
338
  }
160
339
 
161
340
  module.exports = {
162
- ROOM_TYPES, READ_SETTINGS, MEMBER_PIN,
341
+ ROOM_TYPES, META_TYPES, SCHEMA_VERSION, SUPPORTED_SCHEMAS, MIN_WRITER_SCHEMA,
342
+ MIGRATIONS, ROOM_KEYS, keyProblem,
343
+ READ_SETTINGS, MEMBER_PIN,
163
344
  installCommand, assertUsableName,
164
- sessionsRollup, roomNames, currentUser, resolveRooms,
345
+ sessionsRollup, currentParse, createStatement, roomNames, currentUser, resolveRooms,
165
346
  };
@@ -0,0 +1,277 @@
1
+ // The migration runner — the machinery `memhouse migrate` drives.
2
+ //
3
+ // A schema generation change (SCHEMA_VERSION in house.js) is the one kind of change
4
+ // ensureSchema's column healer cannot roll out in place: sorting keys, engines, and
5
+ // anything else ClickHouse refuses to ALTER. Those are REBUILDS, and a rebuild has a
6
+ // middle, an actor, and an outcome — so each one is a registered migration in
7
+ // ./migrations/<id>.js, and this module runs whichever of them a house still needs.
8
+ //
9
+ // The runner is deliberately transport-free: every entry point takes `q`, an object with
10
+ // `sql(text, settings) -> string` and `rows(text, settings) -> object[]`, injected by the
11
+ // caller (the CLI speaks raw HTTP; a test can speak @clickhouse/client). It never
12
+ // constructs a connection and never reads config.
13
+ //
14
+ // What is a migration and what is not:
15
+ //
16
+ // additive change (new column, new table, new index) -> ensureSchema, NOT a migration
17
+ // rebuild (sorting key, engine, column type) -> a migration, class 'rebuildRoom'
18
+ // data transform (backfill, rewrite) -> a migration, class 'sql'
19
+ //
20
+ // SCHEMA_VERSION bumps only for the last two.
21
+ //
22
+ // Invariants every migration inherits from the executors here — these were bought with
23
+ // measured losses and are not per-migration choices:
24
+ //
25
+ // NOTHING IS DELETED. A replaced object is renamed to `<name>_pre_<suffix>` and left
26
+ // for the pilot to drop. A leftover `__migrating` table from an interrupted run is
27
+ // never cleared automatically — it may hold the only copy of mid-flight rows.
28
+ //
29
+ // PROVENANCE IS COPIED, NOT RESTAMPED. `user_id` is MATERIALIZED currentUser(), so a
30
+ // plain INSERT SELECT would stamp every copied row of a shared house with whoever ran
31
+ // the migration. The copy lists columns explicitly under
32
+ // insert_allow_materialized_columns=1, and drops only the derived text_ngram/text_word
33
+ // (recomputed on insert).
34
+ //
35
+ // THE SWAP IS ONE STATEMENT. `RENAME TABLE a TO a_pre_x, tmp TO a` is atomic; the
36
+ // table never stops existing.
37
+ //
38
+ // WRITES DURING THE COPY SURVIVE. A shipper running through the migration writes into
39
+ // the old table until the rename; a late-writes pass copies anything newer than the
40
+ // snapshot, and ReplacingMergeTree makes that idempotent.
41
+ //
42
+ // EVERYTHING IS RECORDED. One house_events pending row before work, applied/failed
43
+ // after, per object; schema_version in house_meta only after a migration completes.
44
+
45
+ const fs = require('fs');
46
+ const path = require('path');
47
+ const { ROOM_TYPES, keyProblem, createStatement } = require('./house');
48
+
49
+ /**
50
+ * Every registered migration, in the order they must run. The id's numeric prefix is the
51
+ * order; the registry refuses duplicates and out-of-order versions at load time rather
52
+ * than at 2am on someone's house.
53
+ *
54
+ * A migration file exports:
55
+ * id '0100-epoch-key' — numeric prefix orders it, the rest names it
56
+ * component which part of memhouse it migrates ('rooms' today; 'daemon', 'config', …
57
+ * later). `memhouse migrate` runs all components; `memhouse migrate-rooms`
58
+ * filters to 'rooms'.
59
+ * toVersion the SCHEMA_VERSION a house is at once this has run
60
+ * detect(q, ctx) -> what still needs doing (any truthy non-empty value), or
61
+ * null/[] when the house is already past this migration.
62
+ * THE ROOMS ARE THE TRUTH, never house_meta: a hand-migrated
63
+ * house has no record, and a restored backup can carry a
64
+ * record newer than its tables.
65
+ * plan(found) -> lines of human text for the confirm prompt
66
+ * steps(found, ctx) -> [{op: 'rebuildRoom'|'healColumns'|'sql', ...args}]
67
+ */
68
+ function listMigrations() {
69
+ const dir = path.join(__dirname, 'migrations');
70
+ const files = fs.readdirSync(dir).filter((f) => /^\d{4}-.*\.js$/.test(f)).sort();
71
+ // An EMPTY registry is a broken install, never a current house. Measured: an npm
72
+ // extraction dropped the one registry file, and `memhouse migrate` on an un-migrated
73
+ // house then said "nothing to migrate — the house is at schema 2" — the most dangerous
74
+ // possible reading of a missing file. The registry has carried at least 0100-epoch-key
75
+ // since it existed; zero entries means the files are gone, not the work.
76
+ if (!files.length) {
77
+ throw new Error(`the migration registry at ${dir} is empty — this install is broken.\n`
78
+ + ' Reinstall memhouse (npm install -g memhouse), then retry.');
79
+ }
80
+ const out = files.map((f) => require(path.join(dir, f)));
81
+ const seen = new Set();
82
+ let lastVersion = 0;
83
+ for (const m of out) {
84
+ // The id is spliced into SQL (house_events predicates) and into kept-table suffixes.
85
+ // It is repo-controlled, but "repo-controlled" is one compromised dependency away
86
+ // from "attacker-controlled" — validate like any other identifier.
87
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(m.id)) throw new Error(`migration id '${m.id}' must match [a-z0-9-]+`);
88
+ if (seen.has(m.id)) throw new Error(`duplicate migration id '${m.id}'`);
89
+ seen.add(m.id);
90
+ if (!(m.toVersion > lastVersion)) {
91
+ throw new Error(`migration '${m.id}' has toVersion ${m.toVersion}, not above ${lastVersion} — order is the numeric prefix`);
92
+ }
93
+ lastVersion = m.toVersion;
94
+ for (const k of ['component', 'detect', 'plan', 'steps']) {
95
+ if (!m[k]) throw new Error(`migration '${m.id}' is missing '${k}'`);
96
+ }
97
+ }
98
+ return out;
99
+ }
100
+
101
+ /**
102
+ * Which migrations this house still needs, in run order.
103
+ * @returns [{ migration, found }] — empty when the house is current.
104
+ */
105
+ async function detectPending(q, ctx, { component = null } = {}) {
106
+ const pending = [];
107
+ for (const m of listMigrations()) {
108
+ if (component && m.component !== component) continue;
109
+ const found = await m.detect(q, ctx);
110
+ if (found && (!Array.isArray(found) || found.length)) pending.push({ migration: m, found });
111
+ }
112
+ return pending;
113
+ }
114
+
115
+ /**
116
+ * Refuse when another actor's run is unfinished. An `applied` newer than the last
117
+ * `pending` means done; a dangling `pending` means someone is (or was) mid-copy, and two
118
+ * concurrent rebuilds of one room end with one of them renaming the other's work.
119
+ * Best-effort: a house without house_events cannot answer, and that is not a reason to
120
+ * block the migration that will create it.
121
+ */
122
+ async function unfinishedBy(q, ctx, migrationId) {
123
+ try {
124
+ const rows = await q.rows(
125
+ `SELECT argMax(status, event_at) AS status, argMax(actor, event_at) AS actor,
126
+ formatDateTime(max(event_at), '%Y-%m-%d %H:%i') AS at
127
+ FROM house_events WHERE kind = 'migration' AND id = '${migrationId}'`);
128
+ const r = rows[0];
129
+ if (r && r.status === 'pending' && r.actor && r.actor !== ctx.member) return r;
130
+ } catch { /* no events table yet */ }
131
+ return null;
132
+ }
133
+
134
+ // ── executors ─────────────────────────────────────────────────────────────────────
135
+
136
+ /**
137
+ * Copy a room into one built from the current template, swap atomically, keep the old
138
+ * one as `<name>_pre_<suffix>`. See the invariants at the top of this file.
139
+ */
140
+ async function rebuildRoom(q, ctx, { type, name, keepSuffix }, ui) {
141
+ const tmp = `${name}__migrating`;
142
+ const kept = `${name}_pre_${keepSuffix}`;
143
+ for (const clash of [tmp, kept]) {
144
+ const exists = await q.rows(`SELECT name FROM system.tables WHERE database = '${ctx.db}' AND name = '${clash}'`);
145
+ if (exists.length) {
146
+ throw new Error(`${clash} already exists — an earlier migration did not finish.\n`
147
+ + ` inspect it, then rename or drop it yourself: DROP TABLE ${ctx.db}.${clash}`);
148
+ }
149
+ }
150
+ const before = Number((await q.rows(`SELECT count() AS c FROM ${name} FINAL`))[0]?.c || 0);
151
+
152
+ const oldCols = (await q.rows(`SELECT name FROM system.columns WHERE database = '${ctx.db}' AND table = '${name}'`)).map((c) => c.name);
153
+ await q.sql(createStatement(ctx.tpl, type, tmp), { allow_experimental_full_text_index: 1 });
154
+ const newCols = (await q.rows(`SELECT name FROM system.columns WHERE database = '${ctx.db}' AND table = '${tmp}'`)).map((c) => c.name);
155
+ const carried = newCols.filter((c) => oldCols.includes(c) && !['text_ngram', 'text_word'].includes(c));
156
+ const list = carried.join(', ');
157
+ const copySettings = { insert_allow_materialized_columns: 1, allow_experimental_full_text_index: 1 };
158
+ // The snapshot the late-writes pass filters against — taken with a MARGIN. An insert
159
+ // that began before this timestamp can commit after the big copy has read its parts,
160
+ // carrying an ingested_at OLDER than the snapshot: filtered at exactly `started`, such
161
+ // a row is missed by the copy AND the late pass, and dropping the kept table would
162
+ // then lose it. Five minutes covers any realistic insert-in-flight window, and the
163
+ // overlap costs nothing — ReplacingMergeTree dedupes re-copied rows.
164
+ const started = (await q.rows("SELECT toString(now64(3, 'UTC') - INTERVAL 300 SECOND) AS t"))[0].t;
165
+
166
+ await q.sql(`INSERT INTO ${tmp} (${list}) SELECT ${list} FROM ${name} FINAL`, copySettings);
167
+ const copied = Number((await q.rows(`SELECT count() AS c FROM ${tmp}`))[0]?.c || 0);
168
+ if (copied < before) throw new Error(`copied ${copied} of ${before} rows — refusing to swap`);
169
+
170
+ await q.sql(`RENAME TABLE ${name} TO ${kept}, ${tmp} TO ${name}`);
171
+ let late = false;
172
+ if (carried.includes('ingested_at')) {
173
+ try {
174
+ await q.sql(`INSERT INTO ${name} (${list}) SELECT ${list} FROM ${kept} FINAL WHERE ingested_at >= toDateTime64('${started}', 3, 'UTC')`, copySettings);
175
+ late = true;
176
+ } catch (e) {
177
+ // Best-effort must not mean SILENT. The rows are not lost — they sit in the kept
178
+ // table — but a shipper that wrote during the copy has its newest rows stranded
179
+ // there until someone re-runs this insert, and nobody re-runs what nobody was told
180
+ // about.
181
+ ui.warn(`${name}: the late-writes pass failed (${e.message.split('\n')[0]})`);
182
+ ui.warn(` rows written during the copy are still in ${kept}; recover them with:`);
183
+ ui.warn(` INSERT INTO ${name} (${list}) SELECT ${list} FROM ${kept} FINAL WHERE ingested_at >= toDateTime64('${started}', 3, 'UTC')`);
184
+ }
185
+ }
186
+ const after = Number((await q.rows(`SELECT count() AS c FROM ${name} FINAL`))[0]?.c || 0);
187
+ ui.ok(`${name}: ${after} rows, old room kept as ${kept}`);
188
+ return { before, after, kept, late };
189
+ }
190
+
191
+ /**
192
+ * ADD COLUMN anything the template declares that the live table lacks — for objects a
193
+ * migration does NOT rebuild. `sessions` keeps its key across schema 2 but still gains
194
+ * `epoch`; without this, the very next ship opens with a "fields are being DISCARDED"
195
+ * warning and sends the pilot to a second command.
196
+ */
197
+ async function healColumns(q, ctx, { type, name }, ui) {
198
+ const { templateColumns } = require('../shipper/ship');
199
+ const want = templateColumns(ctx.tpl);
200
+ const have = new Set((await q.rows(`SELECT name FROM system.columns WHERE database = '${ctx.db}' AND table = '${name}'`)).map((c) => c.name));
201
+ if (!have.size) return;
202
+ for (const col of (want[type] || [])) {
203
+ if (have.has(col.name)) continue;
204
+ await q.sql(`ALTER TABLE ${name} ADD COLUMN IF NOT EXISTS ${col.name} ${col.type}`,
205
+ { allow_experimental_full_text_index: 1 });
206
+ ui.ok(`${name}: added missing column ${col.name}`);
207
+ }
208
+ }
209
+
210
+ const EXECUTORS = { rebuildRoom, healColumns, sql: async (q, ctx, { statement }, ui) => { await q.sql(statement); } };
211
+
212
+ /**
213
+ * Run one pending migration: ledger pending -> steps -> ledger applied (or failed and
214
+ * rethrow). `ledger` is injected ({ event(fields), meta(key, value) }, both best-effort)
215
+ * because the record must never be the reason a rebuild fails.
216
+ */
217
+ async function runMigration(q, ctx, { migration, found }, { ledger, ui }) {
218
+ // One migration-level pending marker FIRST, before any step. The per-step pair below
219
+ // records progress; this one exists for the OTHER migrator — unfinishedBy() reads it,
220
+ // and without it two `memhouse update`s sitting at their confirm prompts both saw a
221
+ // clean ledger and both proceeded on yes. (The room-level clash checks still make the
222
+ // race non-destructive — the loser fails on __migrating/CREATE — but failing cleanly
223
+ // beats failing confusingly.)
224
+ await ledger.event({
225
+ kind: 'migration', id: migration.id, status: 'pending', host: ctx.host,
226
+ to_version: String(migration.toVersion), detail: 'migration started',
227
+ });
228
+ const steps = migration.steps(found, ctx);
229
+ for (const step of steps) {
230
+ const exec = EXECUTORS[step.op];
231
+ if (!exec) throw new Error(`migration '${migration.id}' names unknown op '${step.op}'`);
232
+ const label = step.name || step.op;
233
+ // One event pair per step that MOVES data; heal/sql steps ride on the migration's own.
234
+ const record = step.op === 'rebuildRoom';
235
+ if (record) {
236
+ await ledger.event({
237
+ kind: 'migration', id: migration.id, status: 'pending', host: ctx.host,
238
+ to_version: String(migration.toVersion), detail: `${label}`,
239
+ });
240
+ }
241
+ try {
242
+ const r = await exec(q, ctx, step, ui);
243
+ if (record) {
244
+ await ledger.event({
245
+ kind: 'migration', id: migration.id, status: 'applied', host: ctx.host,
246
+ to_version: String(migration.toVersion),
247
+ rows_before: r?.before || 0, rows_after: r?.after || 0,
248
+ detail: `${label}: old room kept as ${r?.kept}${r?.late ? '; late writes copied' : ''}`,
249
+ });
250
+ }
251
+ } catch (e) {
252
+ if (record) {
253
+ await ledger.event({
254
+ kind: 'migration', id: migration.id, status: 'failed', host: ctx.host,
255
+ to_version: String(migration.toVersion), detail: `${label}: ${e.message}`,
256
+ });
257
+ }
258
+ // The MIGRATION-LEVEL failed row, always — the pending marker written before the
259
+ // steps is what unfinishedBy() reads, and left dangling it locked every OTHER
260
+ // member out of `memhouse migrate` forever, with advice (clean the __migrating
261
+ // leftovers) that did not touch the actual lock. argMax(status) = 'failed'
262
+ // unblocks; the next run re-detects and retries.
263
+ await ledger.event({
264
+ kind: 'migration', id: migration.id, status: 'failed', host: ctx.host,
265
+ to_version: String(migration.toVersion), detail: `migration failed at ${label}: ${e.message}`,
266
+ });
267
+ throw e;
268
+ }
269
+ }
270
+ await ledger.event({
271
+ kind: 'migration', id: migration.id, status: 'applied', host: ctx.host,
272
+ to_version: String(migration.toVersion), detail: 'migration completed',
273
+ });
274
+ await ledger.meta('schema_version', String(migration.toVersion));
275
+ }
276
+
277
+ module.exports = { listMigrations, detectPending, unfinishedBy, runMigration, EXECUTORS };
@@ -0,0 +1,68 @@
1
+ // Schema 1 -> 2: `epoch` joins the transcript rooms' sorting keys.
2
+ //
3
+ // The shipper became insert-only in 0.10.0 — a re-parse that shrinks or diverges is
4
+ // written under a new epoch instead of over the stored parse — and ReplacingMergeTree
5
+ // collapses on the sorting key, so the epoch must BE in the key or the new parse and the
6
+ // old one are the same rows and the old one loses through the merge. ORDER BY cannot be
7
+ // altered in place; hence this rebuild.
8
+ //
9
+ // `sessions` keeps its key (one metadata row per session, deliberately) but gains the
10
+ // `epoch` column, so it is healed rather than rebuilt.
11
+
12
+ const { ROOM_TYPES, keyProblem } = require('../house');
13
+
14
+ module.exports = {
15
+ id: '0100-epoch-key',
16
+ component: 'rooms',
17
+ toVersion: 2,
18
+
19
+ async detect(q, ctx) {
20
+ const found = [];
21
+ for (const t of ROOM_TYPES) {
22
+ const name = ctx.rooms[`${t}_raw`];
23
+ const rows = await q.rows(`SELECT sorting_key AS k FROM system.tables WHERE database = '${ctx.db}' AND name = '${name}'`);
24
+ // A missing room is install's problem, not a migration's — there is nothing to carry.
25
+ if (!rows.length) continue;
26
+ const problem = keyProblem(t, rows[0].k);
27
+ if (problem) { found.push({ t, name, key: rows[0].k, rebuild: true }); continue; }
28
+ // A correct key is not the whole generation: `sessions` keeps its key and gains
29
+ // `epoch` by ALTER. Detect keyed on keys alone went blind to a failed heal — a run
30
+ // that died between the last rebuild and the column heal re-detected as "nothing
31
+ // pending", stamped schema 2, and the missing column stayed missing while the
32
+ // failure text promised that re-running was enough.
33
+ const cols = await q.rows(`SELECT name FROM system.columns WHERE database = '${ctx.db}' AND table = '${name}' AND name = 'epoch'`);
34
+ if (!cols.length) found.push({ t, name, healOnly: true });
35
+ }
36
+ return found;
37
+ },
38
+
39
+ plan(found) {
40
+ const rebuilds = found.filter((x) => x.rebuild);
41
+ const heals = found.filter((x) => x.healOnly);
42
+ return [
43
+ ...(rebuilds.length ? [
44
+ `These rooms predate schema ${this.toVersion} and the shipper refuses to write into them:`,
45
+ '',
46
+ ...rebuilds.map((x) => ` ${x.name} (${x.key})`),
47
+ '',
48
+ 'Each is copied into a room with the current key, swapped in atomically, and the',
49
+ 'old one kept as <room>_pre_epoch. Nothing is deleted — you drop those when ready.',
50
+ ] : []),
51
+ ...(heals.length ? [
52
+ `These rooms keep their key but are missing this generation's columns (added in place):`,
53
+ ...heals.map((x) => ` ${x.name}`),
54
+ ] : []),
55
+ ];
56
+ },
57
+
58
+ steps(found, ctx) {
59
+ const rebuilds = found.filter((x) => x.rebuild);
60
+ return [
61
+ ...rebuilds.map((x) => ({ op: 'rebuildRoom', type: x.t, name: x.name, keepSuffix: 'epoch' })),
62
+ // Heal EVERY room the rebuild does not touch — the detected healOnly ones and the
63
+ // already-correct rest alike; ADD COLUMN IF NOT EXISTS makes the latter free.
64
+ ...ROOM_TYPES.filter((t) => !rebuilds.some((x) => x.t === t))
65
+ .map((t) => ({ op: 'healColumns', type: t, name: ctx.rooms[`${t}_raw`] })),
66
+ ];
67
+ },
68
+ };