memhouse 0.18.12 → 0.20.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.
Files changed (169) hide show
  1. package/AGENTS.md +22 -151
  2. package/CHANGELOG.md +263 -29
  3. package/LICENSE +202 -22
  4. package/LICENSES/agentlytics-MIT.txt +21 -0
  5. package/LICENSES/memhouse-MIT-until-0.18.11.txt +22 -0
  6. package/LICENSES/models.dev-MIT.txt +22 -0
  7. package/NOTICE +28 -0
  8. package/README.md +42 -20
  9. package/SECURITY.md +21 -8
  10. package/bin/memhouse.js +2223 -311
  11. package/docs/README.md +11 -9
  12. package/docs/guides/admin-password.md +37 -10
  13. package/docs/guides/configuration.md +13 -6
  14. package/docs/guides/daemons.md +64 -3
  15. package/docs/guides/import-claude-ai.md +204 -0
  16. package/docs/guides/install.md +11 -6
  17. package/docs/guides/invite.md +21 -7
  18. package/docs/guides/operate.md +114 -7
  19. package/docs/guides/search-and-resume.md +72 -3
  20. package/docs/guides/share.md +91 -52
  21. package/docs/guides/storage.md +44 -4
  22. package/docs/guides/troubleshooting.md +121 -14
  23. package/docs/guides/uninstall.md +4 -3
  24. package/docs/guides/update.md +16 -0
  25. package/docs/guides/upgrade-to-0.20.md +188 -0
  26. package/docs/releases/README.md +2 -1
  27. package/docs/releases/v0.18.10.md +5 -5
  28. package/docs/releases/v0.18.11.md +3 -3
  29. package/docs/releases/v0.20.0.md +627 -0
  30. package/docs/releases/v0.20.1.md +114 -0
  31. package/docs/tutorials/01-first-house.md +10 -7
  32. package/docs/tutorials/02-join-a-house.md +24 -15
  33. package/docs/tutorials/03-share-your-memory.md +40 -31
  34. package/editors/antigravity.js +1 -80
  35. package/editors/base.js +463 -9
  36. package/editors/bridge.js +119 -0
  37. package/editors/claude.js +273 -105
  38. package/editors/codex.js +278 -52
  39. package/editors/copilot.js +1 -70
  40. package/editors/cursor.js +1 -75
  41. package/editors/derive-fingerprints.json +18 -0
  42. package/editors/harness-paths.js +69 -0
  43. package/editors/index.js +1 -20
  44. package/editors/normalise/agy.js +910 -0
  45. package/editors/normalise/copilot-cli.js +321 -0
  46. package/editors/normalise/cursor-agent.js +852 -0
  47. package/editors/normalise/cursor.js +474 -0
  48. package/editors/normalise/devin.js +575 -0
  49. package/editors/normalise/gemini-cli.js +602 -0
  50. package/editors/normalise/goose.js +673 -0
  51. package/editors/normalise/opencode.js +633 -0
  52. package/editors/normalise/vscode.js +549 -0
  53. package/editors/normalise/zed.js +473 -0
  54. package/editors/opencode-v4.js +119 -0
  55. package/editors/scope.js +14 -3
  56. package/editors/sessions-log.js +349 -0
  57. package/editors/sqlite.js +28 -1
  58. package/editors/vscode.js +1 -72
  59. package/editors/windsurf.js +1 -143
  60. package/examples/01-local-one-machine/README.md +24 -19
  61. package/examples/01-local-one-machine/run.sh +1 -1
  62. package/examples/02-existing-clickhouse/README.md +30 -22
  63. package/examples/02-existing-clickhouse/run.sh +5 -4
  64. package/examples/03-team-house/README.md +44 -37
  65. package/examples/03-team-house/run.sh +35 -18
  66. package/examples/04-self-hosted-team/README.md +29 -14
  67. package/examples/04-self-hosted-team/compose.yml +5 -2
  68. package/examples/04-self-hosted-team/smoke.sh +26 -12
  69. package/examples/README.md +8 -4
  70. package/examples/fixtures/README.md +4 -1
  71. package/examples/fixtures/make-sessions.js +8 -5
  72. package/memhouse/admin-secret.js +89 -24
  73. package/memhouse/argv-secrets.js +64 -0
  74. package/memhouse/channel.js +49 -9
  75. package/memhouse/codex-plugin.js +194 -0
  76. package/memhouse/delivery/AGENT-INSTALL-PROMPT.md +2 -1
  77. package/memhouse/delivery/AGENT-INSTALL.md +22 -11
  78. package/memhouse/delivery/INVITATION.md.tpl +44 -45
  79. package/memhouse/delivery/PROMPT.md +53 -5
  80. package/memhouse/delivery/UPGRADE.md.tpl +1 -1
  81. package/memhouse/delivery/codex/AGENTS-BLOCK.md +11 -0
  82. package/memhouse/delivery/codex/SKILL.md +50 -0
  83. package/memhouse/delivery/kernel-install.md +17 -9
  84. package/memhouse/delivery/plugin/.claude-plugin/plugin.json +2 -2
  85. package/memhouse/delivery/plugin/reference/HOUSE.md +168 -41
  86. package/memhouse/delivery/plugin/skills/access/SKILL.md +13 -5
  87. package/memhouse/delivery/plugin/skills/admin/SKILL.md +14 -6
  88. package/memhouse/delivery/plugin/skills/house/SKILL.md +25 -8
  89. package/memhouse/delivery/plugin/skills/recall/SKILL.md +66 -8
  90. package/memhouse/delivery/plugin/skills/sql/SKILL.md +66 -7
  91. package/memhouse/deploy.js +1 -1
  92. package/memhouse/flags.js +35 -18
  93. package/memhouse/help.js +37 -0
  94. package/memhouse/host.js +82 -2
  95. package/memhouse/house/HOUSE.md +2 -2
  96. package/memhouse/house/convert-profile.js +638 -0
  97. package/memhouse/house/convert-v3.js +742 -0
  98. package/memhouse/house/doctor-v3.js +324 -0
  99. package/memhouse/house/host-alias.js +85 -0
  100. package/memhouse/house/house.js +3 -3
  101. package/memhouse/house/migrate.js +2 -2
  102. package/memhouse/house/profile-dedupe.js +183 -0
  103. package/memhouse/house/profile-edges.js +554 -0
  104. package/memhouse/house/profile-house.js +430 -0
  105. package/memhouse/house/profile-rollup.js +190 -0
  106. package/memhouse/house/q-cli.js +863 -0
  107. package/memhouse/house/queries-v3.js +1013 -0
  108. package/memhouse/house/reconcile-v3.js +192 -0
  109. package/memhouse/house/records-to-profile.js +174 -0
  110. package/memhouse/house/records-to-rows.js +169 -0
  111. package/memhouse/house/relocate.js +2 -1
  112. package/memhouse/house/schema-choice.js +121 -0
  113. package/memhouse/house/schema-profile.js +290 -0
  114. package/memhouse/house/schema-profile.sql.tpl +398 -0
  115. package/memhouse/house/schema-v3.js +267 -0
  116. package/memhouse/house/schema-v3.sql.tpl +213 -0
  117. package/memhouse/import/claude-ai.js +420 -0
  118. package/memhouse/import/import.js +372 -0
  119. package/memhouse/import/pyjson.js +206 -0
  120. package/memhouse/import/records.js +64 -0
  121. package/memhouse/import/writer-profile.js +192 -0
  122. package/memhouse/import/writer-v3.js +240 -0
  123. package/memhouse/import/zip.js +112 -0
  124. package/memhouse/invitation.js +102 -0
  125. package/memhouse/invitefile.js +5 -2
  126. package/memhouse/plugin-copies.js +176 -0
  127. package/memhouse/provision.js +7 -2
  128. package/memhouse/release-hint.js +24 -0
  129. package/memhouse/resume.js +4 -4
  130. package/memhouse/server/api-v3.js +632 -0
  131. package/memhouse/server/dashboard-auth.js +181 -0
  132. package/memhouse/server/dashboard-port.js +91 -0
  133. package/memhouse/server/queries.js +30 -0
  134. package/memhouse/server/server.js +118 -20
  135. package/memhouse/service.js +1 -1
  136. package/memhouse/share.js +687 -1
  137. package/memhouse/shipper/adapter-capture.js +756 -0
  138. package/memhouse/shipper/capture.js +2571 -0
  139. package/memhouse/shipper/dedupe-profile.js +237 -0
  140. package/memhouse/shipper/dedupe-v3.js +1027 -0
  141. package/memhouse/shipper/forks.js +233 -0
  142. package/memhouse/shipper/log-adapter.js +219 -0
  143. package/memhouse/shipper/maintenance.js +49 -0
  144. package/memhouse/shipper/normalise-run.js +148 -0
  145. package/memhouse/shipper/passlog.js +68 -5
  146. package/memhouse/shipper/profile-capture.js +903 -0
  147. package/memhouse/shipper/ship.js +91 -8
  148. package/memhouse/shipper/snapshots.js +193 -0
  149. package/memhouse/shipper/source-roots.js +111 -0
  150. package/memhouse/version-notice.js +66 -0
  151. package/package.json +14 -7
  152. package/public/THIRD_PARTY_NOTICES.txt +2 -2
  153. package/public/assets/index-CcNnhfM3.js +78 -0
  154. package/public/index.html +1 -1
  155. package/ui/package-lock.json +56 -37
  156. package/ui/package.json +1 -0
  157. package/ui/src/App.jsx +29 -14
  158. package/ui/src/components/ChatSidebar.jsx +69 -12
  159. package/ui/src/lib/api.js +76 -0
  160. package/ui/src/lib/dashboard-token.js +41 -0
  161. package/ui/src/main.jsx +22 -5
  162. package/ui/src/pages/Dashboard.jsx +22 -36
  163. package/ui/third-party-notices.js +119 -0
  164. package/ui/vite.config.js +5 -1
  165. package/docs/releases/v0.18.12.md +0 -59
  166. package/memhouse/COMPETITION.md +0 -221
  167. package/memhouse/DESIGN.md +0 -222
  168. package/memhouse/RESUME-AND-UPDATE.md +0 -182
  169. package/public/assets/index-CnWzrjF3.js +0 -78
