@cleocode/core 2026.9.16 → 2026.9.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/changesets/parser.d.ts.map +1 -1
- package/dist/changesets/parser.js +67 -0
- package/dist/changesets/parser.js.map +1 -1
- package/dist/crypto/credentials.d.ts +128 -26
- package/dist/crypto/credentials.d.ts.map +1 -1
- package/dist/crypto/credentials.js +183 -142
- package/dist/crypto/credentials.js.map +1 -1
- package/dist/docs/export-document.js +14705 -12949
- package/dist/docs/export-document.js.map +3 -3
- package/dist/doctor/index.d.ts +1 -1
- package/dist/doctor/index.d.ts.map +1 -1
- package/dist/doctor/index.js +1 -1
- package/dist/doctor/index.js.map +1 -1
- package/dist/doctor/nexus-schema-residency.d.ts +1 -1
- package/dist/doctor/nexus-schema-residency.d.ts.map +1 -1
- package/dist/doctor/nexus-schema-residency.js +1 -0
- package/dist/doctor/nexus-schema-residency.js.map +1 -1
- package/dist/doctor/project-identity.d.ts +124 -0
- package/dist/doctor/project-identity.d.ts.map +1 -0
- package/dist/doctor/project-identity.js +307 -0
- package/dist/doctor/project-identity.js.map +1 -0
- package/dist/doctor/superseded-store.d.ts +8 -0
- package/dist/doctor/superseded-store.d.ts.map +1 -1
- package/dist/doctor/superseded-store.js +48 -3
- package/dist/doctor/superseded-store.js.map +1 -1
- package/dist/init.d.ts +7 -0
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +17 -2
- package/dist/init.js.map +1 -1
- package/dist/internal.d.ts +5 -2
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +4 -2
- package/dist/internal.js.map +1 -1
- package/dist/llm/credentials-store.d.ts +10 -0
- package/dist/llm/credentials-store.d.ts.map +1 -1
- package/dist/llm/credentials-store.js +7 -1
- package/dist/llm/credentials-store.js.map +1 -1
- package/dist/llm/plugin-facade.js +13368 -1550
- package/dist/llm/plugin-facade.js.map +3 -3
- package/dist/nexus/analyze-orchestrator.d.ts +20 -3
- package/dist/nexus/analyze-orchestrator.d.ts.map +1 -1
- package/dist/nexus/analyze-orchestrator.js +159 -10
- package/dist/nexus/analyze-orchestrator.js.map +1 -1
- package/dist/nexus/assessment-store.d.ts +57 -0
- package/dist/nexus/assessment-store.d.ts.map +1 -0
- package/dist/nexus/assessment-store.js +73 -0
- package/dist/nexus/assessment-store.js.map +1 -0
- package/dist/nexus/freshness.d.ts +131 -0
- package/dist/nexus/freshness.d.ts.map +1 -0
- package/dist/nexus/freshness.js +391 -0
- package/dist/nexus/freshness.js.map +1 -0
- package/dist/nexus/graph-manifest.d.ts +88 -0
- package/dist/nexus/graph-manifest.d.ts.map +1 -0
- package/dist/nexus/graph-manifest.js +114 -0
- package/dist/nexus/graph-manifest.js.map +1 -0
- package/dist/nexus/knowledge.d.ts +21 -1
- package/dist/nexus/knowledge.d.ts.map +1 -1
- package/dist/nexus/knowledge.js +76 -37
- package/dist/nexus/knowledge.js.map +1 -1
- package/dist/nexus/path-map.d.ts +55 -0
- package/dist/nexus/path-map.d.ts.map +1 -0
- package/dist/nexus/path-map.js +87 -0
- package/dist/nexus/path-map.js.map +1 -0
- package/dist/nexus/projects-clean.d.ts +21 -28
- package/dist/nexus/projects-clean.d.ts.map +1 -1
- package/dist/nexus/projects-clean.js +168 -85
- package/dist/nexus/projects-clean.js.map +1 -1
- package/dist/nexus/query.d.ts.map +1 -1
- package/dist/nexus/query.js +3 -2
- package/dist/nexus/query.js.map +1 -1
- package/dist/nexus/registry-hygiene.d.ts +70 -0
- package/dist/nexus/registry-hygiene.d.ts.map +1 -0
- package/dist/nexus/registry-hygiene.js +113 -0
- package/dist/nexus/registry-hygiene.js.map +1 -0
- package/dist/nexus/registry.d.ts +2 -2
- package/dist/nexus/registry.d.ts.map +1 -1
- package/dist/nexus/registry.js +48 -12
- package/dist/nexus/registry.js.map +1 -1
- package/dist/paths.d.ts +26 -0
- package/dist/paths.d.ts.map +1 -1
- package/dist/paths.js +99 -6
- package/dist/paths.js.map +1 -1
- package/dist/render/nexus/graph/index.d.ts.map +1 -1
- package/dist/render/nexus/graph/index.js +8 -0
- package/dist/render/nexus/graph/index.js.map +1 -1
- package/dist/scaffold/ensure-config.d.ts +12 -3
- package/dist/scaffold/ensure-config.d.ts.map +1 -1
- package/dist/scaffold/ensure-config.js +61 -38
- package/dist/scaffold/ensure-config.js.map +1 -1
- package/dist/scaffold/project-identity.d.ts +136 -0
- package/dist/scaffold/project-identity.d.ts.map +1 -0
- package/dist/scaffold/project-identity.js +225 -0
- package/dist/scaffold/project-identity.js.map +1 -0
- package/dist/selfimprove/fix-gen.js +13319 -1556
- package/dist/selfimprove/fix-gen.js.map +3 -3
- package/dist/store/agent-api-key.d.ts +114 -0
- package/dist/store/agent-api-key.d.ts.map +1 -0
- package/dist/store/agent-api-key.js +136 -0
- package/dist/store/agent-api-key.js.map +1 -0
- package/dist/store/agent-registry-accessor.d.ts +4 -2
- package/dist/store/agent-registry-accessor.d.ts.map +1 -1
- package/dist/store/agent-registry-accessor.js +39 -89
- package/dist/store/agent-registry-accessor.js.map +1 -1
- package/dist/store/api-key-kdf.d.ts +7 -0
- package/dist/store/api-key-kdf.d.ts.map +1 -1
- package/dist/store/api-key-kdf.js +7 -0
- package/dist/store/api-key-kdf.js.map +1 -1
- package/dist/store/backup-crypto.d.ts +49 -0
- package/dist/store/backup-crypto.d.ts.map +1 -1
- package/dist/store/backup-crypto.js +154 -0
- package/dist/store/backup-crypto.js.map +1 -1
- package/dist/store/credential-transfer.d.ts +303 -0
- package/dist/store/credential-transfer.d.ts.map +1 -0
- package/dist/store/credential-transfer.js +774 -0
- package/dist/store/credential-transfer.js.map +1 -0
- package/dist/store/exodus/column-transforms.d.ts +79 -4
- package/dist/store/exodus/column-transforms.d.ts.map +1 -1
- package/dist/store/exodus/column-transforms.js +121 -4
- package/dist/store/exodus/column-transforms.js.map +1 -1
- package/dist/store/exodus/index.d.ts +1 -0
- package/dist/store/exodus/index.d.ts.map +1 -1
- package/dist/store/exodus/index.js +1 -0
- package/dist/store/exodus/index.js.map +1 -1
- package/dist/store/exodus/migrate.d.ts +17 -1
- package/dist/store/exodus/migrate.d.ts.map +1 -1
- package/dist/store/exodus/migrate.js +155 -33
- package/dist/store/exodus/migrate.js.map +1 -1
- package/dist/store/exodus/on-open.d.ts +14 -0
- package/dist/store/exodus/on-open.d.ts.map +1 -1
- package/dist/store/exodus/on-open.js +115 -6
- package/dist/store/exodus/on-open.js.map +1 -1
- package/dist/store/exodus/reconcile.d.ts +103 -0
- package/dist/store/exodus/reconcile.d.ts.map +1 -0
- package/dist/store/exodus/reconcile.js +728 -0
- package/dist/store/exodus/reconcile.js.map +1 -0
- package/dist/store/exodus/recovery.d.ts.map +1 -1
- package/dist/store/exodus/recovery.js +14 -2
- package/dist/store/exodus/recovery.js.map +1 -1
- package/dist/store/exodus/runtime-targets.d.ts +39 -0
- package/dist/store/exodus/runtime-targets.d.ts.map +1 -0
- package/dist/store/exodus/runtime-targets.js +63 -0
- package/dist/store/exodus/runtime-targets.js.map +1 -0
- package/dist/store/exodus/table-name-map.d.ts.map +1 -1
- package/dist/store/exodus/table-name-map.js +5 -0
- package/dist/store/exodus/table-name-map.js.map +1 -1
- package/dist/store/exodus/table-order.d.ts +35 -0
- package/dist/store/exodus/table-order.d.ts.map +1 -0
- package/dist/store/exodus/table-order.js +62 -0
- package/dist/store/exodus/table-order.js.map +1 -0
- package/dist/store/exodus/verify-migration.d.ts +4 -1
- package/dist/store/exodus/verify-migration.d.ts.map +1 -1
- package/dist/store/exodus/verify-migration.js +142 -10
- package/dist/store/exodus/verify-migration.js.map +1 -1
- package/dist/store/global-salt.d.ts +16 -0
- package/dist/store/global-salt.d.ts.map +1 -1
- package/dist/store/global-salt.js +22 -7
- package/dist/store/global-salt.js.map +1 -1
- package/dist/store/legacy-tasks-lineage.d.ts +86 -0
- package/dist/store/legacy-tasks-lineage.d.ts.map +1 -0
- package/dist/store/legacy-tasks-lineage.js +285 -0
- package/dist/store/legacy-tasks-lineage.js.map +1 -0
- package/dist/store/migration-manager.d.ts +27 -0
- package/dist/store/migration-manager.d.ts.map +1 -1
- package/dist/store/migration-manager.js +63 -0
- package/dist/store/migration-manager.js.map +1 -1
- package/dist/store/portable-bundle-import.d.ts +117 -0
- package/dist/store/portable-bundle-import.d.ts.map +1 -0
- package/dist/store/portable-bundle-import.js +589 -0
- package/dist/store/portable-bundle-import.js.map +1 -0
- package/dist/store/portable-bundle-relocate.d.ts +66 -0
- package/dist/store/portable-bundle-relocate.d.ts.map +1 -0
- package/dist/store/portable-bundle-relocate.js +304 -0
- package/dist/store/portable-bundle-relocate.js.map +1 -0
- package/dist/store/portable-bundle-scan.d.ts +167 -0
- package/dist/store/portable-bundle-scan.d.ts.map +1 -0
- package/dist/store/portable-bundle-scan.js +569 -0
- package/dist/store/portable-bundle-scan.js.map +1 -0
- package/dist/store/portable-bundle.d.ts +146 -0
- package/dist/store/portable-bundle.d.ts.map +1 -0
- package/dist/store/portable-bundle.js +674 -0
- package/dist/store/portable-bundle.js.map +1 -0
- package/dist/store/regenerators.d.ts +3 -1
- package/dist/store/regenerators.d.ts.map +1 -1
- package/dist/store/regenerators.js +6 -2
- package/dist/store/regenerators.js.map +1 -1
- package/dist/store/schema/cleo-global/nexus.d.ts +98 -0
- package/dist/store/schema/cleo-global/nexus.d.ts.map +1 -1
- package/dist/store/schema/cleo-global/nexus.js +19 -0
- package/dist/store/schema/cleo-global/nexus.js.map +1 -1
- package/dist/store/schema/nexus-schema.d.ts +102 -0
- package/dist/store/schema/nexus-schema.d.ts.map +1 -1
- package/dist/store/schema/nexus-schema.js +24 -0
- package/dist/store/schema/nexus-schema.js.map +1 -1
- package/dist/store/split-brain-import.d.ts +176 -0
- package/dist/store/split-brain-import.d.ts.map +1 -0
- package/dist/store/split-brain-import.js +680 -0
- package/dist/store/split-brain-import.js.map +1 -0
- package/dist/store/sqlite.d.ts +29 -0
- package/dist/store/sqlite.d.ts.map +1 -1
- package/dist/store/sqlite.js +57 -5
- package/dist/store/sqlite.js.map +1 -1
- package/dist/system/health.d.ts.map +1 -1
- package/dist/system/health.js +30 -5
- package/dist/system/health.js.map +1 -1
- package/dist/upgrade.d.ts.map +1 -1
- package/dist/upgrade.js +60 -0
- package/dist/upgrade.js.map +1 -1
- package/dist/validation/doctor/checks.d.ts +14 -0
- package/dist/validation/doctor/checks.d.ts.map +1 -1
- package/dist/validation/doctor/checks.js +33 -0
- package/dist/validation/doctor/checks.js.map +1 -1
- package/migrations/drizzle-cleo-global/20260924000000_t12354-project-paths/migration.sql +40 -0
- package/migrations/drizzle-nexus/20260924000000_t12315-nexus-parse-cache/migration.sql +21 -0
- package/package.json +13 -13
- package/templates/CLEO-INJECTION.md +3 -3
- package/templates/cleo-gitignore +7 -0
|
@@ -0,0 +1,680 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Split-brain import: bring the rows only one copy of a project store has
|
|
3
|
+
* into another copy, under new ids, without touching a single pre-existing
|
|
4
|
+
* row (T12329).
|
|
5
|
+
*
|
|
6
|
+
* ## The failure this repairs
|
|
7
|
+
*
|
|
8
|
+
* A project directory copied to a second path keeps its `project-info.json`
|
|
9
|
+
* and its `.cleo/cleo.db`. From then on, both copies write under one
|
|
10
|
+
* projectId. Both task allocators continue from the same counter, so the
|
|
11
|
+
* same `T####` names different work in each copy. Brain observations
|
|
12
|
+
* written in one copy never reach the other. Measured on cleocode, whose
|
|
13
|
+
* checkout was rsynced to `/home/keatonhoskins/cleocode` on 2026-09-14:
|
|
14
|
+
* T12188–T12202 exist in both stores with different titles, and 11
|
|
15
|
+
* observations exist only in `/home`.
|
|
16
|
+
*
|
|
17
|
+
* ## What counts as a source-only row
|
|
18
|
+
*
|
|
19
|
+
* The copies were one store until they diverged. The divergence point `T0` is
|
|
20
|
+
* the newest `created_at` among tasks present in BOTH stores with the SAME
|
|
21
|
+
* `created_at`. A source row absent from the target is imported only if it was
|
|
22
|
+
* written after `T0`. A source row written before `T0` was present in the
|
|
23
|
+
* target at divergence, so its absence there means the target deleted it (for
|
|
24
|
+
* example by session GC). Resurrecting it would undo the target's own history,
|
|
25
|
+
* so such rows are counted and reported, never imported.
|
|
26
|
+
*
|
|
27
|
+
* A task whose id exists in both stores with a different `created_at` is a
|
|
28
|
+
* COLLISION: the same id holds different work. Every imported task gets a newly
|
|
29
|
+
* minted id: `max(sequence counter, max stored id) + k`, the same lower bound
|
|
30
|
+
* `allocateNextTaskId` uses. The live allocator therefore continues past the
|
|
31
|
+
* imported ids with no counter write. Child rows follow their task (acceptance
|
|
32
|
+
* criteria, AC history, dependencies, relations, labels, work history, commit
|
|
33
|
+
* links, audit rows). Brain rows and sessions keep their ids, which are
|
|
34
|
+
* time+random and absent in the target by construction. A brain or session id
|
|
35
|
+
* that collides is reported as unresolved, not imported.
|
|
36
|
+
*
|
|
37
|
+
* ## What is deliberately not imported (reported with counts)
|
|
38
|
+
*
|
|
39
|
+
* - telemetry: usage, retrieval and token logs;
|
|
40
|
+
* - derived data the runtime rebuilds: the brain page graph and embeddings;
|
|
41
|
+
* - parent-owned child-projection AC rows on a parent that is NOT imported. A
|
|
42
|
+
* rebuild rewrites the parent's whole AC set, and this import never modifies
|
|
43
|
+
* a pre-existing row. The runtime rebuild owns it;
|
|
44
|
+
* - rows in any other table that reference an imported task. They are counted
|
|
45
|
+
* per table so coverage is never silently partial.
|
|
46
|
+
*
|
|
47
|
+
* ## Provenance
|
|
48
|
+
*
|
|
49
|
+
* Every imported entity gets a `tasks_audit_log` row: action
|
|
50
|
+
* `split_brain_import`, the original id, the new id, the source store path and
|
|
51
|
+
* label. Every imported task also carries a note naming its original id. The
|
|
52
|
+
* returned receipt lists the full id map.
|
|
53
|
+
*
|
|
54
|
+
* The target is written in ONE `BEGIN IMMEDIATE` transaction, or not at all
|
|
55
|
+
* (`dryRun`, the default in the CLI). The source is opened read-only.
|
|
56
|
+
*
|
|
57
|
+
* @task T12329
|
|
58
|
+
*/
|
|
59
|
+
import { randomBytes } from 'node:crypto';
|
|
60
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
61
|
+
import { buildAcRowId } from '../tasks/ac-table.js';
|
|
62
|
+
/** Child tables that follow an imported task. `refs` are the task-id columns. */
|
|
63
|
+
const TASK_CHILD_TABLES = [
|
|
64
|
+
{ table: 'tasks_task_dependencies', refs: ['task_id', 'depends_on'] },
|
|
65
|
+
{ table: 'tasks_task_relations', refs: ['task_id', 'related_to'] },
|
|
66
|
+
{ table: 'tasks_task_labels', refs: ['task_id'] },
|
|
67
|
+
{ table: 'tasks_task_work_history', refs: ['task_id'] },
|
|
68
|
+
{ table: 'tasks_task_commits', refs: ['task_id'] },
|
|
69
|
+
{ table: 'tasks_audit_log', refs: ['task_id'] },
|
|
70
|
+
];
|
|
71
|
+
/** Id-keyed entities imported under their own id when written after divergence. */
|
|
72
|
+
const KEPT_ID_ENTITIES = [
|
|
73
|
+
{
|
|
74
|
+
table: 'tasks_sessions',
|
|
75
|
+
time: 'started_at',
|
|
76
|
+
taskRefs: ['current_task'],
|
|
77
|
+
jsonTaskRefs: ['tasks_completed_json', 'tasks_created_json'],
|
|
78
|
+
},
|
|
79
|
+
{ table: 'brain_observations', time: 'created_at', taskRefs: [] },
|
|
80
|
+
{ table: 'brain_decisions', time: 'created_at', taskRefs: ['context_task_id'] },
|
|
81
|
+
{ table: 'brain_learnings', time: 'created_at', taskRefs: [] },
|
|
82
|
+
{ table: 'brain_patterns', time: 'extracted_at', taskRefs: [] },
|
|
83
|
+
// The bare operation audit log is still written by the dispatch layer (it is
|
|
84
|
+
// not a dead relic), so its post-divergence history is imported too.
|
|
85
|
+
{ table: 'audit_log', time: 'timestamp', taskRefs: ['task_id'] },
|
|
86
|
+
{ table: 'tasks_releases', time: 'created_at', taskRefs: [], jsonTaskRefs: ['tasks_json'] },
|
|
87
|
+
{
|
|
88
|
+
table: 'tasks_release_changesets',
|
|
89
|
+
time: 'created_at',
|
|
90
|
+
taskRefs: [],
|
|
91
|
+
jsonTaskRefs: ['task_ids'],
|
|
92
|
+
},
|
|
93
|
+
];
|
|
94
|
+
/** Source-only rows in these tables are skipped as telemetry or derived data. */
|
|
95
|
+
const EXCLUDED_TABLES = {
|
|
96
|
+
brain_usage_log: 'telemetry (not replicated)',
|
|
97
|
+
brain_retrieval_log: 'telemetry (not replicated)',
|
|
98
|
+
tasks_token_usage: 'telemetry (not replicated)',
|
|
99
|
+
token_usage: 'telemetry (not replicated)',
|
|
100
|
+
brain_page_nodes: 'derived brain graph (rebuilt by the runtime)',
|
|
101
|
+
brain_page_edges: 'derived brain graph (rebuilt by the runtime)',
|
|
102
|
+
};
|
|
103
|
+
/** Timestamp columns used to date a row in the coverage scan, in preference order. */
|
|
104
|
+
const COVERAGE_TIME_COLUMNS = [
|
|
105
|
+
'created_at',
|
|
106
|
+
'timestamp',
|
|
107
|
+
'recorded_at',
|
|
108
|
+
'started_at',
|
|
109
|
+
'extracted_at',
|
|
110
|
+
];
|
|
111
|
+
/** Tables whose rows reference tasks and are handled explicitly above. */
|
|
112
|
+
const HANDLED_TASK_REF_TABLES = new Set([
|
|
113
|
+
'tasks_tasks',
|
|
114
|
+
'tasks_task_acceptance_criteria',
|
|
115
|
+
'tasks_task_acceptance_criteria_history',
|
|
116
|
+
'brain_memory_links',
|
|
117
|
+
'tasks_evidence_ac_bindings',
|
|
118
|
+
...TASK_CHILD_TABLES.map((spec) => spec.table),
|
|
119
|
+
...KEPT_ID_ENTITIES.map((spec) => spec.table),
|
|
120
|
+
...Object.keys(EXCLUDED_TABLES),
|
|
121
|
+
]);
|
|
122
|
+
/**
|
|
123
|
+
* Parse CLEO's mixed timestamp formats to epoch ms.
|
|
124
|
+
*
|
|
125
|
+
* `datetime('now')` writes `YYYY-MM-DD HH:MM:SS` with no zone, meaning UTC.
|
|
126
|
+
* Comparing that text against ISO text is wrong within a day (`' '` sorts
|
|
127
|
+
* before `'T'`), so every comparison goes through this function.
|
|
128
|
+
*
|
|
129
|
+
* @param value - Stored timestamp.
|
|
130
|
+
* @returns Epoch milliseconds, or `null` when absent or unparseable.
|
|
131
|
+
*/
|
|
132
|
+
export function parseStoreTimestamp(value) {
|
|
133
|
+
if (typeof value !== 'string' || value.length === 0)
|
|
134
|
+
return null;
|
|
135
|
+
const iso = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}(:\d{2}(\.\d+)?)?$/.test(value)
|
|
136
|
+
? `${value.replace(' ', 'T')}Z`
|
|
137
|
+
: value;
|
|
138
|
+
const ms = Date.parse(iso);
|
|
139
|
+
return Number.isNaN(ms) ? null : ms;
|
|
140
|
+
}
|
|
141
|
+
/** Quote an identifier for SQL. */
|
|
142
|
+
function q(name) {
|
|
143
|
+
return `"${name.replaceAll('"', '""')}"`;
|
|
144
|
+
}
|
|
145
|
+
/** Column metadata for a table, or `null` when the table does not exist. */
|
|
146
|
+
function columns(db, table) {
|
|
147
|
+
return db.prepare(`SELECT name, pk, type FROM pragma_table_info(?)`).all(table);
|
|
148
|
+
}
|
|
149
|
+
/** All rows of a table. */
|
|
150
|
+
function allRows(db, table) {
|
|
151
|
+
return db.prepare(`SELECT * FROM ${q(table)}`).all();
|
|
152
|
+
}
|
|
153
|
+
/** Whether a table exists. */
|
|
154
|
+
function hasTable(db, table) {
|
|
155
|
+
return (db.prepare(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?`).get(table) !==
|
|
156
|
+
undefined);
|
|
157
|
+
}
|
|
158
|
+
/** Stable key for a row's primary key (or all columns when there is none). */
|
|
159
|
+
function pkKey(row, pk) {
|
|
160
|
+
return JSON.stringify(pk.map((column) => String(row[column] ?? '')));
|
|
161
|
+
}
|
|
162
|
+
/** A new audit-log id in the runtime's `log-<epoch>-<hex>` format. */
|
|
163
|
+
function newLogId() {
|
|
164
|
+
return `log-${Math.floor(Date.now() / 1000)}-${randomBytes(3).toString('hex')}`;
|
|
165
|
+
}
|
|
166
|
+
/** Collects the INSERTs of one import so they can be counted before any write. */
|
|
167
|
+
class InsertPlan {
|
|
168
|
+
target;
|
|
169
|
+
rows = [];
|
|
170
|
+
byTable = {};
|
|
171
|
+
constructor(target) {
|
|
172
|
+
this.target = target;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Queue a row, keeping only columns the target has. An INTEGER PRIMARY KEY is
|
|
176
|
+
* dropped so the target assigns a fresh one.
|
|
177
|
+
*/
|
|
178
|
+
add(table, row) {
|
|
179
|
+
const targetCols = columns(this.target, table);
|
|
180
|
+
const out = {};
|
|
181
|
+
for (const col of targetCols) {
|
|
182
|
+
if (col.pk === 1 && col.type.toUpperCase() === 'INTEGER')
|
|
183
|
+
continue;
|
|
184
|
+
if (col.name in row)
|
|
185
|
+
out[col.name] = row[col.name] ?? null;
|
|
186
|
+
}
|
|
187
|
+
this.rows.push({ table, row: out });
|
|
188
|
+
this.byTable[table] = (this.byTable[table] ?? 0) + 1;
|
|
189
|
+
}
|
|
190
|
+
/** Execute every queued INSERT; any constraint violation aborts the caller's transaction. */
|
|
191
|
+
apply() {
|
|
192
|
+
for (const { table, row } of this.rows) {
|
|
193
|
+
const names = Object.keys(row);
|
|
194
|
+
this.target
|
|
195
|
+
.prepare(`INSERT INTO ${q(table)} (${names.map(q).join(', ')}) VALUES (${names.map(() => '?').join(', ')})`)
|
|
196
|
+
.run(...names.map((name) => row[name] ?? null));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Import the source-only rows of a split-brain store pair into the target.
|
|
202
|
+
*
|
|
203
|
+
* @param options - Source, target, dry-run flag and provenance label.
|
|
204
|
+
* @returns The receipt: divergence point, id map, per-table counts, and
|
|
205
|
+
* every skipped or unresolved row group with its reason.
|
|
206
|
+
* @throws {Error} When the stores share no task (they are not two copies of
|
|
207
|
+
* one store), or when the target write fails. A failed write is rolled
|
|
208
|
+
* back entirely.
|
|
209
|
+
*
|
|
210
|
+
* @example
|
|
211
|
+
* ```ts
|
|
212
|
+
* const plan = importSplitBrain({ sourcePath: home, targetPath: live, dryRun: true });
|
|
213
|
+
* ```
|
|
214
|
+
*/
|
|
215
|
+
export function importSplitBrain(options) {
|
|
216
|
+
const dryRun = options.dryRun !== false;
|
|
217
|
+
// db-open-allowed: a split-brain import reads a foreign store file by path; it is not this project's handle
|
|
218
|
+
const source = new DatabaseSync(options.sourcePath, { readOnly: true });
|
|
219
|
+
// db-open-allowed: the target is an explicit store file, opened read-only for a dry run
|
|
220
|
+
const target = new DatabaseSync(options.targetPath, { readOnly: dryRun });
|
|
221
|
+
try {
|
|
222
|
+
return runImport(source, target, options, dryRun);
|
|
223
|
+
}
|
|
224
|
+
finally {
|
|
225
|
+
source.close();
|
|
226
|
+
target.close();
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
function runImport(source, target, options, dryRun) {
|
|
230
|
+
const label = options.sourceLabel ?? options.sourcePath;
|
|
231
|
+
const mappings = [];
|
|
232
|
+
const skipped = [];
|
|
233
|
+
const unresolved = [];
|
|
234
|
+
const plan = new InsertPlan(target);
|
|
235
|
+
const now = new Date().toISOString();
|
|
236
|
+
// --- Divergence point -----------------------------------------------------
|
|
237
|
+
const targetTasks = new Map(allRows(target, 'tasks_tasks').map((row) => [String(row['id']), row]));
|
|
238
|
+
const sourceTasks = allRows(source, 'tasks_tasks');
|
|
239
|
+
let shared = 0;
|
|
240
|
+
let detectedT0 = Number.NEGATIVE_INFINITY;
|
|
241
|
+
for (const row of sourceTasks) {
|
|
242
|
+
const twin = targetTasks.get(String(row['id']));
|
|
243
|
+
if (twin && twin['created_at'] === row['created_at']) {
|
|
244
|
+
shared++;
|
|
245
|
+
detectedT0 = Math.max(detectedT0, parseStoreTimestamp(row['created_at']) ?? detectedT0);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (shared === 0)
|
|
249
|
+
throw new Error('The stores share no task (same id and created_at): they are not two copies of one store. Refusing to import.');
|
|
250
|
+
const t0 = options.divergedAfter
|
|
251
|
+
? (parseStoreTimestamp(options.divergedAfter) ?? detectedT0)
|
|
252
|
+
: detectedT0;
|
|
253
|
+
const afterT0 = (value) => {
|
|
254
|
+
const ms = parseStoreTimestamp(value);
|
|
255
|
+
return ms !== null && ms > t0;
|
|
256
|
+
};
|
|
257
|
+
// --- Tasks: mint new ids ----------------------------------------------------
|
|
258
|
+
const taskMap = new Map();
|
|
259
|
+
const importTasks = [];
|
|
260
|
+
let predating = 0;
|
|
261
|
+
for (const row of sourceTasks) {
|
|
262
|
+
const id = String(row['id']);
|
|
263
|
+
const twin = targetTasks.get(id);
|
|
264
|
+
if (twin && twin['created_at'] === row['created_at'])
|
|
265
|
+
continue;
|
|
266
|
+
if (!afterT0(row['created_at'])) {
|
|
267
|
+
predating++;
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
importTasks.push(row);
|
|
271
|
+
mappings.push({
|
|
272
|
+
table: 'tasks_tasks',
|
|
273
|
+
originalId: id,
|
|
274
|
+
newId: '',
|
|
275
|
+
reason: twin ? 'collision' : 'source-only',
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
if (predating > 0)
|
|
279
|
+
skipped.push({
|
|
280
|
+
table: 'tasks_tasks',
|
|
281
|
+
count: predating,
|
|
282
|
+
reason: 'absent in target but written before divergence (deleted in the target)',
|
|
283
|
+
});
|
|
284
|
+
// Same lower bound as allocateNextTaskId: max(counter, max stored numeric id).
|
|
285
|
+
const maxStored = Number(target
|
|
286
|
+
.prepare(`SELECT COALESCE(MAX(CAST(substr(id, 2) AS INTEGER)), 0) AS m FROM tasks_tasks
|
|
287
|
+
WHERE id GLOB 'T[0-9]*' AND substr(id, 2) NOT GLOB '*[^0-9]*'`)
|
|
288
|
+
.get().m);
|
|
289
|
+
const counterRow = hasTable(target, 'schema_meta')
|
|
290
|
+
? target
|
|
291
|
+
.prepare(`SELECT json_extract(value, '$.counter') AS c FROM schema_meta WHERE key = 'task_id_sequence'`)
|
|
292
|
+
.get()
|
|
293
|
+
: undefined;
|
|
294
|
+
let next = Math.max(maxStored, Number(counterRow?.c ?? 0));
|
|
295
|
+
// Parents first so a parent's new id exists before its children reference it.
|
|
296
|
+
const depth = (row) => {
|
|
297
|
+
let d = 0;
|
|
298
|
+
let parent = row['parent_id'];
|
|
299
|
+
const byId = new Map(importTasks.map((task) => [String(task['id']), task]));
|
|
300
|
+
while (typeof parent === 'string' && byId.has(parent) && d < 64) {
|
|
301
|
+
d++;
|
|
302
|
+
parent = byId.get(parent)?.['parent_id'] ?? null;
|
|
303
|
+
}
|
|
304
|
+
return d;
|
|
305
|
+
};
|
|
306
|
+
importTasks.sort((a, b) => depth(a) - depth(b));
|
|
307
|
+
for (const row of importTasks) {
|
|
308
|
+
next++;
|
|
309
|
+
taskMap.set(String(row['id']), `T${String(next).padStart(3, '0')}`);
|
|
310
|
+
}
|
|
311
|
+
for (let i = 0; i < mappings.length; i++) {
|
|
312
|
+
const mapping = mappings[i];
|
|
313
|
+
if (mapping?.table === 'tasks_tasks')
|
|
314
|
+
mappings[i] = { ...mapping, newId: taskMap.get(mapping.originalId) ?? '' };
|
|
315
|
+
}
|
|
316
|
+
const remapTask = (value) => typeof value === 'string' ? (taskMap.get(value) ?? value) : (value ?? null);
|
|
317
|
+
for (const row of importTasks) {
|
|
318
|
+
const oldId = String(row['id']);
|
|
319
|
+
const newId = taskMap.get(oldId) ?? oldId;
|
|
320
|
+
const notes = (() => {
|
|
321
|
+
try {
|
|
322
|
+
const parsed = JSON.parse(String(row['notes_json'] ?? '[]'));
|
|
323
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
return [];
|
|
327
|
+
}
|
|
328
|
+
})();
|
|
329
|
+
notes.push(`${now}: imported by split-brain reconcile (T12329) from ${label}, where this task was ${oldId}.`);
|
|
330
|
+
plan.add('tasks_tasks', {
|
|
331
|
+
...row,
|
|
332
|
+
id: newId,
|
|
333
|
+
parent_id: remapTask(row['parent_id']),
|
|
334
|
+
notes_json: JSON.stringify(notes),
|
|
335
|
+
idempotency_key: null,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
// --- Acceptance criteria of imported tasks ---------------------------------
|
|
339
|
+
const acMap = new Map();
|
|
340
|
+
let parentProjections = 0;
|
|
341
|
+
for (const row of hasTable(source, 'tasks_task_acceptance_criteria')
|
|
342
|
+
? allRows(source, 'tasks_task_acceptance_criteria')
|
|
343
|
+
: []) {
|
|
344
|
+
const owner = String(row['task_id']);
|
|
345
|
+
const ownerNew = taskMap.get(owner);
|
|
346
|
+
if (!ownerNew) {
|
|
347
|
+
if (typeof row['target_task_id'] === 'string' && taskMap.has(row['target_task_id']))
|
|
348
|
+
parentProjections++;
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
const kind = String(row['kind'] ?? 'text');
|
|
352
|
+
let sourceKey = String(row['source_key'] ?? '');
|
|
353
|
+
let text = String(row['text'] ?? '');
|
|
354
|
+
const oldTarget = row['target_task_id'];
|
|
355
|
+
const targetTask = remapTask(oldTarget);
|
|
356
|
+
if (typeof oldTarget === 'string' && taskMap.has(oldTarget)) {
|
|
357
|
+
// A projection of an imported child under an imported parent: rename in place.
|
|
358
|
+
sourceKey = sourceKey.replace(oldTarget, String(targetTask));
|
|
359
|
+
text = text.replace(oldTarget, String(targetTask));
|
|
360
|
+
}
|
|
361
|
+
const newAcId = buildAcRowId(ownerNew, kind === 'text' ? text : sourceKey);
|
|
362
|
+
acMap.set(String(row['id']), newAcId);
|
|
363
|
+
plan.add('tasks_task_acceptance_criteria', {
|
|
364
|
+
...row,
|
|
365
|
+
id: newAcId,
|
|
366
|
+
task_id: ownerNew,
|
|
367
|
+
target_task_id: targetTask,
|
|
368
|
+
source_key: sourceKey,
|
|
369
|
+
text,
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
if (parentProjections > 0)
|
|
373
|
+
skipped.push({
|
|
374
|
+
table: 'tasks_task_acceptance_criteria',
|
|
375
|
+
count: parentProjections,
|
|
376
|
+
reason: 'child-projection rows owned by a parent that is not imported; the runtime projection rebuild owns them (it rewrites the parent, which this import never does)',
|
|
377
|
+
});
|
|
378
|
+
if (hasTable(source, 'tasks_task_acceptance_criteria_history'))
|
|
379
|
+
for (const row of allRows(source, 'tasks_task_acceptance_criteria_history')) {
|
|
380
|
+
const acId = acMap.get(String(row['ac_id']));
|
|
381
|
+
if (acId)
|
|
382
|
+
plan.add('tasks_task_acceptance_criteria_history', { ...row, ac_id: acId });
|
|
383
|
+
}
|
|
384
|
+
// --- Other task-owned child rows ------------------------------------------
|
|
385
|
+
for (const spec of TASK_CHILD_TABLES) {
|
|
386
|
+
if (!hasTable(source, spec.table) || !hasTable(target, spec.table))
|
|
387
|
+
continue;
|
|
388
|
+
const pkCols = columns(target, spec.table);
|
|
389
|
+
const textPk = pkCols.filter((col) => col.pk > 0 && col.type.toUpperCase() !== 'INTEGER');
|
|
390
|
+
for (const row of allRows(source, spec.table)) {
|
|
391
|
+
if (!spec.refs.some((ref) => typeof row[ref] === 'string' && taskMap.has(row[ref])))
|
|
392
|
+
continue;
|
|
393
|
+
const out = { ...row };
|
|
394
|
+
for (const ref of spec.refs)
|
|
395
|
+
out[ref] = remapTask(row[ref]);
|
|
396
|
+
// A single text id (audit log) is re-minted so no live id can be hit.
|
|
397
|
+
if (textPk.length === 1 && textPk[0]?.name === 'id')
|
|
398
|
+
out['id'] = newLogId();
|
|
399
|
+
plan.add(spec.table, out);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
// --- Id-keyed entities written after divergence ---------------------------
|
|
403
|
+
const keptIds = new Map();
|
|
404
|
+
for (const spec of KEPT_ID_ENTITIES) {
|
|
405
|
+
if (!hasTable(source, spec.table) || !hasTable(target, spec.table))
|
|
406
|
+
continue;
|
|
407
|
+
const targetRows = new Map(allRows(target, spec.table).map((row) => [String(row['id']), row]));
|
|
408
|
+
const kept = new Set();
|
|
409
|
+
let before = 0;
|
|
410
|
+
let collisions = 0;
|
|
411
|
+
for (const row of allRows(source, spec.table)) {
|
|
412
|
+
const id = String(row['id']);
|
|
413
|
+
const twin = targetRows.get(id);
|
|
414
|
+
if (twin) {
|
|
415
|
+
if (twin[spec.time] !== row[spec.time] && afterT0(row[spec.time]))
|
|
416
|
+
collisions++;
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
if (!afterT0(row[spec.time])) {
|
|
420
|
+
before++;
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
const out = { ...row };
|
|
424
|
+
for (const ref of spec.taskRefs)
|
|
425
|
+
out[ref] = remapTask(row[ref]);
|
|
426
|
+
for (const col of spec.jsonTaskRefs ?? []) {
|
|
427
|
+
try {
|
|
428
|
+
const ids = JSON.parse(String(row[col] ?? 'null'));
|
|
429
|
+
if (Array.isArray(ids))
|
|
430
|
+
out[col] = JSON.stringify(ids.map((v) => (typeof v === 'string' ? remapTask(v) : v)));
|
|
431
|
+
}
|
|
432
|
+
catch {
|
|
433
|
+
/* not a JSON array: keep the stored text */
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
plan.add(spec.table, out);
|
|
437
|
+
kept.add(id);
|
|
438
|
+
mappings.push({ table: spec.table, originalId: id, newId: id, reason: 'source-only' });
|
|
439
|
+
}
|
|
440
|
+
keptIds.set(spec.table, kept);
|
|
441
|
+
if (before > 0)
|
|
442
|
+
skipped.push({
|
|
443
|
+
table: spec.table,
|
|
444
|
+
count: before,
|
|
445
|
+
reason: 'absent in target but written before divergence (deleted in the target)',
|
|
446
|
+
});
|
|
447
|
+
if (collisions > 0)
|
|
448
|
+
unresolved.push({
|
|
449
|
+
table: spec.table,
|
|
450
|
+
count: collisions,
|
|
451
|
+
reason: 'same id holds different content in both stores; not imported',
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
const sessions = keptIds.get('tasks_sessions') ?? new Set();
|
|
455
|
+
if (sessions.size > 0 && hasTable(source, 'tasks_session_handoff_entries'))
|
|
456
|
+
for (const row of allRows(source, 'tasks_session_handoff_entries'))
|
|
457
|
+
if (sessions.has(String(row['session_id'])))
|
|
458
|
+
plan.add('tasks_session_handoff_entries', row);
|
|
459
|
+
// Evidence bindings written after divergence. A binding to an imported AC
|
|
460
|
+
// follows it to the new AC id; its id embeds the AC id prefix, re-derived here.
|
|
461
|
+
if (hasTable(source, 'tasks_evidence_ac_bindings') &&
|
|
462
|
+
hasTable(target, 'tasks_evidence_ac_bindings')) {
|
|
463
|
+
const existing = new Set(allRows(target, 'tasks_evidence_ac_bindings').map((row) => String(row['id'])));
|
|
464
|
+
let collided = 0;
|
|
465
|
+
for (const row of allRows(source, 'tasks_evidence_ac_bindings')) {
|
|
466
|
+
if (!afterT0(row['created_at']) || existing.has(String(row['id'])))
|
|
467
|
+
continue;
|
|
468
|
+
const oldAc = String(row['ac_id'] ?? '');
|
|
469
|
+
const newAc = acMap.get(oldAc);
|
|
470
|
+
const id = newAc
|
|
471
|
+
? String(row['id']).replace(oldAc.slice(0, 8), newAc.slice(0, 8))
|
|
472
|
+
: String(row['id']);
|
|
473
|
+
if (existing.has(id)) {
|
|
474
|
+
collided++;
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
plan.add('tasks_evidence_ac_bindings', { ...row, id, ac_id: newAc ?? oldAc });
|
|
478
|
+
}
|
|
479
|
+
if (collided > 0)
|
|
480
|
+
unresolved.push({
|
|
481
|
+
table: 'tasks_evidence_ac_bindings',
|
|
482
|
+
count: collided,
|
|
483
|
+
reason: 'the re-derived binding id already exists in the target; not imported',
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
// Memory links: follow an imported memory or an imported task.
|
|
487
|
+
if (hasTable(source, 'brain_memory_links') && hasTable(target, 'brain_memory_links')) {
|
|
488
|
+
const importedMemory = new Set();
|
|
489
|
+
for (const table of [
|
|
490
|
+
'brain_observations',
|
|
491
|
+
'brain_decisions',
|
|
492
|
+
'brain_learnings',
|
|
493
|
+
'brain_patterns',
|
|
494
|
+
])
|
|
495
|
+
for (const id of keptIds.get(table) ?? [])
|
|
496
|
+
importedMemory.add(id);
|
|
497
|
+
const pk = columns(target, 'brain_memory_links')
|
|
498
|
+
.filter((col) => col.pk > 0)
|
|
499
|
+
.sort((a, b) => a.pk - b.pk)
|
|
500
|
+
.map((col) => col.name);
|
|
501
|
+
const existing = new Set(allRows(target, 'brain_memory_links').map((row) => pkKey(row, pk)));
|
|
502
|
+
for (const row of allRows(source, 'brain_memory_links')) {
|
|
503
|
+
const touches = importedMemory.has(String(row['memory_id'])) ||
|
|
504
|
+
(typeof row['task_id'] === 'string' && taskMap.has(row['task_id']));
|
|
505
|
+
if (!touches)
|
|
506
|
+
continue;
|
|
507
|
+
const out = { ...row, task_id: remapTask(row['task_id']) };
|
|
508
|
+
if (!existing.has(pkKey(out, pk)))
|
|
509
|
+
plan.add('brain_memory_links', out);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
// --- Coverage: every other tasks_/brain_ table referencing an imported task
|
|
513
|
+
for (const [table, reason] of Object.entries(EXCLUDED_TABLES)) {
|
|
514
|
+
if (!hasTable(source, table))
|
|
515
|
+
continue;
|
|
516
|
+
const cols = columns(source, table).map((col) => col.name);
|
|
517
|
+
const timeCol = ['created_at', 'timestamp', 'recorded_at'].find((c) => cols.includes(c));
|
|
518
|
+
const count = allRows(source, table).filter((row) => (timeCol !== undefined && afterT0(row[timeCol])) ||
|
|
519
|
+
cols.some((c) => typeof row[c] === 'string' && taskMap.has(row[c]))).length;
|
|
520
|
+
if (count > 0)
|
|
521
|
+
skipped.push({ table, count, reason: `${reason}; written after divergence` });
|
|
522
|
+
}
|
|
523
|
+
if (taskMap.size > 0) {
|
|
524
|
+
const tables = source
|
|
525
|
+
.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND sql NOT LIKE 'CREATE VIRTUAL TABLE%' AND (name LIKE 'tasks\\_%' ESCAPE '\\' OR name LIKE 'brain\\_%' ESCAPE '\\')`)
|
|
526
|
+
.all().map((row) => row.name);
|
|
527
|
+
for (const table of tables) {
|
|
528
|
+
if (HANDLED_TASK_REF_TABLES.has(table))
|
|
529
|
+
continue;
|
|
530
|
+
const refCols = columns(source, table)
|
|
531
|
+
.map((col) => col.name)
|
|
532
|
+
.filter((name) => /(^|_)task_id$|^target_task_id$|^related_to$|^depends_on$/.test(name));
|
|
533
|
+
if (refCols.length === 0)
|
|
534
|
+
continue;
|
|
535
|
+
const count = allRows(source, table).filter((row) => refCols.some((c) => typeof row[c] === 'string' && taskMap.has(row[c]))).length;
|
|
536
|
+
if (count > 0)
|
|
537
|
+
unresolved.push({
|
|
538
|
+
table,
|
|
539
|
+
count,
|
|
540
|
+
reason: 'references an imported task; this table is not supported by the import',
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
// --- Coverage: post-divergence rows in any other table --------------------
|
|
545
|
+
// Every ordinary table the import does not handle is scanned for rows written
|
|
546
|
+
// after T0 that have no identical row in the target. They are reported, so a
|
|
547
|
+
// table this import does not know about can never be lost silently.
|
|
548
|
+
const virtualTables = source
|
|
549
|
+
.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%'`)
|
|
550
|
+
.all().map((row) => row.name);
|
|
551
|
+
const ordinaryTables = source
|
|
552
|
+
.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND sql NOT LIKE 'CREATE VIRTUAL TABLE%' AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\'`)
|
|
553
|
+
.all()
|
|
554
|
+
.map((row) => row.name)
|
|
555
|
+
.filter((name) => !virtualTables.some((v) => name.startsWith(`${v}_`)));
|
|
556
|
+
const reported = new Set(unresolved.map((entry) => entry.table));
|
|
557
|
+
for (const table of ordinaryTables) {
|
|
558
|
+
if (HANDLED_TASK_REF_TABLES.has(table) || reported.has(table) || !hasTable(target, table))
|
|
559
|
+
continue;
|
|
560
|
+
const cols = columns(source, table).map((col) => col.name);
|
|
561
|
+
const timeCol = COVERAGE_TIME_COLUMNS.find((c) => cols.includes(c));
|
|
562
|
+
if (!timeCol)
|
|
563
|
+
continue;
|
|
564
|
+
const targetCols = new Set(columns(target, table).map((col) => col.name));
|
|
565
|
+
const shared = cols.filter((c) => targetCols.has(c));
|
|
566
|
+
const probe = target.prepare(`SELECT 1 FROM ${q(table)} WHERE ${shared.map((c) => `${q(c)} IS ?`).join(' AND ')} LIMIT 1`);
|
|
567
|
+
let count = 0;
|
|
568
|
+
for (const row of allRows(source, table)) {
|
|
569
|
+
if (!afterT0(row[timeCol]))
|
|
570
|
+
continue;
|
|
571
|
+
if (probe.get(...shared.map((c) => row[c] ?? null)) === undefined)
|
|
572
|
+
count++;
|
|
573
|
+
}
|
|
574
|
+
if (count > 0)
|
|
575
|
+
unresolved.push({
|
|
576
|
+
table,
|
|
577
|
+
count,
|
|
578
|
+
reason: 'written after divergence, absent in the target, and not handled by this import',
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
// --- Provenance -------------------------------------------------------------
|
|
582
|
+
const needsAnchor = mappings.some((mapping) => mapping.table !== 'tasks_tasks' && mapping.table !== 'audit_log');
|
|
583
|
+
const anchor = options.provenanceTaskId;
|
|
584
|
+
if (!dryRun && needsAnchor) {
|
|
585
|
+
if (!anchor)
|
|
586
|
+
throw new Error('provenanceTaskId is required to apply an import with non-task rows: tasks_audit_log.task_id is NOT NULL.');
|
|
587
|
+
if (!targetTasks.has(anchor))
|
|
588
|
+
throw new Error(`provenanceTaskId ${anchor} does not exist in the target store.`);
|
|
589
|
+
}
|
|
590
|
+
for (const mapping of mappings) {
|
|
591
|
+
// An imported audit row is its own provenance; auditing it again adds nothing.
|
|
592
|
+
if (mapping.table === 'audit_log')
|
|
593
|
+
continue;
|
|
594
|
+
const isTask = mapping.table === 'tasks_tasks';
|
|
595
|
+
plan.add('tasks_audit_log', {
|
|
596
|
+
id: newLogId(),
|
|
597
|
+
timestamp: now,
|
|
598
|
+
action: 'split_brain_import',
|
|
599
|
+
task_id: isTask ? mapping.newId : (anchor ?? null),
|
|
600
|
+
actor: 'split-brain-import',
|
|
601
|
+
details_json: JSON.stringify({
|
|
602
|
+
task: 'T12329',
|
|
603
|
+
table: mapping.table,
|
|
604
|
+
originalId: mapping.originalId,
|
|
605
|
+
newId: mapping.newId,
|
|
606
|
+
reason: mapping.reason,
|
|
607
|
+
sourceStore: options.sourcePath,
|
|
608
|
+
sourceLabel: label,
|
|
609
|
+
divergedAfter: new Date(t0).toISOString(),
|
|
610
|
+
}),
|
|
611
|
+
source: 'split-brain-import',
|
|
612
|
+
success: 1,
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
if (!dryRun) {
|
|
616
|
+
target.exec('BEGIN IMMEDIATE');
|
|
617
|
+
try {
|
|
618
|
+
plan.apply();
|
|
619
|
+
target.exec('COMMIT');
|
|
620
|
+
}
|
|
621
|
+
catch (error) {
|
|
622
|
+
target.exec('ROLLBACK');
|
|
623
|
+
throw error;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
return {
|
|
627
|
+
dryRun,
|
|
628
|
+
sourcePath: options.sourcePath,
|
|
629
|
+
targetPath: options.targetPath,
|
|
630
|
+
divergedAfter: new Date(t0).toISOString(),
|
|
631
|
+
sharedTasks: shared,
|
|
632
|
+
mappings,
|
|
633
|
+
rowsByTable: plan.byTable,
|
|
634
|
+
skipped,
|
|
635
|
+
unresolved,
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* Prove that an import only ADDED rows: every row of every ordinary table in
|
|
640
|
+
* the pre-import snapshot still exists, byte-identical, in the post-import
|
|
641
|
+
* store.
|
|
642
|
+
*
|
|
643
|
+
* FTS virtual tables and their shadow tables are excluded. They are derived,
|
|
644
|
+
* and an FTS5 index legitimately rewrites its segments on insert.
|
|
645
|
+
*
|
|
646
|
+
* @param beforePath - Snapshot of the target taken before the import.
|
|
647
|
+
* @param afterPath - The target after the import.
|
|
648
|
+
* @returns One entry per table; every `missingOrChanged` must be 0.
|
|
649
|
+
*/
|
|
650
|
+
export function verifyPreexistingRows(beforePath, afterPath) {
|
|
651
|
+
// db-open-allowed: read-only comparison of two explicit store snapshots
|
|
652
|
+
const db = new DatabaseSync(afterPath, { readOnly: true });
|
|
653
|
+
try {
|
|
654
|
+
db.prepare(`ATTACH DATABASE ? AS before_import`).run(beforePath);
|
|
655
|
+
const virtual = db
|
|
656
|
+
.prepare(`SELECT name FROM before_import.sqlite_master WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%'`)
|
|
657
|
+
.all().map((row) => row.name);
|
|
658
|
+
const tables = db
|
|
659
|
+
.prepare(`SELECT name FROM before_import.sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\' AND sql NOT LIKE 'CREATE VIRTUAL TABLE%' ORDER BY name`)
|
|
660
|
+
.all()
|
|
661
|
+
.map((row) => row.name)
|
|
662
|
+
.filter((name) => !virtual.some((v) => name.startsWith(`${v}_`)));
|
|
663
|
+
return tables.map((table) => {
|
|
664
|
+
const count = (schema) => Number(db.prepare(`SELECT COUNT(*) AS n FROM ${schema}.${q(table)}`).get().n);
|
|
665
|
+
const missing = Number(db
|
|
666
|
+
.prepare(`SELECT COUNT(*) AS n FROM (SELECT * FROM before_import.${q(table)} EXCEPT SELECT * FROM main.${q(table)})`)
|
|
667
|
+
.get().n);
|
|
668
|
+
return {
|
|
669
|
+
table,
|
|
670
|
+
before: count('before_import'),
|
|
671
|
+
after: count('main'),
|
|
672
|
+
missingOrChanged: missing,
|
|
673
|
+
};
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
finally {
|
|
677
|
+
db.close();
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
//# sourceMappingURL=split-brain-import.js.map
|