memhouse 0.9.0 → 0.11.1

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.
@@ -14,11 +14,18 @@
14
14
  // - DateTime64 values travel as 'YYYY-MM-DD HH:MM:SS.mmm' UTC strings (plain
15
15
  // format; ISO 'T'/'Z' forms parse unreliably under JSONEachRow). Nullable → null.
16
16
  // - Int64-bound values are integer-coerced (some adapters emit fractional ms).
17
+ // - INSERT-ONLY. The shipper never deletes and never mutates. A re-parse that shrinks
18
+ // or diverges from what is stored is written under a NEW epoch, so the superseded
19
+ // parse survives intact — Claude Code compacts transcripts and deletes them after
20
+ // cleanupPeriodDays (30 by default), which makes the house the only remaining copy.
21
+ // memhouse is an accumulator, not a mirror: absence of a session is never a signal,
22
+ // and no "sync" or "prune" feature may ever be built here.
17
23
  // - Re-shipping is always safe: ReplacingMergeTree(ingested_at) collapses to
18
- // latest-wins at FINAL. Keyed (session_id, user_id, origin, seq) on messages and
19
- // (session_id, user_id, origin, idx) on tool_calls, so an imported row and a shipped
20
- // one at the same seq are two rows, not one. sessions is (session_id, user_id) with
21
- // NO origin — one metadata row per session is what every read path assumes.
24
+ // latest-wins at FINAL. Keyed (session_id, user_id, origin, epoch, seq) on messages
25
+ // and (…, epoch, idx) on tool_calls, so an imported row and a shipped one at the same
26
+ // seq are two rows, and so are two parses of the same session. sessions is
27
+ // (session_id, user_id) with NO origin and NO epoch — one metadata row per session is
28
+ // what every read path assumes.
22
29
  //
23
30
  // CLI: node ship.js one incremental pass
24
31
  // node ship.js --loop [sec] repeat every sec seconds (default 300)
@@ -52,7 +59,14 @@ const selfUpdate = require('../self-update');
52
59
  // Captured at require time, which is as close to "what this process booted with" as it
53
60
  // gets. Taking it later would record whatever an upgrade had already replaced.
54
61
  const selfSnap = selfUpdate.snapshot(__filename);
55
- const { resolveRooms, READ_SETTINGS, ROOM_TYPES } = require('../house/house');
62
+ const {
63
+ resolveRooms, READ_SETTINGS, ROOM_TYPES, META_TYPES, SCHEMA_VERSION, MEMBER_PIN,
64
+ SUPPORTED_SCHEMAS, keyProblem,
65
+ } = require('../house/house');
66
+ // Rooms plus the house's own record of itself. Every table the template declares, which
67
+ // is what the column healer and the drift warning have to cover — a column added to
68
+ // house_events would otherwise roll out to nobody.
69
+ const ALL_TABLES = [...ROOM_TYPES, ...META_TYPES];
56
70
 
57
71
  const BATCH_ROWS = 2000; // insert batch ceiling (binding)
58
72
  const TEXT_MAX = 50000; // messages.text truncation