@@ -0,0 +1,121 @@
1
+ 'use strict';
2
+ // Which schema an install joins (memhouse 0.20). A NEW house is schema 3; an existing house is
3
+ // followed — detected as the connecting credential sees it — never overridden by the default, and
4
+ // an ambiguous one is refused with what to do. bin/memhouse.js `install` (and `onboard` and
5
+ // `deploy --local`, which install) asks here before it writes anything.
6
+
7
+ const sqlStr = (s) => `'${String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
8
+
9
+ // ClickHouse's AUTHENTICATION_FAILED, and REQUIRED_PASSWORD (a user that needs a password was
10
+ // given none). Told by the code, which the HTTP wrappers carry as `chCode` from the
11
+ // X-ClickHouse-Exception-Code header (bin/memhouse.js ch(), q-cli.js httpTransport), never by its
12
+ // prose: a credential the server refused is not a house whose tables are unreadable.
13
+ const AUTHENTICATION_FAILED = 516;
14
+ const REQUIRED_PASSWORD = 194;
15
+ const AUTH_CODES = new Set([AUTHENTICATION_FAILED, REQUIRED_PASSWORD]);
16
+ function isAuthFailure(e) { return AUTH_CODES.has(Number(e && e.chCode)); }
17
+ /** What every caller says when it is one; the caller adds what to set. */
18
+ function authFailureText(user, url) {
19
+ return `could not authenticate${user ? ` as '${user}'` : ''}${url ? ` at ${url}` : ''}`;
20
+ }
21
+
22
+ /**
23
+ * Which schema an install joins (0.20: schema 3 for a NEW house; an existing house is followed,
24
+ * never overridden by the default). Schema 3 is harness-sessions-sql's profile (<m>_session_parts); a
25
+ * member still on the first schema-3 layout of the 0.20 development builds (<m>_turns) is named, so
26
+ * the install can refuse rather than lay the profile over it. Read as the connecting credential:
27
+ * member — this member's own tables: '3' (<m>_session_parts), '2' (<m>_messages), 'both', or null;
28
+ * devLayout — <m>_turns: the development builds' layout, which the profile replaces;
29
+ * devImports — on that layout, how many live sessions `memhouse import` wrote into <m>_turns (null: unreadable);
30
+ * unreadable — the house's tables could not be listed: callers that guard a move refuse (fail closed).
31
+ * authFailed — and that was the server refusing the credential (isAuthFailure): say so, not "could not list";
32
+ * fenced — its meta says min_writer_schema 3 (memhouse migrate --v3-prepare ran);
33
+ * converted — its meta holds profile_converted (memhouse migrate --v3-convert ran to the end);
34
+ * house — the other tables this credential can see: '3', '2', 'mixed', or null (a new house).
35
+ */
36
+ async function detectHouseSchema(rows, db, member) {
37
+ const out = { member: null, fenced: false, converted: false, house: null, devLayout: false, devImports: 0, unreadable: false, authFailed: false };
38
+ let names = [];
39
+ try { names = (await rows(`SELECT name FROM system.tables WHERE database = ${sqlStr(db)}`)).map((r) => r.name); }
40
+ catch (e) { out.unreadable = true; out.authFailed = isAuthFailure(e); return out; }
41
+ const has = (n) => names.includes(n);
42
+ const mine3 = has(`${member}_session_parts`); const mine2 = has(`${member}_messages`);
43
+ out.member = mine3 && mine2 ? 'both' : mine3 ? '3' : mine2 ? '2' : null;
44
+ out.devLayout = has(`${member}_turns`);
45
+ // Counted from <m>_turns itself, where the imported rows live: <m>_sessions may have been dropped
46
+ // or renamed for the move, or already be the profile's.
47
+ if (out.devLayout) {
48
+ try {
49
+ out.devImports = Number(((await rows(`SELECT uniqExact(harness, session_id) AS n FROM \`${db}\`.\`${member}_turns\` FINAL WHERE is_deleted = 0 AND origin = 'import'`))[0] || {}).n || 0);
50
+ } catch { out.devImports = null; }
51
+ }
52
+ if (has(`${member}_meta`)) {
53
+ try {
54
+ const r = await rows(`SELECT value FROM \`${db}\`.\`${member}_meta\` FINAL WHERE key = 'min_writer_schema'`);
55
+ out.fenced = r.length > 0 && Number(r[0].value) >= 3;
56
+ out.converted = (await rows(`SELECT value FROM \`${db}\`.\`${member}_meta\` FINAL WHERE key = 'profile_converted'`)).length > 0;
57
+ } catch { /* unreadable: no fence known */ }
58
+ }
59
+ const any3 = names.some((n) => n.endsWith('_session_parts')); const any2 = names.some((n) => n.endsWith('_messages'));
60
+ out.house = any3 && any2 ? 'mixed' : any3 ? '3' : any2 ? '2' : null;
61
+ return out;
62
+ }
63
+ /**
64
+ * Whether this build's `memhouse import` writes the profile tables. Until it does, a member whose
65
+ * imported sessions live only in <m>_turns is not moved: nothing in the move brings them back.
66
+ */
67
+ function importWritesProfile() {
68
+ try { return require('../import/import').WRITES_LAYOUTS.includes('profile'); } catch { return false; }
69
+ }
70
+
71
+ /**
72
+ * What to say about a development-layout member's imported sessions, or '' when it has none.
73
+ * They live only in <m>_turns: a recapture reads the editors' files, and an import has none.
74
+ */
75
+ function importsNotice(det, member) {
76
+ if (!det.devLayout || det.devImports === 0) return '';
77
+ const n = det.devImports === null ? 'an unknown number of' : String(det.devImports);
78
+ const how = importWritesProfile()
79
+ ? 'keep their export and import it again after the move, a dry run first'
80
+ : 'this build cannot import into the profile tables, so move this member with a build that can, then import the export again';
81
+ return `${n} imported session(s) need a re-import: '${member}' holds them in ${member}_turns (memhouse import), and a recapture cannot bring them back — ${how} (docs/guides/upgrade-to-0.20.md)`;
82
+ }
83
+
84
+ /** The schema to install with, or { refuse: message }. `stated`: '', '2' or '3' as given. */
85
+ /** Why a member whose house holds <m>_turns may not be laid or captured on the profile, or ''. */
86
+ function devLayoutRefusal(det, member) {
87
+ if (!det.devLayout) return '';
88
+ const notice = importsNotice(det, member);
89
+ // The profile tables already there and nothing imported left behind: <m>_turns is a leftover.
90
+ if (det.member === '3' && !(notice && !importWritesProfile())) return '';
91
+ return `the house holds ${member}_turns, the layout of memhouse 0.20's development builds, which harness-sessions-sql's profile replaces — its admin recaptures or converts '${member}' first (docs/guides/upgrade-to-0.20.md)${notice ? `. ${notice}` : ''}`;
92
+ }
93
+
94
+ /**
95
+ * @param {{user?:string, url?:string}} [who] the credential the detection connected as, to name a refusal
96
+ */
97
+ function chooseSchema(stated, det, member, who = {}) {
98
+ // A credential the server refused saw nothing: say that, rather than reading the house as new
99
+ // and letting the next step fail on the same credential.
100
+ if (det.authFailed) return { refuse: `${authFailureText(who.user, who.url)} — check the user and password, then run this again`, auth: true };
101
+ // Checked before the member's own tables: an --ensure-schema that laid the profile beside
102
+ // <m>_turns must not make the development layout look converted.
103
+ const dev = devLayoutRefusal(det, member);
104
+ if (dev) return { refuse: dev };
105
+ if (det.member === 'both') return { refuse: `the house holds both ${member}_messages (0.18) and ${member}_session_parts (schema 3) for '${member}' — ask its admin which is current (docs/guides/upgrade-to-0.20.md)` };
106
+ if (det.member && stated && det.member !== stated) {
107
+ return { refuse: `the house holds '${member}' on schema ${det.member}; --schema ${stated} contradicts it. Leave --schema out to follow the house` };
108
+ }
109
+ // The conversion lays the profile tables first: until it ran to the end, they are not the house yet.
110
+ if (det.member === '3' && det.fenced && !det.converted) {
111
+ return { refuse: `'${member}' is being converted to schema 3 and its conversion has not finished — install once its admin has run memhouse migrate --v3-convert to the end (a re-run skips the finished buckets)` };
112
+ }
113
+ if (det.member) return { schema: det.member };
114
+ if (det.fenced) return { refuse: `'${member}' is being converted to schema 3 (its 0.18 rooms are fenced off, its schema-3 tables not there yet) — install once its admin has run memhouse migrate --v3-convert` };
115
+ if (stated) return { schema: stated };
116
+ if (det.house === 'mixed') return { refuse: 'this house holds members on the 0.18 rooms and members on schema 3 — say which this install joins: --schema 2 or --schema 3' };
117
+ if (det.house) return { schema: det.house };
118
+ return { schema: '3' }; // a new house
119
+ }
120
+
121
+ module.exports = { detectHouseSchema, chooseSchema, importsNotice, importWritesProfile, devLayoutRefusal, isAuthFailure, authFailureText, AUTHENTICATION_FAILED, REQUIRED_PASSWORD };
@@ -0,0 +1,290 @@
1
+ // The harness-sessions-sql ClickHouse profile as memhouse's house tables (step B1): one member's tables,
2
+ // in two databases.
3
+ //
4
+ // <db>.<m>_session_parts, _sessions, _session_usage, _session_links, _session_lifecycle, _calls
5
+ // conversation layer, under the member's `<db>.<m>_*` grant
6
+ // <db>_archive.<m>_session_units, _session_sources
7
+ // capture layer: SELECT and INSERT for the member, no grant
8
+ // option, nothing in <db>_archive granted to anyone else
9
+ //
10
+ // The DDL lives in schema-profile.sql.tpl, rendered with {db} and {m}; every statement is
11
+ // CREATE … IF NOT EXISTS. Transport-free like schema-v3.js: every entry point takes `q`.
12
+
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+ const { assertUsableName, legacyTextIndexDialect, isTextIndexGrammarRefusal, textIndexDialectFor } = require('./house');
16
+ const { stripComments, qFromClient, DDL_SETTINGS } = require('./schema-v3');
17
+
18
+ const TEMPLATE = path.join(__dirname, 'schema-profile.sql.tpl');
19
+
20
+ // The columns retyped in harness-sessions-sql v1, which ensureProfileSchema brings an older table to:
21
+ // adapter_ver UInt32 -> LowCardinality(String), child_stored Bool -> Nullable(Bool), orig_sha256
22
+ // FixedString(64) -> Nullable(FixedString(64)).
23
+ const TYPE_HEALS = new Set(['adapter_ver', 'child_stored', 'orig_sha256']);
24
+ const CONVERSATION_TYPES = ['session_parts', 'sessions', 'session_usage', 'session_links', 'session_lifecycle', 'calls'];
25
+ const CAPTURE_TYPES = ['session_units', 'session_sources'];
26
+ const PROFILE_TYPES = [...CONVERSATION_TYPES, ...CAPTURE_TYPES];
27
+
28
+ /** Each table's sorting key as system.tables.sorting_key reports it: harness-sessions-sql §3's, host first, no ts. */
29
+ const S = 'host, harness, harness_path, session_id, incarnation';
30
+ const PROFILE_KEYS = {
31
+ session_parts: `${S}, message_id, part_id`,
32
+ sessions: S,
33
+ session_usage: `${S}, usage_id`,
34
+ session_links: `${S}, owner, kind, other_host, other_harness_path, other_harness, other_session_id, other_incarnation, at`,
35
+ session_lifecycle: `${S}, lifecycle_id`,
36
+ calls: `${S}, call_id`,
37
+ session_units: `${S}, source_key, generation, unit_key, chunk, sha256`,
38
+ session_sources: `${S}, source_key`,
39
+ };
40
+
41
+ /**
42
+ * Columns an INSERT built from a row's mapped columns leaves to the server (insertColumns). The
43
+ * capture's archive rows are written as built and carry captured_at themselves: one value per
44
+ * archive write, the replay's pass id (profile-capture.js header: archive).
45
+ */
46
+ const SERVER_COLUMNS = ['writer', 'ingested_at', 'search_ngram', 'search_word', 'ver', 'captured_at', 'committed_at'];
47
+
48
+ function archiveDb(db) { return `${db}_archive`; }
49
+
50
+ /** Every fully-qualified name of one member's profile tables. */
51
+ function profileNames(db, member) {
52
+ assertUsableName(db, 'house');
53
+ assertUsableName(member, 'member');
54
+ const out = { db, archive: archiveDb(db), member };
55
+ for (const t of CONVERSATION_TYPES) out[t] = `${db}.${member}_${t}`;
56
+ for (const t of CAPTURE_TYPES) out[t] = `${archiveDb(db)}.${member}_${t}`;
57
+ out.meta = `${db}.${member}_meta`;
58
+ out.events = `${db}.${member}_events`;
59
+ return out;
60
+ }
61
+
62
+ function readTemplate() { return fs.readFileSync(TEMPLATE, 'utf-8'); }
63
+
64
+ /** The CREATE statements for one member, in template order: [{ type, database, table, fq, sql }]. */
65
+ function renderStatements(tpl, { db, member }) {
66
+ assertUsableName(db, 'house');
67
+ assertUsableName(member, 'member');
68
+ const body = stripComments(tpl).replaceAll('{db}', db).replaceAll('{m}', member);
69
+ const out = [];
70
+ for (const raw of body.split(';')) {
71
+ const sql = raw.trim();
72
+ if (!sql) continue;
73
+ const m = /^CREATE TABLE IF NOT EXISTS ([A-Za-z0-9_]+)\.([A-Za-z0-9_]+)\s*\(/.exec(sql);
74
+ if (!m) throw new Error(`schema-profile template: unexpected statement: ${sql.slice(0, 80)}`);
75
+ const type = m[2].startsWith(`${member}_`) ? m[2].slice(member.length + 1) : m[2];
76
+ if (!PROFILE_TYPES.includes(type)) throw new Error(`schema-profile template declares an unknown table '${m[2]}'`);
77
+ out.push({ type, database: m[1], table: m[2], fq: `${m[1]}.${m[2]}`, sql });
78
+ }
79
+ const missing = PROFILE_TYPES.filter((t) => !out.some((s) => s.type === t));
80
+ if (missing.length) throw new Error(`schema-profile template is missing: ${missing.join(', ')}`);
81
+ return out;
82
+ }
83
+
84
+ /** The columns each table declares, as [{ name, type }]; a Nested column is one entry. */
85
+ function templateColumns(tpl) {
86
+ const out = {};
87
+ for (const s of renderStatements(tpl, { db: 'db', member: 'm' })) {
88
+ const inner = s.sql.slice(s.sql.indexOf('(') + 1, s.sql.search(/\)\s*ENGINE\b/));
89
+ const cols = [];
90
+ let depth = 0; let buf = '';
91
+ for (const ch of `${inner},`) {
92
+ if (ch === '(') depth++;
93
+ if (ch === ')') depth--;
94
+ if (ch === ',' && depth === 0) {
95
+ const decl = buf.replace(/\s+/g, ' ').trim();
96
+ buf = '';
97
+ if (!decl || /^(INDEX|CONSTRAINT|PROJECTION)\b/i.test(decl)) continue;
98
+ const sp = decl.indexOf(' ');
99
+ cols.push({ name: decl.slice(0, sp), type: decl.slice(sp + 1) });
100
+ continue;
101
+ }
102
+ buf += ch;
103
+ }
104
+ out[s.type] = cols;
105
+ }
106
+ return out;
107
+ }
108
+ function rowColumns(tpl, type) {
109
+ return (templateColumns(tpl)[type] || []).map((c) => c.name).filter((x) => !SERVER_COLUMNS.includes(x));
110
+ }
111
+ /** The columns an INSERT names, in template order: rowColumns with a Nested column as its dotted subcolumns. */
112
+ function insertColumns(tpl, type) {
113
+ return (templateColumns(tpl)[type] || []).filter((c) => !SERVER_COLUMNS.includes(c.name)).flatMap((c) => {
114
+ const m = /^Nested\s*\(([\s\S]*)\)$/i.exec(c.type);
115
+ if (!m) return [c.name];
116
+ const subs = []; let depth = 0; let buf = '';
117
+ for (const ch of `${m[1]},`) {
118
+ if (ch === '(') depth++;
119
+ if (ch === ')') depth--;
120
+ if (ch === ',' && depth === 0) { if (buf.trim()) subs.push(`${c.name}.${buf.trim().split(' ')[0]}`); buf = ''; continue; }
121
+ buf += ch;
122
+ }
123
+ return subs;
124
+ });
125
+ }
126
+ /** The ORDER BY each table's template declares. */
127
+ function templateKeys(tpl) {
128
+ const out = {};
129
+ for (const s of renderStatements(tpl, { db: 'db', member: 'm' })) {
130
+ const m = /ORDER BY \(([^)]*)\)/.exec(s.sql);
131
+ out[s.type] = m ? m[1].replace(/\s+/g, ' ').trim() : '';
132
+ }
133
+ return out;
134
+ }
135
+
136
+ /** What is wrong with a profile table's sorting key, or null. */
137
+ function keyProblem(type, sortingKey) {
138
+ const want = PROFILE_KEYS[type];
139
+ if (!want) return `'${type}' is not a profile table`;
140
+ const have = String(sortingKey || '').trim();
141
+ if (!have) return 'not a MergeTree';
142
+ return have === want ? null : `(${have}) — expected (${want})`;
143
+ }
144
+
145
+ /** The capture layer's grants: SELECT and INSERT on the member's two archive tables, no grant option. */
146
+ function captureGrants(db, member) {
147
+ const n = profileNames(db, member);
148
+ return CAPTURE_TYPES.map((t) => `GRANT SELECT, INSERT ON ${n[t]} TO ${member}`);
149
+ }
150
+
151
+ function isDenied(m) { return /Not enough privileges|ACCESS_DENIED/i.test(m); }
152
+ function isExists(m) { return /ALREADY_EXISTS|already exists/i.test(m); }
153
+ function msg(e) { return e && e.message ? e.message : String(e); }
154
+
155
+ /**
156
+ * Create one member's profile tables, idempotently, then verify them against PROFILE_KEYS.
157
+ * As ensureV3Schema: a privilege denial is counted and skipped, a lost create race is success.
158
+ */
159
+ async function ensureProfileSchema(q, { db, member, createDatabases = false, grantCapture = false, tpl = null } = {}, ui = null) {
160
+ const n = profileNames(db, member);
161
+ const say = (m) => ui && ui.ok && ui.ok(m);
162
+ const version = String(((await q.rows('SELECT version() AS v', { final: 0 }))[0] || {}).v || '');
163
+ const dialect = textIndexDialectFor(version);
164
+ const out = { version, dialect, created: [], denied: [], unhealed: [], grants: [], grant_failures: [], tables: {}, ok: false };
165
+ if (createDatabases) {
166
+ for (const d of [n.db, n.archive]) {
167
+ try { await q.sql(`CREATE DATABASE IF NOT EXISTS ${d}`, DDL_SETTINGS); } catch (e) {
168
+ const m = msg(e);
169
+ if (isDenied(m)) out.denied.push(`CREATE DATABASE ${d}`); else if (!isExists(m)) throw e;
170
+ }
171
+ }
172
+ }
173
+ for (const s of renderStatements(tpl || readTemplate(), { db, member })) {
174
+ const modern = s.sql;
175
+ const legacy = legacyTextIndexDialect(modern);
176
+ const [first, second] = dialect === 'legacy' ? [legacy, modern] : [modern, legacy];
177
+ try {
178
+ try { await q.sql(first, DDL_SETTINGS); } catch (e) {
179
+ if (!(first !== second && isTextIndexGrammarRefusal(msg(e)))) throw e;
180
+ await q.sql(second, DDL_SETTINGS);
181
+ }
182
+ out.created.push(s.fq);
183
+ } catch (e) {
184
+ const m = msg(e);
185
+ if (isDenied(m)) { out.denied.push(`CREATE TABLE ${s.fq}`); continue; }
186
+ if (isExists(m)) { out.created.push(s.fq); continue; }
187
+ throw new Error(`${s.fq}: ${m.split('\n')[0]}`);
188
+ }
189
+ }
190
+ // A table made before the template gained a column (session_links' r_* deployment columns, for
191
+ // one) gets it: ADD COLUMN IF NOT EXISTS for each column the template declares and it lacks.
192
+ out.added = [];
193
+ const lacking = new Map(); // table -> the columns a denial left it without
194
+ const want = templateColumns(tpl || readTemplate());
195
+ const dbOf = (t) => (CAPTURE_TYPES.includes(t) ? n.archive : n.db);
196
+ const have = new Map();
197
+ const types = new Map(); // db.table.column -> the server's type
198
+ const defaults = new Map(); // db.table.column -> the server's default expression ('' for none)
199
+ for (const r of await q.rows(`SELECT database, table, name, type, default_expression FROM system.columns
200
+ WHERE (database = '${n.db}' AND table IN (${CONVERSATION_TYPES.map((t) => `'${member}_${t}'`).join(', ')}))
201
+ OR (database = '${n.archive}' AND table IN (${CAPTURE_TYPES.map((t) => `'${member}_${t}'`).join(', ')}))`, { final: 0 })) {
202
+ const k = `${r.database}.${r.table}`;
203
+ if (!have.has(k)) have.set(k, new Set());
204
+ have.get(k).add(r.name);
205
+ types.set(`${k}.${r.name}`, String(r.type || ''));
206
+ defaults.set(`${k}.${r.name}`, String(r.default_expression || ''));
207
+ }
208
+ for (const t of PROFILE_TYPES) {
209
+ const got = have.get(`${dbOf(t)}.${member}_${t}`);
210
+ if (!got) continue;
211
+ for (const c of want[t] || []) {
212
+ // A Nested column is listed by its fields (usage.hour, usage.model, ...).
213
+ if (got.has(c.name) || [...got].some((x) => x.startsWith(`${c.name}.`))) continue;
214
+ try {
215
+ await q.sql(`ALTER TABLE ${n[t]} ADD COLUMN IF NOT EXISTS \`${c.name}\` ${c.type}`, DDL_SETTINGS);
216
+ out.added.push(`${n[t]}.${c.name}`);
217
+ } catch (e) {
218
+ const m = msg(e);
219
+ if (isDenied(m)) { out.denied.push(`ALTER TABLE ${n[t]} ADD COLUMN ${c.name}`); lacking.set(t, [...(lacking.get(t) || []), c.name]); continue; }
220
+ throw new Error(`${n[t]}: ${m.split('\n')[0]}`);
221
+ }
222
+ }
223
+ }
224
+ if (out.added.length) say(`profile tables for ${member}: added ${out.added.join(', ')}`);
225
+ // A column whose type the spec changed (TYPE_HEALS: harness-sessions-sql v1) is brought to the template's
226
+ // type: MODIFY COLUMN, which ClickHouse applies to the parts as a mutation. Only these columns: a
227
+ // type changed anywhere else is a template bug, not something to heal on a live house. A member
228
+ // may not ALTER its <db>_archive tables, so those wait for an admin; the capture writes both typings
229
+ // (ProfileHouse.insert), so an archive table left on the old ones is reported, not refused. A
230
+ // conversation table the member could not retype is a problem: session_links' old child_stored
231
+ // turns an unknown into true.
232
+ out.retyped = [];
233
+ for (const t of PROFILE_TYPES) {
234
+ const k = `${dbOf(t)}.${member}_${t}`;
235
+ if (!have.has(k)) continue;
236
+ for (const c of want[t] || []) {
237
+ const now = types.get(`${k}.${c.name}`);
238
+ // A type-only MODIFY keeps the column's old default (child_stored's true, orig_sha256's ''),
239
+ // so a default the template does not declare is removed too.
240
+ const stale = !/\bDEFAULT\b/.test(c.type) && !!defaults.get(`${k}.${c.name}`);
241
+ if (!TYPE_HEALS.has(c.name) || now == null || (now === c.type && !stale)) continue;
242
+ try {
243
+ if (now !== c.type) await q.sql(`ALTER TABLE ${n[t]} MODIFY COLUMN \`${c.name}\` ${c.type}`, DDL_SETTINGS);
244
+ if (stale) await q.sql(`ALTER TABLE ${n[t]} MODIFY COLUMN \`${c.name}\` REMOVE DEFAULT`, DDL_SETTINGS);
245
+ out.retyped.push(`${n[t]}.${c.name}: ${now} -> ${c.type}${stale ? ', its old default removed' : ''}`);
246
+ } catch (e) {
247
+ const m = msg(e);
248
+ if (isDenied(m)) {
249
+ out.denied.push(`ALTER TABLE ${n[t]} MODIFY COLUMN ${c.name}`);
250
+ const what = now === c.type ? `${c.name} (its old default kept)` : `${c.name} (${now}, not ${c.type})`;
251
+ if (CAPTURE_TYPES.includes(t)) out.unhealed.push(`${n[t]}.${what}`);
252
+ else lacking.set(t, [...(lacking.get(t) || []), what]);
253
+ continue;
254
+ }
255
+ throw new Error(`${n[t]}: ${m.split('\n')[0]}`);
256
+ }
257
+ }
258
+ }
259
+ if (out.retyped.length) say(`profile tables for ${member}: retyped ${out.retyped.join(', ')}`);
260
+ if (grantCapture) {
261
+ for (const g of captureGrants(db, member)) {
262
+ try { await q.sql(g, DDL_SETTINGS); out.grants.push(g); } catch (e) {
263
+ const m = msg(e);
264
+ if (isDenied(m)) out.denied.push(g); else out.grant_failures.push(`${g}: ${m.split('\n')[0]}`);
265
+ }
266
+ }
267
+ }
268
+ const rows = await q.rows(
269
+ `SELECT database, name, sorting_key FROM system.tables
270
+ WHERE (database = '${n.db}' AND name IN (${CONVERSATION_TYPES.map((t) => `'${member}_${t}'`).join(', ')}))
271
+ OR (database = '${n.archive}' AND name IN (${CAPTURE_TYPES.map((t) => `'${member}_${t}'`).join(', ')}))`, { final: 0 });
272
+ let ok = true;
273
+ for (const t of PROFILE_TYPES) {
274
+ const fq = n[t];
275
+ const r = rows.find((x) => `${x.database}.${x.name}` === fq);
276
+ // A table without a column the template declares cannot take the rows written to it.
277
+ const problem = r ? keyProblem(t, r.sorting_key) || (lacking.has(t) ? `columns not as the template has them: ${lacking.get(t).join(', ')}` : null) : 'missing';
278
+ if (problem) ok = false;
279
+ out.tables[t] = { fq, exists: Boolean(r), problem };
280
+ }
281
+ out.ok = ok;
282
+ if (ok) say(`profile tables for ${member}: ${PROFILE_TYPES.map((t) => n[t]).join(', ')}`);
283
+ return out;
284
+ }
285
+
286
+ module.exports = {
287
+ TEMPLATE, CONVERSATION_TYPES, CAPTURE_TYPES, PROFILE_TYPES, PROFILE_KEYS, SERVER_COLUMNS,
288
+ archiveDb, profileNames, readTemplate, renderStatements, templateColumns, templateKeys, rowColumns, insertColumns,
289
+ keyProblem, captureGrants, ensureProfileSchema, qFromClient,
290
+ };