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.
- package/README.md +84 -47
- package/bin/memhouse.js +993 -89
- package/editors/antigravity.js +7 -15
- package/editors/cursor.js +18 -24
- package/editors/goose.js +9 -21
- package/editors/index.js +19 -70
- package/editors/opencode.js +6 -15
- package/editors/sqlite.js +74 -0
- package/editors/windsurf.js +3 -3
- package/editors/zed.js +10 -15
- package/memhouse/COMPETITION.md +9 -5
- package/memhouse/DESIGN.md +37 -12
- package/memhouse/RESUME-AND-UPDATE.md +7 -10
- package/memhouse/delivery/AGENT-INSTALL-PROMPT.md +10 -2
- package/memhouse/delivery/AGENT-INSTALL.md +56 -32
- package/memhouse/delivery/PROMPT.md +37 -13
- package/memhouse/delivery/kernel-install.md +4 -4
- package/memhouse/delivery/plugin/.claude-plugin/plugin.json +14 -5
- package/memhouse/delivery/plugin/skills/ask/SKILL.md +112 -0
- package/memhouse/delivery/plugin/skills/hello/SKILL.md +76 -0
- package/memhouse/delivery/plugin/skills/invite/SKILL.md +65 -0
- package/memhouse/delivery/plugin/skills/search/SKILL.md +1 -1
- package/memhouse/delivery/plugin/skills/sessions/SKILL.md +3 -3
- package/memhouse/delivery/plugin/skills/share/SKILL.md +130 -0
- package/memhouse/delivery/plugin/skills/sql/SKILL.md +34 -11
- package/memhouse/delivery/plugin/skills/status/SKILL.md +114 -0
- package/memhouse/delivery/plugin/skills/users/SKILL.md +97 -0
- package/memhouse/house/HOUSE.md +40 -5
- package/memhouse/house/house.js +191 -10
- package/memhouse/house/migrate.js +277 -0
- package/memhouse/house/migrations/0100-epoch-key.js +68 -0
- package/memhouse/house/schema.sql.tpl +96 -25
- package/memhouse/self-update.js +4 -4
- package/memhouse/service.js +7 -2
- package/memhouse/shipper/ship.js +471 -131
- package/package.json +3 -6
package/memhouse/house/house.js
CHANGED
|
@@ -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
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
* one
|
|
57
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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
|
+
};
|