dsh-memsys 0.1.0 → 0.2.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.
@@ -1,16 +1,24 @@
1
+ import { canonicalJson } from '../identity.mjs';
2
+
1
3
  const TABLES = ['observations', 'candidates', 'memories', 'relations', 'import_items', 'timeline', 'pointers'];
4
+ const ID_TABLES = {
5
+ observations: 'id',
6
+ candidates: 'id',
7
+ memories: 'id',
8
+ relations: 'id',
9
+ import_items: 'id',
10
+ };
11
+
12
+ function rowHash(row) {
13
+ return canonicalJson({ ...row });
14
+ }
2
15
 
3
16
  export function compareBusinessCounts({ db, expected = {} }) {
4
17
  const missing = [];
5
18
  const counts = {};
6
19
  for (const [table, minimum] of Object.entries(expected)) {
7
20
  if (!TABLES.includes(table)) continue;
8
- let actual = 0;
9
- try {
10
- actual = Number(db.prepare('SELECT COUNT(*) AS n FROM ' + table).get().n);
11
- } catch {
12
- actual = 0;
13
- }
21
+ const actual = Number(db.prepare('SELECT COUNT(*) AS n FROM ' + table).get().n);
14
22
  counts[table] = actual;
15
23
  if (actual < Number(minimum)) missing.push(table);
16
24
  }
@@ -20,60 +28,105 @@ export function compareBusinessCounts({ db, expected = {} }) {
20
28
  export function snapshotBusinessCounts(db) {
21
29
  const counts = {};
22
30
  for (const table of TABLES) {
23
- try {
24
- counts[table] = Number(db.prepare('SELECT COUNT(*) AS n FROM ' + table).get().n);
25
- } catch {
26
- counts[table] = 0;
27
- }
31
+ counts[table] = Number(db.prepare('SELECT COUNT(*) AS n FROM ' + table).get().n);
28
32
  }
29
33
  return counts;
30
34
  }
31
35
 
32
- const ID_TABLES = {
33
- observations: 'id',
34
- candidates: 'id',
35
- memories: 'id',
36
- relations: 'id',
37
- import_items: 'item_id',
38
- };
39
-
40
36
  export function snapshotBusinessRows(db) {
41
37
  const ids = {};
38
+ const content = {};
42
39
  for (const [table, key] of Object.entries(ID_TABLES)) {
43
- try {
44
- ids[table] = db.prepare('SELECT ' + key + ' AS id FROM ' + table + ' ORDER BY 1').all().map((row) => row.id);
45
- } catch {
46
- ids[table] = [];
47
- }
40
+ const rows = db.prepare('SELECT * FROM ' + table + ' ORDER BY ' + key).all();
41
+ ids[table] = rows.map((row) => row[key]);
42
+ content[table] = Object.fromEntries(rows.map((row) => [row[key], rowHash(row)]));
43
+ }
44
+ const links = {};
45
+ for (const memoryId of ids.memories ?? []) {
46
+ const timeline = db.prepare('SELECT * FROM timeline WHERE memory_id = ?').get(memoryId) ?? null;
47
+ const pointer = db.prepare('SELECT * FROM pointers WHERE memory_id = ?').get(memoryId) ?? null;
48
+ const evidence = db.prepare('SELECT id FROM evidence WHERE memory_id = ? ORDER BY id').all(memoryId).map((row) => row.id);
49
+ links[memoryId] = {
50
+ timeline: timeline ? rowHash(timeline) : null,
51
+ pointer: pointer ? rowHash(pointer) : null,
52
+ evidence,
53
+ };
48
54
  }
49
- return { counts: snapshotBusinessCounts(db), ids };
55
+ return { counts: snapshotBusinessCounts(db), ids, content, links };
50
56
  }
51
57
 
52
58
  export function compareBusinessRows({ db, baseline = {} }) {
53
59
  const missing = [];
54
60
  const ids = baseline.ids ?? {};
61
+ const content = baseline.content ?? {};
55
62
  for (const [table, key] of Object.entries(ID_TABLES)) {
56
63
  for (const id of ids[table] ?? []) {
57
- let row = null;
58
- try {
59
- row = db.prepare('SELECT ' + key + ' AS id FROM ' + table + ' WHERE ' + key + ' = ?').get(id);
60
- } catch {
61
- row = null;
64
+ const row = db.prepare('SELECT * FROM ' + table + ' WHERE ' + key + ' = ?').get(id);
65
+ if (!row) {
66
+ missing.push(table + ':' + id);
67
+ continue;
68
+ }
69
+ const expectedHash = content[table]?.[id];
70
+ if (expectedHash && rowHash(row) !== expectedHash) {
71
+ missing.push(table + ':content:' + id);
62
72
  }
63
- if (!row) missing.push(table + ':' + id);
64
73
  }
65
74
  }
66
75
  for (const memoryId of ids.memories ?? []) {
67
- try {
68
- const timeline = db.prepare('SELECT 1 AS ok FROM timeline WHERE memory_id = ?').get(memoryId);
69
- const pointer = db.prepare('SELECT 1 AS ok FROM pointers WHERE memory_id = ?').get(memoryId);
70
- const evidence = db.prepare('SELECT 1 AS ok FROM evidence WHERE memory_id = ? LIMIT 1').get(memoryId);
71
- if (!timeline || !pointer || !evidence) missing.push('memory_links:' + memoryId);
72
- } catch {
76
+ const expected = baseline.links?.[memoryId];
77
+ const timeline = db.prepare('SELECT * FROM timeline WHERE memory_id = ?').get(memoryId) ?? null;
78
+ const pointer = db.prepare('SELECT * FROM pointers WHERE memory_id = ?').get(memoryId) ?? null;
79
+ const evidence = db.prepare('SELECT id FROM evidence WHERE memory_id = ? ORDER BY id').all(memoryId).map((row) => row.id);
80
+ if (!timeline || !pointer || evidence.length === 0) {
73
81
  missing.push('memory_links:' + memoryId);
82
+ continue;
83
+ }
84
+ if (expected) {
85
+ if ((timeline ? rowHash(timeline) : null) !== expected.timeline) missing.push('timeline:content:' + memoryId);
86
+ if ((pointer ? rowHash(pointer) : null) !== expected.pointer) missing.push('pointer:content:' + memoryId);
87
+ if (canonicalJson(evidence) !== canonicalJson(expected.evidence)) missing.push('evidence:content:' + memoryId);
74
88
  }
75
89
  }
76
90
  const counts = compareBusinessCounts({ db, expected: baseline.counts ?? {} });
77
91
  for (const key of counts.missing) missing.push('count:' + key);
78
92
  return { ok: missing.length === 0, missing, counts: counts.counts };
79
93
  }
94
+
95
+ const FREEZE_TABLES = {
96
+ main: ['observations', 'candidates', 'memories', 'relations', 'timeline', 'pointers', 'evidence', 'import_items', 'graph_nodes', 'graph_edges'],
97
+ control: ['operations', 'object_streams', 'context_snapshots', 'snapshot_groups', 'snapshot_items', 'operation_attempts'],
98
+ dream: ['dreams', 'propositions', 'relations', 'confirmation_snapshots'],
99
+ };
100
+
101
+ function tableFingerprint(db, table) {
102
+ const rows = db.prepare('SELECT * FROM ' + table + ' ORDER BY rowid').all();
103
+ return { count: rows.length, hash: canonicalJson(rows) };
104
+ }
105
+
106
+ export function snapshotDatabaseFreeze({ main, control, dream }) {
107
+ const freeze = { versions: {}, tables: {} };
108
+ for (const [label, db] of Object.entries({ main, control, dream })) {
109
+ if (!db) continue;
110
+ freeze.versions[label] = Number(db.prepare('SELECT COALESCE(MAX(version), 0) AS version FROM schema_migrations').get().version);
111
+ freeze.tables[label] = {};
112
+ for (const table of FREEZE_TABLES[label] ?? []) {
113
+ freeze.tables[label][table] = tableFingerprint(db, table);
114
+ }
115
+ }
116
+ return freeze;
117
+ }
118
+
119
+ export function compareDatabaseFreeze({ main, control, dream }, freeze = {}) {
120
+ const missing = [];
121
+ for (const [label, db] of Object.entries({ main, control, dream })) {
122
+ if (!db || !freeze.versions?.[label]) continue;
123
+ const version = Number(db.prepare('SELECT COALESCE(MAX(version), 0) AS version FROM schema_migrations').get().version);
124
+ if (version !== Number(freeze.versions[label])) missing.push(label + ':version');
125
+ for (const [table, expected] of Object.entries(freeze.tables?.[label] ?? {})) {
126
+ const actual = tableFingerprint(db, table);
127
+ if (actual.count !== expected.count) missing.push(label + ':' + table + ':count');
128
+ else if (actual.hash !== expected.hash) missing.push(label + ':' + table + ':content');
129
+ }
130
+ }
131
+ return { ok: missing.length === 0, missing };
132
+ }
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Host side of the optional Web UI (sidebar panel + settings section).
3
+ *
4
+ * Everything here is read-only over SQLite plus one small JSON file holding the
5
+ * `auto_inject` override written by the settings switch. Both routes enforce
6
+ * loopback + same-origin before touching data; nothing here is model-facing.
7
+ */
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+
11
+ const UI_CONFIG_FILE = 'ui-config.json';
12
+ const UI_CONFIG_VERSION = 1;
13
+ const RECENT_LIMIT = 5;
14
+ const TEXT_LIMIT = 80;
15
+ const BODY_LIMIT = 4096;
16
+
17
+ const warn = (logger, message) => {
18
+ try {
19
+ logger?.warn?.(`[dsh-memsys] ${message}`);
20
+ } catch {
21
+ // a logger that throws must never break boot
22
+ }
23
+ };
24
+
25
+ /**
26
+ * Read the settings file written by the Web switch.
27
+ * Returns `{ auto_inject }` or `null` when absent. A corrupt or foreign-version
28
+ * file is ignored with one warning: a bad file must never stop the plugin.
29
+ */
30
+ export function readUiConfig(dataDir, logger = console) {
31
+ if (!dataDir) return null;
32
+ const file = path.join(dataDir, UI_CONFIG_FILE);
33
+ let raw = null;
34
+ try {
35
+ if (!fs.existsSync(file)) return null;
36
+ raw = fs.readFileSync(file, 'utf8');
37
+ } catch (err) {
38
+ warn(logger, `ui-config 读取失败(${err?.message ?? err}),回落 profile 配置`);
39
+ return null;
40
+ }
41
+ let parsed = null;
42
+ try {
43
+ parsed = JSON.parse(raw);
44
+ } catch {
45
+ warn(logger, 'ui-config 不是合法 JSON,回落 profile 配置');
46
+ return null;
47
+ }
48
+ if (parsed?.version !== UI_CONFIG_VERSION) {
49
+ warn(logger, `ui-config 版本不符(${parsed?.version ?? 'none'},期望 ${UI_CONFIG_VERSION}),已忽略`);
50
+ return null;
51
+ }
52
+ if (typeof parsed?.auto_inject !== 'boolean') {
53
+ warn(logger, 'ui-config 缺少 auto_inject 布尔值,已忽略');
54
+ return null;
55
+ }
56
+ return { auto_inject: parsed.auto_inject };
57
+ }
58
+
59
+ /**
60
+ * Priority: ui-config.json (when present and valid) > profile patch value > false.
61
+ * The reported `source` is what the Web page shows next to the switch.
62
+ */
63
+ export function resolveAutoInject({ dataDir = null, profileValue = undefined, logger = console } = {}) {
64
+ const stored = readUiConfig(dataDir, logger);
65
+ if (stored !== null) return { value: stored.auto_inject, source: 'ui-config' };
66
+ if (typeof profileValue === 'boolean') return { value: profileValue, source: 'profile' };
67
+ return { value: false, source: 'default' };
68
+ }
69
+
70
+ /** Write-through with a temp file so a crash never leaves half a config. */
71
+ export function writeUiConfig(dataDir, autoInject) {
72
+ if (!dataDir) return false;
73
+ fs.mkdirSync(dataDir, { recursive: true });
74
+ const target = path.join(dataDir, UI_CONFIG_FILE);
75
+ const temp = `${target}.tmp`;
76
+ fs.writeFileSync(
77
+ temp,
78
+ JSON.stringify({ version: UI_CONFIG_VERSION, auto_inject: autoInject === true }, null, 2),
79
+ 'utf8',
80
+ );
81
+ fs.renameSync(temp, target);
82
+ return true;
83
+ }
84
+
85
+ const truncate = (text) => Array.from(String(text ?? '')).slice(0, TEXT_LIMIT).join('');
86
+
87
+ /** Three counts plus the five latest memories; `candidates` means still-open ones. */
88
+ export function buildSummary(main, { dataDir = null, autoInject = false, autoInjectSource = 'default' } = {}) {
89
+ const db = typeof main === 'function' ? main() : main;
90
+ const one = (sql, ...params) => Number(db.prepare(sql).get(...params)?.n ?? 0);
91
+ const recent = db
92
+ .prepare('SELECT id, text FROM memories ORDER BY recorded_at DESC, id DESC LIMIT ?')
93
+ .all(RECENT_LIMIT)
94
+ .map((row) => ({ id: row.id, text: truncate(row.text) }));
95
+ return {
96
+ memories: one('SELECT COUNT(*) AS n FROM memories'),
97
+ candidates: one("SELECT COUNT(*) AS n FROM candidates WHERE status IN ('pending', 'conflicted')"),
98
+ pending_due: one("SELECT COUNT(*) AS n FROM memories WHERE due_state = 'pending-due'"),
99
+ recent,
100
+ data_dir: dataDir,
101
+ auto_inject: autoInject === true,
102
+ auto_inject_source: autoInjectSource,
103
+ };
104
+ }
105
+
106
+ export function isLoopback(req) {
107
+ const address = req?.socket?.remoteAddress ?? '';
108
+ return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1';
109
+ }
110
+
111
+ export function sameOrigin(req) {
112
+ const origin = req?.headers?.origin;
113
+ const host = req?.headers?.host;
114
+ if (!origin) return true;
115
+ try {
116
+ return new URL(origin).host === host;
117
+ } catch {
118
+ return false;
119
+ }
120
+ }
121
+
122
+ /**
123
+ * One handler for GET summary and POST config.
124
+ *
125
+ * `main` may be a thunk so a profile that never opened the database still gets
126
+ * a structured 503 instead of a thrown route error.
127
+ */
128
+ export function createSummaryHandler({
129
+ main,
130
+ dataDir = null,
131
+ getAutoInject = () => false,
132
+ onAutoInject = null,
133
+ } = {}) {
134
+ const send = (res, status, payload) => {
135
+ res.statusCode = status;
136
+ res.setHeader('content-type', 'application/json');
137
+ res.setHeader('cache-control', 'no-store');
138
+ res.end(JSON.stringify(payload));
139
+ };
140
+
141
+ return (req, res) => {
142
+ if (!isLoopback(req) || !sameOrigin(req)) {
143
+ send(res, 403, { error: 'forbidden' });
144
+ return;
145
+ }
146
+ if (req.method === 'POST') {
147
+ const type = String(req.headers?.['content-type'] ?? '').toLowerCase();
148
+ if (!type.includes('application/json')) {
149
+ send(res, 415, { error: 'json_required' });
150
+ return;
151
+ }
152
+ let body = '';
153
+ let tooLarge = false;
154
+ req.on?.('data', (chunk) => {
155
+ if (tooLarge) return;
156
+ body += chunk;
157
+ if (body.length > BODY_LIMIT) tooLarge = true;
158
+ });
159
+ req.on?.('end', () => {
160
+ if (tooLarge) {
161
+ send(res, 413, { error: 'body_too_large' });
162
+ return;
163
+ }
164
+ let parsed = null;
165
+ try {
166
+ parsed = JSON.parse(body || '{}');
167
+ } catch {
168
+ send(res, 400, { error: 'invalid_json' });
169
+ return;
170
+ }
171
+ if (typeof parsed?.auto_inject !== 'boolean') {
172
+ send(res, 400, { error: 'auto_inject_required' });
173
+ return;
174
+ }
175
+ try {
176
+ onAutoInject?.(parsed.auto_inject);
177
+ send(res, 200, { ok: true, auto_inject: parsed.auto_inject, auto_inject_source: 'ui-config' });
178
+ } catch (err) {
179
+ send(res, 500, { error: 'write_failed', message: String(err?.message ?? err) });
180
+ }
181
+ });
182
+ return;
183
+ }
184
+ if (req.method && req.method !== 'GET') {
185
+ send(res, 405, { error: 'method_not_allowed' });
186
+ return;
187
+ }
188
+ try {
189
+ const current = getAutoInject();
190
+ const value = typeof current === 'boolean' ? current : current?.value === true;
191
+ const source = typeof current === 'boolean' ? 'ui-config' : (current?.source ?? 'default');
192
+ send(res, 200, buildSummary(main, { dataDir, autoInject: value, autoInjectSource: source }));
193
+ } catch (err) {
194
+ send(res, 503, { error: 'summary_unavailable', message: String(err?.code ?? err?.message ?? err) });
195
+ }
196
+ };
197
+ }
@@ -0,0 +1,90 @@
1
+ {
2
+ "capturedAt": "2026-11-02T17:32:36.368Z",
3
+ "source": "P3-C1 pre-flight (plan-3 §2/§5.4)",
4
+ "protected_inputs": {
5
+ "whale-unify/memsys-design-v0.38.md": {
6
+ "bytes": 99880,
7
+ "sha256": "511cbd8ce3db4ff92706c641449ad83b195d4bd37f17b1b50a43e4847f028ac9"
8
+ },
9
+ "whale-unify/_build/contract.md": {
10
+ "bytes": 3520,
11
+ "sha256": "e3efb6588da5550cc1a526a31f0375b10abfc314a921ea8342a4b0d25396ff10"
12
+ },
13
+ "whale-unify/_build/plan-1.md": {
14
+ "bytes": 65433,
15
+ "sha256": "8b6572a59f9c968844f2a524202bf61bfb2a39f6d095961d6f849387cdfcf999"
16
+ },
17
+ "whale-unify/_build/plan-2.md": {
18
+ "bytes": 61363,
19
+ "sha256": "49e1cdec8f6fda36e40e27c0d1ce90e167c2cd77002608b8864b40fd3061fdc2"
20
+ },
21
+ "whale-unify/_build/plan-3.md": {
22
+ "bytes": 64985,
23
+ "sha256": "498550e1fe24fd075c43940a59dcf47d18ae19b7a914e2b0be917cf33578b3d6"
24
+ },
25
+ "whale-unify/_build/report-1.md": {
26
+ "bytes": 3448,
27
+ "sha256": "9a94435b1e61c2cd6c1dfe347eaac6669657239a364fcc3597c2892550e4d736"
28
+ },
29
+ "whale-unify/_build/report-2.md": {
30
+ "bytes": 3572,
31
+ "sha256": "42e00e3c7fbc50b3512362d2af8bfa68995fcb53faacd5f9c1d82439cb5e0c12"
32
+ },
33
+ "whale-unify/_build/decisions.md": {
34
+ "bytes": 10587,
35
+ "sha256": "2cbdda67546ea3b1dd8ded1b3355d47148c43cdad0d0bbe0a962e403debb434e"
36
+ }
37
+ },
38
+ "migrations": {
39
+ "control-001.sql": {
40
+ "bytes": 3504,
41
+ "sha256": "1b1e865dee83757213e7b5b09cd20d0ff19b4dd5ec53efb495205255b32e9f93"
42
+ },
43
+ "control-002.sql": {
44
+ "bytes": 3268,
45
+ "sha256": "df684d926d49b0c209d05ede4b533f46f0942ca80866a3d4bf54d05eb06d493c"
46
+ },
47
+ "control-003.sql": {
48
+ "bytes": 351,
49
+ "sha256": "7cf225bb30a80fb5ec7655bc6ad26ec5e296fd9edb86ea162770d3c418b56e32"
50
+ },
51
+ "dream-001.sql": {
52
+ "bytes": 370,
53
+ "sha256": "ac87b2becc5636645a4dcd14947040503091529eef114e9f589bea56ea1cc31e"
54
+ },
55
+ "main-001.sql": {
56
+ "bytes": 14497,
57
+ "sha256": "062d36f2651fc0ef55c3b329dd00ffff45ad0564d40d82fedffa7dfe75391bee"
58
+ },
59
+ "main-002.sql": {
60
+ "bytes": 3023,
61
+ "sha256": "dbbe52c06365c5903130283d22c295d0cc06492a6d9ee6a5f1702af6f789d96b"
62
+ },
63
+ "main-003.sql": {
64
+ "bytes": 706,
65
+ "sha256": "315330ffc9ffcfeecec7bf841688228630fd105b421d167ec080b559703ab633"
66
+ },
67
+ "main-004.sql": {
68
+ "bytes": 1285,
69
+ "sha256": "0a585a2115325db8d413e45ae97d287e77af061f435c77b528e9460dd1c33bba"
70
+ },
71
+ "main-005.sql": {
72
+ "bytes": 4675,
73
+ "sha256": "686a185473ed35c714768c9106a0f5165a14ee05a6f89c76be457894b0c81258"
74
+ },
75
+ "main-006.sql": {
76
+ "bytes": 1162,
77
+ "sha256": "695f0ad844bd6fb91813fc785ddebe1b1e6fa741300e89aff214250bd6389c1f"
78
+ }
79
+ },
80
+ "expected_versions_before": {
81
+ "main": 6,
82
+ "control": 3,
83
+ "dream": 1
84
+ },
85
+ "expected_versions_c1": {
86
+ "main": 7,
87
+ "control": 4,
88
+ "dream": 2
89
+ }
90
+ }
@@ -500,7 +500,7 @@ test('P3-C1 migration contract', async (t) => {
500
500
  }
501
501
  assert.deepEqual(versions, { main: 8, control: 4, dream: 2 });
502
502
  const baseline = JSON.parse(
503
- fs.readFileSync(path.join(ROOT, '..', '_build', 'artifacts', 'p3', 'c1-baseline.json'), 'utf8'),
503
+ fs.readFileSync(path.join(ROOT, 'test', 'fixtures', 'p3', 'c1-baseline.json'), 'utf8'),
504
504
  );
505
505
  for (const [name, entry] of Object.entries(baseline.migrations)) {
506
506
  if (name === 'control-004.sql' || name === 'main-007.sql' || name === 'dream-002.sql') continue;
@@ -0,0 +1,60 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+
4
+ import {
5
+ insertCandidate,
6
+ insertMemory,
7
+ insertObservation,
8
+ insertPointer,
9
+ insertTimeline,
10
+ } from '../src/storage/repositories.mjs';
11
+ import { mergeExact } from '../src/dream/organize.mjs';
12
+ import { createTestContext, makeCandidate, makeMemory, makeObservation, mutableClock } from './helpers/fixtures.mjs';
13
+ import { fakeRuntime } from './helpers/runtime.mjs';
14
+
15
+ let seq = 0;
16
+
17
+ function memory(ctx, { type = 'fact', dueState = 'none', deadline = null } = {}) {
18
+ seq += 1;
19
+ const text = '完全相同的合并记忆';
20
+ const observation = makeObservation(ctx, { messageSeq: 25000 + seq, text });
21
+ insertObservation(ctx.main, observation);
22
+ const candidate = makeCandidate(ctx, observation, { businessKey: `p6b1-${seq}`, text, why: 'w', type, scene: 'work', scope: 'work-only' });
23
+ Object.assign(candidate, { key_status: 'resolved', binding_status: 'accepted', binding_basis: '{}', origin_mode: 'self', semantic_status: 'quoted', record_kind: 'utterance' });
24
+ insertCandidate(ctx.main, candidate);
25
+ const row = makeMemory(ctx, candidate);
26
+ Object.assign(row, {
27
+ key_status: 'resolved', binding_status: 'accepted', binding_basis: '{}', origin_mode: 'self',
28
+ semantic_status: 'quoted', record_kind: 'utterance', due_state: dueState,
29
+ });
30
+ insertMemory(ctx.main, row);
31
+ insertTimeline(ctx.main, { memory_id: row.id, start_at: null, end_at: null, prec_start: 'unknown', prec_end: 'unknown', time_state: 'point', revision: 1 });
32
+ insertPointer(ctx.main, { memory_id: row.id, status: 'unknown', location: null, search_hint: null, lossy: 0, revision: 1 });
33
+ if (deadline) {
34
+ ctx.main
35
+ .prepare("INSERT INTO due_queue (memory_id, due_state, enqueued_seq, enqueued_at, opened_at, deadline_at, revision, reason) VALUES (?, 'none', 1, ?, NULL, ?, 0, 'test')")
36
+ .run(row.id, ctx.clock.at().at, deadline);
37
+ }
38
+ return row;
39
+ }
40
+
41
+ function merge(ctx, a, b) {
42
+ return mergeExact(
43
+ { main: ctx.main, control: ctx.control, clock: ctx.clock },
44
+ { runtime: fakeRuntime({ role: 'dream', callId: 'p6b1' }), ids: [a.id, b.id] },
45
+ );
46
+ }
47
+
48
+ test('P6/B1: promise, done/dropped and non-empty due are blocked from merge', (t) => {
49
+ const ctx = createTestContext({ clock: mutableClock().clock });
50
+ t.after(() => ctx.cleanup());
51
+ for (const options of [{ type: 'promise' }, { dueState: 'done' }, { dueState: 'dropped' }, { deadline: '2026-12-01T00:00:00Z' }]) {
52
+ const a = memory(ctx, options);
53
+ const b = memory(ctx, options);
54
+ assert.throws(
55
+ () => merge(ctx, a, b),
56
+ (err) => err.code === 'blocked_by_due_state',
57
+ JSON.stringify(options),
58
+ );
59
+ }
60
+ });
@@ -0,0 +1,77 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import crypto from 'node:crypto';
7
+
8
+ import { applyPlan, buildPlan, verifyPlan } from '../src/import/legacy.mjs';
9
+ import { applyPointers, planPointers } from '../src/import/pointers.mjs';
10
+ import { resolveConfig } from '../src/config.mjs';
11
+ import { closeAll, openAll } from '../src/storage/open.mjs';
12
+ import { migrateAll } from '../src/storage/migrate.mjs';
13
+ import { seedInitialRegistry } from '../src/storage/repositories.mjs';
14
+ import { canonicalJson } from '../src/identity.mjs';
15
+ import { mutableClock } from './helpers/fixtures.mjs';
16
+
17
+ function legacyCtx() {
18
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-memsys-p6b2-'));
19
+ const source = path.join(dir, 'source');
20
+ fs.mkdirSync(source, { recursive: true });
21
+ const clock = mutableClock().clock;
22
+ const handles = openAll(resolveConfig({ dataDir: path.join(dir, 'target') }));
23
+ migrateAll(handles, { clock });
24
+ handles.main.exec('BEGIN IMMEDIATE');
25
+ seedInitialRegistry(handles.main, clock);
26
+ handles.main.exec('COMMIT');
27
+ return {
28
+ dir, source, handles, clock,
29
+ deps: { main: handles.main, control: handles.control, clock },
30
+ cleanup() { try { closeAll(handles); } catch {} fs.rmSync(dir, { recursive: true, force: true }); },
31
+ };
32
+ }
33
+
34
+ test('P6/B2: changing a source byte blocks pointer apply before relation write', (t) => {
35
+ const ctx = legacyCtx();
36
+ t.after(() => ctx.cleanup());
37
+ const raw = '真实原文';
38
+ const record = { id: 'b2p', kind: 'point', raw, gist: '摘要', why: 'w', type: 'fact', scene: 'work', scope: 'work-only', pointer: { status: 'unknown' } };
39
+ fs.writeFileSync(path.join(ctx.source, 'legacy.json'), JSON.stringify({ records: [record], graph: { nodes: [], edges: [] } }), 'utf8');
40
+ const plan = buildPlan(ctx.source);
41
+ applyPlan(ctx.deps, plan);
42
+ const item = plan.items.find((entry) => entry.itemKind === 'memory');
43
+ const bytes = Buffer.from(raw, 'utf8');
44
+ const file = path.join(ctx.source, 'b2p.md');
45
+ fs.writeFileSync(file, bytes);
46
+ const versions = path.join(ctx.source, 'versions.json');
47
+ fs.writeFileSync(versions, JSON.stringify({ versions: [{
48
+ sourceObjectId: 'b2p', versionHash: item.sourceVersionHash,
49
+ location: file + '#bytes:0-' + bytes.length,
50
+ file, span: { start: 0, end: bytes.length },
51
+ spanHash: crypto.createHash('sha256').update(bytes).digest('hex'),
52
+ }] }), 'utf8');
53
+ const pointerPlan = planPointers({ main: ctx.handles.main, clock: ctx.clock }, versions);
54
+ assert.equal(pointerPlan.items.length, 1, JSON.stringify(pointerPlan.skipped));
55
+ fs.writeFileSync(file, Buffer.concat([bytes, Buffer.from('x')]));
56
+ assert.throws(
57
+ () => applyPointers({ main: ctx.handles.main, clock: ctx.clock }, pointerPlan),
58
+ (err) => err.code === 'source_bytes_changed',
59
+ );
60
+ const changes = ctx.handles.main.prepare("SELECT COUNT(*) AS n FROM relations WHERE type = 'pointer-change' AND json_extract(payload, '$.basis') = 'pointer-backfill'").get().n;
61
+ assert.equal(Number(changes), 0);
62
+ });
63
+
64
+ test('P6/B2: changed graph node label makes verifyPlan fail', (t) => {
65
+ const ctx = legacyCtx();
66
+ t.after(() => ctx.cleanup());
67
+ const record = { id: 'b2g', kind: 'point', raw: '文本', gist: '摘要', why: 'w', type: 'fact', scene: 'work', scope: 'work-only', pointer: { status: 'unknown' } };
68
+ const graph = { nodes: [{ id: 'n1', label: '旧标签', kind: 'concept' }], edges: [] };
69
+ fs.writeFileSync(path.join(ctx.source, 'legacy.json'), JSON.stringify({ records: [record], graph }), 'utf8');
70
+ const plan = buildPlan(ctx.source);
71
+ applyPlan(ctx.deps, plan);
72
+ ctx.handles.main.exec('DROP TRIGGER IF EXISTS trg_graph_nodes_no_update');
73
+ ctx.handles.main.prepare('UPDATE graph_nodes SET label = ? WHERE id = ?').run('新标签', 'n1');
74
+ const verify = verifyPlan({ main: ctx.handles.main, clock: ctx.clock }, plan);
75
+ assert.equal(verify.status, 'failed');
76
+ assert.ok(verify.missing.some((entry) => entry.includes('graph_node_content')), JSON.stringify(verify.missing));
77
+ });
@@ -0,0 +1,38 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+
4
+ import {
5
+ compareBusinessRows,
6
+ compareDatabaseFreeze,
7
+ snapshotBusinessRows,
8
+ snapshotDatabaseFreeze,
9
+ } from '../src/import/rollback.mjs';
10
+ import { createTestContext, mutableClock } from './helpers/fixtures.mjs';
11
+
12
+ test('P6/L6: rollback detects changed row content even when counts match', (t) => {
13
+ const ctx = createTestContext({ clock: mutableClock().clock });
14
+ t.after(() => ctx.cleanup());
15
+ ctx.main
16
+ .prepare("INSERT INTO observations (id, idempotency_key, at, payload_hash, text, source, author_kind, runtime_refs, origin, lossy, state_snapshot, initial_strength, recorded_at) VALUES ('obs_l6_content','obs:l6c',?, 'h','原始内容','file','unknown','{}','migrated',0,'{}',0.5,?)")
17
+ .run(ctx.clock.at().at, ctx.clock.at().at);
18
+ const baseline = snapshotBusinessRows(ctx.main);
19
+ ctx.main.exec('DROP TRIGGER IF EXISTS trg_observations_no_update');
20
+ ctx.main.prepare('UPDATE observations SET text = ? WHERE id = ?').run('改过的内容', 'obs_l6_content');
21
+ const compared = compareBusinessRows({ db: ctx.main, baseline });
22
+ assert.equal(compared.ok, false, 'same count must not pass when content changed');
23
+ assert.ok(compared.missing.some((entry) => entry.includes('content')), JSON.stringify(compared));
24
+ });
25
+
26
+ test('P6/L6: boundary freeze detects changed content with unchanged counts', (t) => {
27
+ const ctx = createTestContext({ clock: mutableClock().clock });
28
+ t.after(() => ctx.cleanup());
29
+ ctx.main
30
+ .prepare("INSERT INTO observations (id, idempotency_key, at, payload_hash, text, source, author_kind, runtime_refs, origin, lossy, state_snapshot, initial_strength, recorded_at) VALUES ('obs_l6_freeze','obs:l6f',?, 'h','原始','file','unknown','{}','migrated',0,'{}',0.5,?)")
31
+ .run(ctx.clock.at().at, ctx.clock.at().at);
32
+ const freeze = snapshotDatabaseFreeze(ctx.handles);
33
+ ctx.main.exec('DROP TRIGGER IF EXISTS trg_observations_no_update');
34
+ ctx.main.prepare('UPDATE observations SET text = ? WHERE id = ?').run('改过', 'obs_l6_freeze');
35
+ const compared = compareDatabaseFreeze(ctx.handles, freeze);
36
+ assert.equal(compared.ok, false);
37
+ assert.ok(compared.missing.some((entry) => entry.includes('content')), JSON.stringify(compared));
38
+ });