@@ -263,11 +277,19 @@ function makeClient() {
263
277
  *
264
278
  * MATERIALIZED and DEFAULT clauses are kept: `user_id String MATERIALIZED currentUser()`
265
279
  * has to be added exactly that way or the identity stamp does not happen.
280
+ *
281
+ * The room name in this pattern is the LITERAL table name. It used to be
282
+ * `<type>_{{MEMBER}}`, from the per-member layout, and nobody updated it when the rooms
283
+ * became plain shared tables — so it matched nothing, and every consumer silently got an
284
+ * empty column list. That took out three surfaces at once, all of them reporting success:
285
+ * the column healer in ensureSchema had nothing to add, warnMissingColumns had nothing to
286
+ * warn about, and doctor's column check printed "✓ columns: every room matches the schema
287
+ * template" having compared zero columns. A regression test now pins it (misc/unit-test.js).
266
288
  */
267
289
  function templateColumns(tpl) {
268
290
  const out = {};
269
- for (const t of ROOM_TYPES) {
270
- const re = new RegExp(`CREATE TABLE IF NOT EXISTS ${t}_\\{\\{MEMBER\\}\\}\\s*\\(([\\s\\S]*?)\\n\\)`, 'm');
291
+ for (const t of ALL_TABLES) {
292
+ const re = new RegExp(`CREATE TABLE IF NOT EXISTS ${t}\\s*\\(([\\s\\S]*?)\\n\\)`, 'm');
271
293
  const m = tpl.match(re);
272
294
  if (!m) continue;
273
295
  const cols = [];
@@ -308,15 +330,15 @@ async function warnMissingColumns(client, rooms) {
308
330
  catch { return; }
309
331
  const want = templateColumns(tpl);
310
332
  const missing = [];
311
- for (const t of ROOM_TYPES) {
333
+ for (const t of ALL_TABLES) {
312
334
  try {
313
335
  const rs = await client.query({
314
336
  query: 'SELECT name FROM system.columns WHERE database = currentDatabase() AND table = {n:String}',
315
- query_params: { n: rooms[t] }, format: 'JSONEachRow',
337
+ query_params: { n: rooms[`${t}_raw`] }, format: 'JSONEachRow',
316
338
  });
317
339
  const have = new Set((await rs.json()).map((r) => r.name));
318
340
  if (!have.size) continue;
319
- for (const c of (want[t] || [])) if (!have.has(c.name)) missing.push(`${rooms[t]}.${c.name}`);
341
+ for (const c of (want[t] || [])) if (!have.has(c.name)) missing.push(`${rooms[`${t}_raw`]}.${c.name}`);
320
342
  } catch { /* unreadable rooms are assertRoomsExist's problem */ }
321
343
  }
322
344
  if (missing.length) {
@@ -329,11 +351,11 @@ async function warnMissingColumns(client, rooms) {
329
351
  async function assertRoomsExist(client, rooms) {
330
352
  const rs = await client.query({
331
353
  query: `SELECT name FROM system.tables WHERE database = currentDatabase() AND name IN ({n:Array(String)})`,
332
- query_params: { n: ROOM_TYPES.map((t) => rooms[t]) },
354
+ query_params: { n: ROOM_TYPES.map((t) => rooms[`${t}_raw`]) },
333
355
  format: 'JSONEachRow',
334
356
  });
335
357
  const have = new Set((await rs.json()).map((r) => r.name));
336
- const missing = ROOM_TYPES.map((t) => rooms[t]).filter((n) => !have.has(n));
358
+ const missing = ROOM_TYPES.map((t) => rooms[`${t}_raw`]).filter((n) => !have.has(n));
337
359
  if (!missing.length) return;
338
360
  throw new Error(
339
361
  `'${rooms.member}' has no ${missing.join(', ')} in this house.\n`
@@ -343,46 +365,47 @@ async function assertRoomsExist(client, rooms) {
343
365
  + ' A house from before 0.4 reaches this too — its rooms have different names.');
344
366
  }
345
367
 
346
- async function assertOriginKeyed(client, rooms) {
368
+ /**
369
+ * Refuse to write into rooms whose sorting key is not the one the shipper's safety
370
+ * depends on. What each column buys is written out on ROOM_KEYS in ../house/house.js;
371
+ * this is the enforcement, run at the top of EVERY pass rather than only in
372
+ * `--ensure-schema`, because a plain `memhouse ship` never calls ensureSchema and a guard
373
+ * living only there is a guard that never runs on the path that does the damage.
374
+ */
375
+ async function assertRoomKeys(client, rooms) {
347
376
  const wrong = [];
348
377
  for (const t of ROOM_TYPES) {
349
378
  const rs = await client.query({
350
379
  query: `SELECT sorting_key FROM system.tables WHERE database = currentDatabase() AND name = {n:String}`,
351
- query_params: { n: rooms[t] }, format: 'JSONEachRow',
380
+ query_params: { n: rooms[`${t}_raw`] }, format: 'JSONEachRow',
352
381
  });
353
382
  const row = (await rs.json())[0];
354
- const key = (row || {}).sorting_key || '';
355
- // A room that EXISTS but has no sorting key is not a MergeTree — a Merge, a View, a Log
356
- // engine standing where the shipper expects to DELETE and INSERT. Skipping it here let
357
- // the pass sail past its own guard and die later on
358
- // `DELETE query is not supported for table …`, which is exactly what this refusal is
359
- // meant to prevent. (A room that does not exist at all is assertRoomsExist's job and is
360
- // reported there.)
361
- if (row && !key) { wrong.push(`${rooms[t]} — not a MergeTree, so it cannot be shipped to`); continue; }
362
- if (!key) continue;
363
- const keyed = /\borigin\b/.test(key);
364
- // sessions is one row per session and must NOT key on origin; the transcript rooms
365
- // hold many rows per session and must.
366
- const want = t !== 'sessions';
367
- if (keyed !== want) {
368
- wrong.push(`${rooms[t]} (${key}) — origin ${want ? 'missing from' : 'must not be in'} the key`);
369
- }
383
+ // A room that does not exist at all is assertRoomsExist's job and is reported there.
384
+ if (!row) continue;
385
+ const problem = keyProblem(t, row.sorting_key);
386
+ if (problem) wrong.push(`${rooms[`${t}_raw`]} ${problem}`);
370
387
  }
371
388
  if (wrong.length) {
372
389
  throw new Error(
373
- 'these rooms have the wrong sorting key, so a ship pass would corrupt them:\n'
390
+ 'this house needs a one-time migration before it can be shipped to. Run:\n'
391
+ + '\n memhouse migrate\n'
392
+ + '\n (an upgrade from a pre-0.10 memhouse cannot prompt for this itself — the old\n'
393
+ + ' `update` command predates migrations, so run the line above once, by hand.)\n'
394
+ + '\n Why: these rooms have the wrong sorting key, so a ship pass would corrupt them:\n'
374
395
  + wrong.map((s) => ` ${s}`).join('\n')
375
- + '\n ORDER BY cannot be altered in place. Rebuild each room, then re-ship:'
376
- + '\n RENAME TABLE <room> TO <room>_old;'
377
- + `\n -- recreate from ${path.join(__dirname, '..', 'house', 'schema.sql.tpl')}`
378
- + '\n INSERT INTO <room> SELECT * FROM <room>_old;'
379
- + '\n (add `, \'ship\' AS origin` to the SELECT only if <room>_old has no origin column)'
380
- + '\n A house with no imported rows can also just be re-shipped from scratch.');
396
+ + '\n ORDER BY cannot be altered in place, so migrate copies each room into one with\n'
397
+ + ' the current key, swaps them atomically, and keeps the old one as <room>_pre_epoch\n'
398
+ + ' for you to drop. Nothing is deleted.');
381
399
  }
382
400
  }
383
401
 
384
402
  async function ensureSchema(client) {
385
403
  const rooms = await resolveRooms(client);
404
+ // BEFORE the CREATEs and ALTERs, not beside the end-of-function asserts. A house whose
405
+ // record says a newer release moved it forward may have columns this template does not
406
+ // know; running this template's DDL first could add back what that release removed —
407
+ // the exact write the guard exists to prevent.
408
+ await assertWriterSupported(client, rooms);
386
409
  const tpl = fs.readFileSync(path.join(__dirname, '..', 'house', 'schema.sql.tpl'), 'utf-8');
387
410
  const sql = tpl; // plain shared tables — nothing to render
388
411
  const stripped = sql.split('\n').map((l) => l.replace(/--.*$/, '')).join('\n');
@@ -418,10 +441,11 @@ async function ensureSchema(client) {
418
441
  console.error(`[memhouse] ${denied} schema statement(s) needed rights you do not hold — continuing with what you can do.`);
419
442
  console.error('[memhouse] creating or replacing a ROOM is the house owner\'s job; adding a missing COLUMN is not.');
420
443
  }
421
- // A house created before origin existed has no such column, and the clear binds it —
422
- // an unguarded DELETE there would be the old destructive behaviour, and a guarded one
423
- // would error. Add it in place; ReplacingMergeTree backfills the DEFAULT, so every
424
- // pre-existing row reads as 'ship', which is what it was.
444
+ // A house created before origin existed has no such column, and every read and write
445
+ // scopes by it. Add it in place; ReplacingMergeTree backfills the DEFAULT, so every
446
+ // pre-existing row reads as 'ship', which is what it was. (Having the COLUMN is not
447
+ // having it in the sorting KEY — that is assertRoomKeys' refusal and migrate-rooms'
448
+ // job, because ORDER BY cannot be altered.)
425
449
  //
426
450
  // EVERY column the template declares, not just `origin`.
427
451
  //
@@ -435,60 +459,198 @@ async function ensureSchema(client) {
435
459
  // It also made the grant a lie: rooms.js and INSTALL.md both say ALTER ADD COLUMN is
436
460
  // granted "for ensureSchema's rollout", and no rollout of anything but `origin` existed.
437
461
  const wantCols = templateColumns(tpl);
438
- for (const t of ROOM_TYPES) {
462
+ for (const t of ALL_TABLES) {
439
463
  let have = new Set();
440
464
  try {
441
465
  const rs = await client.query({
442
466
  query: 'SELECT name FROM system.columns WHERE database = currentDatabase() AND table = {n:String}',
443
- query_params: { n: rooms[t] }, format: 'JSONEachRow',
467
+ query_params: { n: rooms[`${t}_raw`] }, format: 'JSONEachRow',
444
468
  });
445
469
  have = new Set((await rs.json()).map((r) => r.name));
446
470
  } catch { /* unreadable: the checks above already reported why */ }
471
+ // No columns means the table is not there — the CREATEs above were denied, or this is
472
+ // a house where the meta tables were never created. ALTERing it would throw
473
+ // UNKNOWN_TABLE, which is not a permissions error and so prints, once per column, on
474
+ // every `--ensure-schema`. Nothing to heal on a table that does not exist.
475
+ if (!have.size) continue;
447
476
  for (const col of (wantCols[t] || [])) {
448
- if (have.size && have.has(col.name)) continue;
477
+ if (have.has(col.name)) continue;
449
478
  try {
450
479
  await client.command({
451
- query: `ALTER TABLE ${rooms[t]} ADD COLUMN IF NOT EXISTS ${col.name} ${col.type}`,
480
+ query: `ALTER TABLE ${rooms[`${t}_raw`]} ADD COLUMN IF NOT EXISTS ${col.name} ${col.type}`,
452
481
  clickhouse_settings: { allow_experimental_full_text_index: 1 },
453
482
  });
454
- if (have.size) console.log(`[memhouse] added missing column ${rooms[t]}.${col.name}`);
483
+ console.log(`[memhouse] added missing column ${rooms[`${t}_raw`]}.${col.name}`);
455
484
  } catch (e) {
456
485
  const m = e && e.message ? e.message : String(e);
457
486
  if (!/Not enough privileges|ACCESS_DENIED/i.test(m)) {
458
- console.error(`[memhouse] could not add ${rooms[t]}.${col.name}: ${m}`);
487
+ console.error(`[memhouse] could not add ${rooms[`${t}_raw`]}.${col.name}: ${m}`);
459
488
  }
460
489
  }
461
490
  }
462
- try {
463
- await client.command({
464
- query: `ALTER TABLE ${rooms[t]} ADD COLUMN IF NOT EXISTS origin LowCardinality(String) DEFAULT 'ship'`,
465
- // 25.11 refuses ANY alter on a table carrying the messages text indexes unless
466
- // this is set — including an ADD COLUMN that has nothing to do with them
467
- // (Code: 344, SUPPORT_IS_DISABLED). Without it the backfill fails silently and
468
- // the guard ends up missing on precisely the houses that need upgrading. A no-op
469
- // on 26.x, and query-scoped, so it needs no server config.
470
- clickhouse_settings: { allow_experimental_full_text_index: 1 },
471
- });
472
- } catch (e) {
473
- // No rights to alter is genuinely not fatal — a member on someone else's house
474
- // cannot roll a column out and does not need to. But this used to swallow
475
- // EVERYTHING, including the 25.11 Code 344 this call carries a setting to avoid, and
476
- // `schema ensured` printed regardless. Name anything that is not a permissions
477
- // problem, and keep going.
478
- const m = e && e.message ? e.message : String(e);
479
- if (!/Not enough privileges|ACCESS_DENIED/i.test(m)) {
480
- console.error(`[memhouse] could not add the 'origin' column to ${rooms[t]}: ${m}`);
481
- console.error('[memhouse] the room still works; a house that predates 0.4.4 needs rebuilding — memhouse doctor');
482
- }
483
- }
484
491
  }
492
+ // There used to be a second, hardcoded `ADD COLUMN … origin` here, from when the healer
493
+ // above could only add that one column. It is redundant now that the generic loop reads
494
+ // the template correctly — and it would have been actively wrong once this loop covered
495
+ // house_meta and house_events, which have no origin and want none.
485
496
 
486
497
  await assertRoomsExist(client, rooms);
487
- await assertOriginKeyed(client, rooms);
498
+ await assertRoomKeys(client, rooms);
488
499
  await warnMissingColumns(client, rooms);
500
+ // Re-assert the async-insert pin. A 0.11 member holds ALTER USER on themselves (so
501
+ // `memhouse passwd` needs no admin) — and that same grant lets them drop the pin
502
+ // (`ALTER USER <self> SETTINGS NONE`), which on some ClickHouse versions makes their
503
+ // OWN async inserts land with an empty user_id. It cannot forge another identity
504
+ // (currentUser() is server-side) and doctor flags any blank user_id, but re-adding the
505
+ // pin here means an accidental or transient drop self-heals on the next --ensure-schema
506
+ // (install, update, migrate). Best-effort: a member who cannot ADD SETTING just stays
507
+ // as they were.
508
+ try { await client.command({ query: `ALTER USER \`${rooms.user}\` ADD SETTING ${MEMBER_PIN}`, clickhouse_settings: { async_insert: 0 } }); }
509
+ catch { /* no self-alter grant (pre-0.11), or not permitted — leave it */ }
510
+ // The rooms are at this schema generation — the assertion above is what makes that a
511
+ // fact rather than a claim. Recording it here means a house built by `install` is
512
+ // already stamped, instead of looking un-migrated until its first ship pass.
513
+ //
514
+ // MEMHOUSE_NO_RECORD is the invite path: `memhouse invite` runs this only to PROVE the
515
+ // new member can build the rooms, on a throwaway home. Recording would stamp a phantom
516
+ // <member>@<ephemeral-host> writer into a house the inviter never ships to — the
517
+ // invitee mints their real identity on their own first pass.
518
+ if (process.env.MEMHOUSE_NO_RECORD !== '1') await recordHouseState(client, rooms, hostId());
489
519
  return stmts.length;
490
520
  }
491
521
 
522
+ /**
523
+ * Keep the house's record of itself current: which schema generation it is at, and which
524
+ * memhouse version is writing into it.
525
+ *
526
+ * Written only when something CHANGED. A row per pass would turn house_events into a
527
+ * heartbeat log — under `--loop` at the default interval that is 288 rows a day per
528
+ * machine, and the one question the table exists to answer ("when did this house move,
529
+ * and who moved it") would be buried in noise.
530
+ *
531
+ * Entirely best-effort. A member on someone else's house may hold no rights on these
532
+ * tables, or the house may predate them; none of that is a reason to stop shipping, so
533
+ * every failure here is swallowed. The rooms are the product, this is the paperwork.
534
+ */
535
+ async function recordHouseState(client, rooms, host) {
536
+ const version = require('../../package.json').version;
537
+ const writer = `${rooms.user}@${host}`;
538
+ try {
539
+ const rs = await client.query({
540
+ query: `SELECT key, value FROM ${rooms.house_meta} FINAL WHERE key IN ('schema_version', {ck:String}, {cs:String})`,
541
+ // Keyed per member AND host. Keyed by member alone, two machines of one member
542
+ // overwrote each other's entry on every pass, so the fleet view could only ever
543
+ // show the machine that shipped last — the exact machine that needs no attention.
544
+ query_params: { ck: `client_version:${writer}`, cs: `client_schema:${writer}` }, format: 'JSONEachRow',
545
+ });
546
+ const have = new Map((await rs.json()).map((r) => [r.key, String(r.value)]));
547
+ const events = [];
548
+ const metas = [];
549
+ if (have.get('schema_version') !== String(SCHEMA_VERSION)) {
550
+ // The rooms are at this generation — assertRoomKeys ran before this and refuses to
551
+ // let a pass reach here otherwise, so recording it is a statement of fact, not a
552
+ // claim about what someone intends to do.
553
+ metas.push({ key: 'schema_version', value: String(SCHEMA_VERSION), host });
554
+ events.push({
555
+ kind: 'schema', status: 'observed', host,
556
+ from_version: have.get('schema_version') || '', to_version: String(SCHEMA_VERSION),
557
+ detail: 'rooms carry the current sorting keys',
558
+ });
559
+ }
560
+ const clientKey = `client_version:${writer}`;
561
+ // The schema this writer SUPPORTS, beside the marketing version — the fleet view
562
+ // judges on this, because a version string cannot be compared against a schema
563
+ // requirement (and a pre-release checkout may not have bumped package.json at all).
564
+ // Tracked on its own change, not the version's: tying it to a version bump left every
565
+ // already-recorded writer without one forever.
566
+ const mySchema = String(Math.max(...SUPPORTED_SCHEMAS));
567
+ if (have.get(`client_schema:${writer}`) !== mySchema) {
568
+ metas.push({ key: `client_schema:${writer}`, value: mySchema, host });
569
+ }
570
+ if (have.get(clientKey) !== version) {
571
+ metas.push({ key: clientKey, value: version, host });
572
+ events.push({
573
+ kind: 'version', status: 'observed', host,
574
+ from_version: have.get(clientKey) || '', to_version: version,
575
+ detail: `${rooms.user} shipping from ${host}`,
576
+ });
577
+ }
578
+ // The heartbeat, EVERY pass — the fleet view's health column. Latest-wins on the key,
579
+ // so it is one live row per writer however often it fires; version/schema above stay
580
+ // change-only so house_events remains a record of moves, not a pulse trace.
581
+ metas.push({ key: `last_ship:${writer}`, value: new Date().toISOString(), host });
582
+ await client.insert({
583
+ table: rooms.house_meta_raw, values: metas, format: 'JSONEachRow',
584
+ clickhouse_settings: { async_insert: 0 },
585
+ });
586
+ if (events.length) {
587
+ await client.insert({
588
+ table: rooms.house_events_raw, values: events, format: 'JSONEachRow',
589
+ clickhouse_settings: { async_insert: 0 },
590
+ });
591
+ }
592
+ } catch { /* the house's paperwork is never worth failing a pass over */ }
593
+ }
594
+
595
+ /**
596
+ * The forward half of the compatibility story: refuse a house whose RECORDED schema this
597
+ * release does not support, before any room is touched.
598
+ *
599
+ * Distinct from assertRoomKeys, which inspects key SHAPES — a future generation could be
600
+ * a data transform the keys do not show. This reads what the house says about itself:
601
+ * house_meta['schema_version'] (what generation the rooms are at) and
602
+ * house_meta['min_writer_schema'] (the floor `memhouse migrate` sets under writers).
603
+ * Too new -> the fix is on THIS machine: memhouse update. Below the floor -> same.
604
+ * (A house OLDER than this release is not an error here — the room checks catch it and
605
+ * name `memhouse migrate`; this guard must not fire on a pre-0.10 house that has no
606
+ * record at all.)
607
+ *
608
+ * Releases before 0.10.0 never read this — for them the floor is enforced by the
609
+ * pilot's GRANTs, not by code.
610
+ */
611
+ async function assertWriterSupported(client, rooms) {
612
+ let have;
613
+ try {
614
+ const rs = await client.query({
615
+ query: `SELECT key, value FROM ${rooms.house_meta} FINAL WHERE key IN ('schema_version', 'min_writer_schema')`,
616
+ format: 'JSONEachRow',
617
+ });
618
+ have = new Map((await rs.json()).map((r) => [r.key, toInt(r.value)]));
619
+ } catch (e) {
620
+ // ONLY a missing table means a pre-0.10 house (the room checks own that case). Any
621
+ // other failure — an ACCESS_DENIED on house_meta, a timeout — used to fall through
622
+ // here too, and a writer that merely could not READ the record was treated as if the
623
+ // record did not exist: on a newer-schema house whose key shapes happen to match,
624
+ // that bypassed the whole compatibility guard. Not being able to check is a reason
625
+ // to refuse, never a reason to proceed.
626
+ const m = e && e.message ? e.message : String(e);
627
+ if (/UNKNOWN_TABLE|Unknown table expression|doesn't exist|does not exist/i.test(m)) return;
628
+ throw new Error(`could not read this house's compatibility record (${m.split('\n')[0]})\n`
629
+ + ' Refusing to write until it is readable — a writer that cannot check the schema\n'
630
+ + ' generation must not assume it matches.');
631
+ }
632
+ const houseSchema = have.get('schema_version') || 0;
633
+ const floor = have.get('min_writer_schema') || 0;
634
+ const mine = Math.max(...SUPPORTED_SCHEMAS);
635
+ // MEMBERSHIP, not a ceiling. `houseSchema <= max` would let a [4]-only writer into a
636
+ // schema-3 house — and if generation 4 was a data transform, the sorting keys match and
637
+ // nothing else refuses. A recorded generation this release does not list is unwritable
638
+ // in either direction; only the remedy differs.
639
+ if (houseSchema > 0 && !SUPPORTED_SCHEMAS.includes(houseSchema)) {
640
+ throw new Error(houseSchema > mine
641
+ ? `this house is at schema ${houseSchema}; this memhouse (${require('../../package.json').version}) supports ${SUPPORTED_SCHEMAS.join(', ')}.\n`
642
+ + ' A newer release moved the house forward. Writing with this one could corrupt it, so it will not.\n'
643
+ + ' Update THIS machine: memhouse update'
644
+ : `this house is at schema ${houseSchema}; this memhouse supports ${SUPPORTED_SCHEMAS.join(', ')}.\n`
645
+ + ' Bring the house forward: memhouse migrate');
646
+ }
647
+ if (floor > mine) {
648
+ throw new Error(
649
+ `this house requires writers at schema ${floor}+; this memhouse supports ${SUPPORTED_SCHEMAS.join(', ')}.\n`
650
+ + ' Update THIS machine: memhouse update');
651
+ }
652
+ }
653
+
492
654
  // Incremental state: what the house already holds, keyed by session_id. We compare
493
655
  // against extra.bubbleCount (the adapter's own cheap count, stored at ship time) —
494
656
  // NOT message_count — because parsed-message count and bubbleCount are different
@@ -496,11 +658,11 @@ async function ensureSchema(client) {
496
658
  // must be decidable WITHOUT calling getMessages on every chat.
497
659
  async function loadExisting(client, rooms) {
498
660
  const rs = await client.query({
499
- query: `SELECT session_id, last_updated_at, message_count, extra FROM ${rooms.sessions} FINAL WHERE user_id = currentUser()`,
661
+ query: `SELECT session_id, last_updated_at, message_count, extra FROM ${rooms.sessions_raw} FINAL WHERE user_id = currentUser()`,
500
662
  format: 'JSONEachRow',
501
663
  });
502
- // Actual message rows per session: an interrupted re-ship (crash between the
503
- // clear-DELETE and the inserts, or mid-flush on a large transcript) leaves a
664
+ // Actual message rows per session: an interrupted re-ship (a crash mid-flush on a
665
+ // large transcript, or between the sessions insert and the messages one) leaves a
504
666
  // fresh-looking session row with a missing or PARTIAL transcript — the skip
505
667
  // must compare the real row count against the recorded message_count, not
506
668
  // merely check that some row exists.
@@ -511,12 +673,40 @@ async function loadExisting(client, rooms) {
511
673
  // on every pass — no data lost, but the incremental skip silently stops existing.
512
674
  // Measured: adding one origin='import' row to a settled session made it re-ship
513
675
  // every pass; removing it froze the session again.
676
+ //
677
+ // GROUPED BY EPOCH, and only the newest epoch's rows count. A session whose parse was
678
+ // superseded still holds every older parse — that is the point of the epoch — so a
679
+ // plain count would exceed message_count forever, `intact` would never be true, and the
680
+ // incremental skip would quietly stop existing for exactly the sessions that have been
681
+ // through a compaction. One row per (session, epoch); epochs are rare, so this stays
682
+ // the same size as the per-session grouping it replaces.
514
683
  const mr = await client.query({
515
- query: `SELECT session_id, countIf(origin = 'ship') AS n FROM ${rooms.messages} FINAL WHERE user_id = currentUser() GROUP BY session_id`,
684
+ query: `SELECT session_id, epoch, count() AS n, max(seq) AS max_seq FROM ${rooms.messages_raw} FINAL
685
+ WHERE user_id = currentUser() AND origin = 'ship' GROUP BY session_id, epoch`,
516
686
  format: 'JSONEachRow',
517
687
  });
518
- const msgCounts = new Map();
519
- for (const r of await mr.json()) msgCounts.set(r.session_id, toInt(r.n));
688
+ // Kept PER EPOCH, not collapsed to each room's own newest — a room can legitimately
689
+ // have no rows at the session's current epoch, and reading its highest epoch instead
690
+ // would answer with the superseded parse. See the tool_calls note below, which is where
691
+ // that actually bites.
692
+ //
693
+ // U+0000 as the key separator, and written as an ESCAPE, deliberately. A session_id is
694
+ // '<source>:<adapter-local id>' and an adapter-local id can legally contain any
695
+ // printable character, so only a byte that cannot appear in one is collision-proof.
696
+ // The escape matters twice over: a literal NUL byte typed here once made grep treat
697
+ // this file as binary — and one of the three key sites was typed with a plain space
698
+ // instead, so the tool lookups missed on every call, `tools.n` read 0, every session
699
+ // with tool calls failed `intact`, and a real 437-session history re-shipped 324
700
+ // sessions on EVERY pass, forever, with every surface green. All three sites must
701
+ // build this key identically; the unit test pins them to each other.
702
+ const at = (m, id, epoch) => m.get(`${id}\u0000${epoch}`) || null;
703
+ const msgState = new Map(); // (session, epoch) → { n, maxSeq }
704
+ const msgEpoch = new Map(); // session → newest epoch present
705
+ for (const r of await mr.json()) {
706
+ const epoch = toInt(r.epoch);
707
+ msgState.set(`${r.session_id}\u0000${epoch}`, { n: toInt(r.n), maxSeq: toInt(r.max_seq) });
708
+ msgEpoch.set(r.session_id, Math.max(epoch, msgEpoch.get(r.session_id) || 0));
709
+ }
520
710
  // The tool_calls room needs the same check, and used to have none. shipSession writes
521
711
  // sessions, then messages, then tool_calls — so a pass that fails during the LAST of
522
712
  // those three leaves a session whose message count matches perfectly. The next pass
@@ -525,11 +715,17 @@ async function loadExisting(client, rooms) {
525
715
  // Reproduced by dropping the room mid-pass; any transient — a ClickHouse restart, a
526
716
  // quota rejection, a network blip — reaches the same state.
527
717
  const tr = await client.query({
528
- query: `SELECT session_id, countIf(origin = 'ship') AS n FROM ${rooms.tool_calls} FINAL WHERE user_id = currentUser() GROUP BY session_id`,
718
+ query: `SELECT session_id, epoch, count() AS n, max(idx) AS max_idx FROM ${rooms.tool_calls_raw} FINAL
719
+ WHERE user_id = currentUser() AND origin = 'ship' GROUP BY session_id, epoch`,
529
720
  format: 'JSONEachRow',
530
721
  });
531
- const toolCounts = new Map();
532
- for (const r of await tr.json()) toolCounts.set(r.session_id, toInt(r.n));
722
+ const toolState = new Map(); // (session, epoch) → { n, maxIdx }
723
+ const toolEpoch = new Map(); // session → newest epoch present
724
+ for (const r of await tr.json()) {
725
+ const epoch = toInt(r.epoch);
726
+ toolState.set(`${r.session_id}\u0000${epoch}`, { n: toInt(r.n), maxIdx: toInt(r.max_idx) });
727
+ toolEpoch.set(r.session_id, Math.max(epoch, toolEpoch.get(r.session_id) || 0));
728
+ }
533
729
  const map = new Map();
534
730
  for (const r of await rs.json()) {
535
731
  // DateTime64 comes back as 'YYYY-MM-DD HH:MM:SS.mmm' — re-parse as UTC.
@@ -540,9 +736,30 @@ async function loadExisting(client, rooms) {
540
736
  // have tool calls re-ship exactly once and then record it. Self-correcting, and
541
737
  // cheaper than a branch that has to be remembered forever.
542
738
  const toolCount = toInt(r.extra && r.extra.toolCallCount);
543
- const intact = (msgCounts.get(r.session_id) || 0) === count
544
- && (toolCounts.get(r.session_id) || 0) === toolCount;
545
- map.set(r.session_id, { ms, count, bc, intact });
739
+ // The session's current epoch is whatever its ROWS say, never what the sessions row
740
+ // says: sessions is latest-wins with no origin in its key, so an import writing that
741
+ // row after a bump would hand the next pass a 0 and make it overwrite the current
742
+ // parse. The column there is for people, not for this.
743
+ const epoch = Math.max(msgEpoch.get(r.session_id) || 0, toolEpoch.get(r.session_id) || 0);
744
+ // Everything else is read AT that epoch, and "no rows there" means zero — not "look
745
+ // at the newest epoch this room happens to have".
746
+ //
747
+ // Getting this wrong does not lose data, it makes the shipper churn forever. A parse
748
+ // that keeps its messages but drops every tool call writes nothing into tool_calls at
749
+ // the new epoch. Reading that room's own newest epoch then returned the SUPERSEDED
750
+ // count (say 2) and its maxIdx — so `intact` compared 2 against the recorded 0 and was
751
+ // false on every pass, and decideEpoch compared `0 - 1 < oldMaxIdx` and bumped again
752
+ // on every pass. The session re-shipped and gained an epoch forever, growing the house
753
+ // without end, while every surface reported success.
754
+ const msgs = at(msgState, r.session_id, epoch) || { n: 0, maxSeq: -1 };
755
+ const tools = at(toolState, r.session_id, epoch) || { n: 0, maxIdx: -1 };
756
+ const intact = msgs.n === count && tools.n === toolCount;
757
+ map.set(r.session_id, {
758
+ ms, count, bc, intact, epoch,
759
+ maxSeq: msgs.maxSeq,
760
+ maxIdx: tools.maxIdx,
761
+ hasRows: msgEpoch.has(r.session_id) || toolEpoch.has(r.session_id),
762
+ });
546
763
  }
547
764
  return map;
548
765
  }
@@ -663,20 +880,124 @@ function rowsForChat(chat, host) {
663
880
  return { session, msgRows, toolRows };
664
881
  }
665
882
 
883
+ /**
884
+ * Which parse epoch this re-ship belongs to, and why.
885
+ *
886
+ * THE RULE: the shipper never writes over stored content that differs from what it is
887
+ * writing. If the new parse can be laid on top of the old one without changing or
888
+ * orphaning anything, it reuses the epoch and ReplacingMergeTree dedupes it exactly as
889
+ * before — the common case, free. Otherwise it moves to a new epoch, where the new rows
890
+ * cannot collide with the old ones, and the old parse stays whole.
891
+ *
892
+ * Two things make a parse un-overwritable, and both are ordinary:
893
+ *
894
+ * SHRINK. A re-parse with fewer messages leaves the old higher-seq rows with nothing
895
+ * written over them. That stale tail is what the DELETE this replaces was written to
896
+ * remove — and the delete could not tell a fixed adapter bug (junk, worth removing)
897
+ * from a compacted or user-deleted transcript (irreplaceable), because from outside
898
+ * they look identical.
899
+ *
900
+ * DIVERGENCE. Claude Code COMPACTS a session in place: same session, rewritten
901
+ * shorter, with different content at low seq numbers. Bumping on shrink alone would
902
+ * preserve the tail and still overwrite the rewritten head — the same loss, reached
903
+ * through the merge instead of the delete. line_hash is already stored per row, so
904
+ * comparing is a lookup, not a re-read of the source.
905
+ *
906
+ * Tool calls carry no hash, so they are compared on (tool_name, args) directly. They are
907
+ * derived from `m._toolCalls`, which can change while the assistant text does not.
908
+ *
909
+ * Pure, and exported: this is the decision the whole redesign rests on, and it is unit
910
+ * tested without a server.
911
+ *
912
+ * @param {object|null} stored { epoch, maxSeq, maxIdx, hashes: Map<seq,string>,
913
+ * tools: Map<idx,{tool_name,args}> } — null when the
914
+ * session is not in the house yet.
915
+ * @param {object} incoming { msgRows, toolRows } as built by rowsForChat.
916
+ * @returns {{ epoch: number, reason: string|null }} reason is null when nothing moved.
917
+ */
918
+ function decideEpoch(stored, incoming) {
919
+ if (!stored) return { epoch: 0, reason: null };
920
+ const epoch = toInt(stored.epoch);
921
+ const bump = (reason) => ({ epoch: epoch + 1, reason });
922
+
923
+ const maxSeq = Number.isFinite(stored.maxSeq) ? stored.maxSeq : -1;
924
+ const maxIdx = Number.isFinite(stored.maxIdx) ? stored.maxIdx : -1;
925
+ if (incoming.msgRows.length - 1 < maxSeq) {
926
+ return bump(`${maxSeq + 1} messages stored, ${incoming.msgRows.length} parsed`);
927
+ }
928
+ if (incoming.toolRows.length - 1 < maxIdx) {
929
+ return bump(`${maxIdx + 1} tool calls stored, ${incoming.toolRows.length} parsed`);
930
+ }
931
+
932
+ // A seq the house does not hold is a gap, not a disagreement — there is nothing there
933
+ // to destroy, so it is not a reason to fork the session.
934
+ const hashes = stored.hashes || new Map();
935
+ for (const row of incoming.msgRows) {
936
+ if (row.seq > maxSeq) break;
937
+ const was = hashes.get(row.seq);
938
+ if (was !== undefined && was !== String(row.line_hash)) {
939
+ return bump(`message ${row.seq} was rewritten`);
940
+ }
941
+ }
942
+ const tools = stored.tools || new Map();
943
+ for (const row of incoming.toolRows) {
944
+ if (row.idx > maxIdx) break;
945
+ const was = tools.get(row.idx);
946
+ if (was !== undefined && (was.tool_name !== row.tool_name || was.args !== row.args)) {
947
+ return bump(`tool call ${row.idx} was rewritten`);
948
+ }
949
+ }
950
+ return { epoch, reason: null };
951
+ }
952
+
953
+ /**
954
+ * What the house currently holds for ONE session, at its current epoch — the input
955
+ * decideEpoch needs to tell an append from a rewrite.
956
+ *
957
+ * Read only for sessions actually being re-shipped, which the incremental skip has
958
+ * already narrowed to a handful; it replaces the two DELETE mutations that used to run
959
+ * per re-shipped session, so a normal pass does strictly less work than before.
960
+ *
961
+ * `toString(line_hash)`: the client is configured with
962
+ * output_format_json_quote_64bit_integers = 0, so a UInt64 arrives as a JSON number and
963
+ * everything above 2^53 comes back rounded. Comparing rounded hashes would report
964
+ * divergence on identical rows and fork a session on every single pass.
965
+ */
966
+ async function loadStoredParse(client, rooms, id, uid, epoch) {
967
+ const params = { id, uid, e: epoch };
968
+ const mr = await client.query({
969
+ query: `SELECT seq, toString(line_hash) AS line_hash FROM ${rooms.messages_raw} FINAL
970
+ WHERE session_id = {id:String} AND user_id = {uid:String} AND origin = 'ship' AND epoch = {e:UInt32}`,
971
+ query_params: params, format: 'JSONEachRow',
972
+ });
973
+ const hashes = new Map();
974
+ for (const r of await mr.json()) hashes.set(toInt(r.seq), String(r.line_hash));
975
+ const tr = await client.query({
976
+ query: `SELECT idx, tool_name, args FROM ${rooms.tool_calls_raw} FINAL
977
+ WHERE session_id = {id:String} AND user_id = {uid:String} AND origin = 'ship' AND epoch = {e:UInt32}`,
978
+ query_params: params, format: 'JSONEachRow',
979
+ });
980
+ const tools = new Map();
981
+ for (const r of await tr.json()) tools.set(toInt(r.idx), { tool_name: r.tool_name, args: r.args });
982
+ return { hashes, tools };
983
+ }
984
+
666
985
  // One shipping pass. Incremental unless opts.full: a chat is skipped when the house
667
986
  // already has it at least as fresh (last_updated_at) and at least as large
668
987
  // (extra.bubbleCount) — both readable without parsing the chat.
669
988
  async function runShip(client, opts = {}) {
670
989
  const { full = false } = opts;
671
990
  const host = hostId();
672
- // Always load what the house holds — even with --full. The skip decision uses it
673
- // only in incremental mode, but re-shipping a KNOWN session must clear its old
674
- // message/tool rows first (a shrunken re-parse leaves stale higher-seq rows
675
- // otherwise: ReplacingMergeTree collapses same-key rows only).
991
+ // Always load what the house holds — even with --full. The skip decision uses it only
992
+ // in incremental mode, but re-shipping a KNOWN session needs its current epoch and row
993
+ // shape: writing at the wrong epoch either forks a session that did not change, or
994
+ // overwrites a stored parse that did.
676
995
  const rooms = await resolveRooms(client);
996
+ await assertWriterSupported(client, rooms);
677
997
  await assertRoomsExist(client, rooms);
678
- await assertOriginKeyed(client, rooms);
998
+ await assertRoomKeys(client, rooms);
679
999
  await warnMissingColumns(client, rooms);
1000
+ await recordHouseState(client, rooms, host);
680
1001
  const existing = await loadExisting(client, rooms);
681
1002
  const chats = getAllChats();
682
1003
 
@@ -693,7 +1014,7 @@ async function runShip(client, opts = {}) {
693
1014
  const flush = async (table) => {
694
1015
  if (!batches[table].length) return;
695
1016
  await client.insert({
696
- table: rooms[table],
1017
+ table: rooms[`${table}_raw`],
697
1018
  values: batches[table],
698
1019
  format: 'JSONEachRow',
699
1020
  clickhouse_settings: { async_insert: 0 }, // binding: user_id stamping breaks otherwise
@@ -706,7 +1027,7 @@ async function runShip(client, opts = {}) {
706
1027
  };
707
1028
 
708
1029
  const seen = new Set(); // adapters must not double-ship a session id within a pass
709
- let sessions = 0, skipped = 0, msgRows = 0, toolRows = 0, unreadable = 0;
1030
+ let sessions = 0, skipped = 0, msgRows = 0, toolRows = 0, unreadable = 0, bumped = 0, withheld = 0;
710
1031
  for (const chat of chats) {
711
1032
  if (chat.encrypted) continue;
712
1033
  // Same canonical '<source>:<adapter-local id>' as rowsForChat: dedup and
@@ -734,11 +1055,13 @@ async function runShip(client, opts = {}) {
734
1055
  }
735
1056
 
736
1057
  // rowsForChat returns null only when getMessages() *throws*. An adapter that
737
- // swallows its own failure returns [] instead, which would look like a session
738
- // that genuinely has no messages — and for a known session the re-ship below
739
- // deletes the old transcript before inserting that emptiness. So treat a
740
- // failure recorded while reading THIS chat as unreadable too: write nothing,
741
- // delete nothing, retry next pass.
1058
+ // swallows its own failure returns [] instead, which would look like a session that
1059
+ // genuinely has no messages — and for a known session that is a maximal shrink, so
1060
+ // the pass would fork it to a new, EMPTY epoch and every read would show nothing
1061
+ // where a transcript used to be. Nothing is destroyed any more, but a house that
1062
+ // reports empty sessions after a locked SQLite file is still wrong. So treat a
1063
+ // failure recorded while reading THIS chat as unreadable: write nothing, retry next
1064
+ // pass.
742
1065
  const errsBefore = adapterErrorSink.recorded().length;
743
1066
  const rows = rowsForChat(chat, host);
744
1067
  const failedHere = adapterErrorSink.recorded().slice(errsBefore).some((e) => e.source === chat.source);
@@ -753,43 +1076,56 @@ async function runShip(client, opts = {}) {
753
1076
  // the skip predicate entirely, so every pass retries it and re-reports it until
754
1077
  // the underlying store is readable.
755
1078
  if (!rows || failedHere) { unreadable++; continue; }
756
- if (prev) {
757
- // Known session being re-shipped: clear its old rows BEFORE inserting so a
758
- // shorter re-parse can't leave stale seq/idx tails. A crash between the
759
- // delete and the inserts is repaired by the next pass: the skip predicate
760
- // refuses to skip a non-empty session whose message rows are missing.
761
- // user_id is BOUND, not `= currentUser()`. A DELETE is a mutation, and a mutation
762
- // does not necessarily evaluate currentUser() in the caller's context — it matches
763
- // nothing at all, so the delete silently removes zero rows and the stale tail this
764
- // code exists to clear survives forever. Measured on ClickHouse 25.11 —
765
- // the identical predicate with the literal value deleted 2000 rows where
766
- // currentUser() deleted 0. The value is the same identity either way: it is read
767
- // from the server over this very connection.
768
- // origin='ship' is the third bind, and it is not cosmetic. This clear exists so a
769
- // shorter re-parse cannot leave a stale seq tail behind — but scoped to
770
- // (session_id, user_id) alone it deletes EVERY row for the session, including rows
771
- // the adapters did not write and cannot rewrite. Measured, on a real house: an
772
- // import of 135,307 messages lost 27,948 of them to one ship pass, because
773
- // memory-house had captured more per session than the adapters emit. The rows the
774
- // shipper owns are the only rows it may remove.
775
- for (const t of ['messages', 'tool_calls']) {
776
- await client.command({
777
- query: `DELETE FROM ${rooms[t]} WHERE session_id = {id:String} AND user_id = {uid:String} AND origin = 'ship'`,
778
- query_params: { id, uid: rooms.user },
779
- clickhouse_settings: { async_insert: 0 },
780
- });
1079
+
1080
+ // A KNOWN session that now parses to nothing is withheld, not written.
1081
+ //
1082
+ // Two reasons, and the second is the one that bites. An adapter that swallows its own
1083
+ // failure returns [] rather than throwing, which is indistinguishable from a chat
1084
+ // whose content really did vanish — and the house is the party that still has the
1085
+ // content either way. Worse, writing it would not even be honest about itself: with
1086
+ // zero rows to insert, the new epoch exists nowhere in the room, so the read filter's
1087
+ // max(epoch) would keep answering with the SUPERSEDED epoch and serve the old parse as
1088
+ // if it were current. Withholding leaves the session out of the skip predicate, so
1089
+ // every pass retries it and says so.
1090
+ if (prev && prev.hasRows && !rows.msgRows.length) {
1091
+ withheld++;
1092
+ console.log(`[memhouse] WARNING: ${id} parsed to 0 messages but the house holds ${prev.count} — withholding`);
1093
+ continue;
1094
+ }
1095
+
1096
+ // Which parse this belongs to. A known session whose stored rows the new parse
1097
+ // cannot be laid over — shorter, or rewritten at an overlapping position — moves to
1098
+ // a new epoch instead of overwriting anything. See decideEpoch.
1099
+ //
1100
+ // This is where a DELETE used to be. It removed the session's shipped rows before
1101
+ // re-inserting them, which is the only way to clear a stale seq tail in a
1102
+ // ReplacingMergeTree — and it destroyed content that existed nowhere else, because
1103
+ // Claude Code compacts transcripts and deletes them after cleanupPeriodDays (30 by
1104
+ // default), so a shorter re-parse is at least as likely to mean "the source lost it"
1105
+ // as "the adapter was fixed". The house is supposed to outlive the source.
1106
+ let epoch = 0;
1107
+ if (prev && prev.hasRows) {
1108
+ const stored = await loadStoredParse(client, rooms, id, rooms.user, prev.epoch);
1109
+ const d = decideEpoch({ ...prev, ...stored }, rows);
1110
+ epoch = d.epoch;
1111
+ if (d.reason) {
1112
+ bumped++;
1113
+ console.log(`[memhouse] ${id} → epoch ${epoch} (${d.reason}); the previous parse is kept`);
781
1114
  }
1115
+ } else if (prev) {
1116
+ epoch = toInt(prev.epoch);
782
1117
  }
1118
+ rows.session.epoch = epoch;
783
1119
  await push('sessions', scrub(rows.session));
784
1120
  sessions++;
785
- for (const r of rows.msgRows) { await push('messages', scrub(r)); msgRows++; }
786
- for (const r of rows.toolRows) { await push('tool_calls', scrub(r)); toolRows++; }
1121
+ for (const r of rows.msgRows) { r.epoch = epoch; await push('messages', scrub(r)); msgRows++; }
1122
+ for (const r of rows.toolRows) { r.epoch = epoch; await push('tool_calls', scrub(r)); toolRows++; }
787
1123
  }
788
1124
  for (const table of Object.keys(batches)) await flush(table);
789
1125
  // Anything that only failed while reading messages — the sink is reset by the
790
1126
  // next getAllChats(), so unreported here means never reported at all.
791
1127
  reportAdapterErrors(warned);
792
- return { sessions, skipped, msgRows, toolRows, unreadable };
1128
+ return { sessions, skipped, msgRows, toolRows, unreadable, bumped, withheld };
793
1129
  }
794
1130
 
795
1131
  // Warn once per adapter per pass. `warned` carries across the two call sites so one
@@ -870,7 +1206,10 @@ async function main() {
870
1206
  let failed = false;
871
1207
  try {
872
1208
  const r = await runShip(client, { full });
873
- console.log(`[memhouse] shipped ${r.sessions} sessions (${r.skipped} skipped${r.unreadable ? `, ${r.unreadable} unreadable-will-retry` : ''}) → ` +
1209
+ console.log(`[memhouse] shipped ${r.sessions} sessions (${r.skipped} skipped`
1210
+ + `${r.unreadable ? `, ${r.unreadable} unreadable-will-retry` : ''}`
1211
+ + `${r.withheld ? `, ${r.withheld} withheld-empty` : ''}`
1212
+ + `${r.bumped ? `, ${r.bumped} kept an earlier parse` : ''}) → ` +
874
1213
  `${r.msgRows} msg rows, ${r.toolRows} tool rows in ${((Date.now() - t0) / 1000).toFixed(1)}s`);
875
1214
  } catch (e) {
876
1215
  failed = true;
@@ -921,7 +1260,7 @@ async function main() {
921
1260
  }
922
1261
  }
923
1262
 
924
- module.exports = { runShip, ensureSchema, templateColumns };
1263
+ module.exports = { runShip, ensureSchema, templateColumns, decideEpoch };
925
1264
 
926
1265
  if (require.main === module) {
927
1266
  main().catch((e) => {
@@ -936,7 +1275,11 @@ if (require.main === module) {
936
1275
  // advice to run `ship --ensure-schema`.
937
1276
  const m = e && e.message ? e.message : String(e);
938
1277
  if (/Not enough privileges|ACCESS_DENIED/i.test(m) && process.env.MEMHOUSE_QUIET_DENIED === '1') process.exit(1);
939
- console.error(`[memhouse] ${/Not enough privileges|ACCESS_DENIED/i.test(m) ? 'refused' : 'fatal'}: ${m}`);
1278
+ // The sorting-key refusal joins the permissions one: both are the shipper DECLINING
1279
+ // to act, with the remediation already in the message. "fatal:" ahead of it made the
1280
+ // most-seen line of the upgrade path — every `--ensure-schema` on a pre-epoch house —
1281
+ // read like a crash.
1282
+ console.error(`[memhouse] ${/Not enough privileges|ACCESS_DENIED|wrong sorting key/i.test(m) ? 'refused' : 'fatal'}: ${m}`);
940
1283
  process.exit(1);
941
1284
  });
942
1285
  }