@pasko70/pibo 1.3.5 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/apps/vscode-artifacts/latest.vsix +0 -0
- package/dist/apps/vscode-artifacts/pibo-vscode-1.3.0.vsix +0 -0
- package/dist/apps/vscode-artifacts/pibo-vscode-ext-1.3.3.vsix +0 -0
- package/dist/apps/vscode-artifacts/pibo-vscode-ext-1.3.4.vsix +0 -0
- package/dist/apps/vscode-artifacts/pibo-vscode-ext-1.3.5.vsix +0 -0
- package/dist/core/shared-app.js +17 -0
- package/dist/data/final-app-space-cutover-migration.js +728 -0
- package/dist/data/shared-app-migration.js +757 -0
- package/dist/session-ui/ownerViewModel.js +27 -0
- package/dist/shared-app.js +4 -0
- package/package.json +4 -1
|
@@ -0,0 +1,757 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, statSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { DatabaseSync } from "node:sqlite";
|
|
5
|
+
import { piboHomePath } from "../core/pibo-home.js";
|
|
6
|
+
import { getSharedAppLegacyOwnerScope } from "../shared-app.js";
|
|
7
|
+
const SHARED_APP_VALUE = getSharedAppLegacyOwnerScope();
|
|
8
|
+
const AUXILIARY_MIGRATION_STORES = [
|
|
9
|
+
{ store: "pibo", file: "pibo.sqlite", ownerTables: ["workflow_lifecycle_events", "workflow_prompt_assets", "workflow_prompt_asset_revisions", "workflow_ui_drafts"] },
|
|
10
|
+
{ store: "chat-agents", file: "chat-agents.sqlite", ownerTables: ["chat_agents"], customAgentProfileNames: true },
|
|
11
|
+
{ store: "ralph", file: "pibo-ralph.sqlite", ownerTables: ["pibo_ralph_jobs", "pibo_ralph_runs", "pibo_ralph_run_facts"], targetTables: ["pibo_ralph_jobs"] },
|
|
12
|
+
{ store: "cron", file: "pibo-cron.sqlite", ownerTables: ["pibo_cron_jobs", "pibo_cron_runs"], targetTables: ["pibo_cron_jobs"] },
|
|
13
|
+
{ store: "web-annotations", file: "web-annotations.sqlite", ownerTables: ["web_annotation_bindings", "web_annotations"] },
|
|
14
|
+
{ store: "web-projects", file: "web-projects.sqlite", ownerTables: ["projects"] },
|
|
15
|
+
];
|
|
16
|
+
const MIGRATION_STORES = [
|
|
17
|
+
{
|
|
18
|
+
name: "pibo",
|
|
19
|
+
file: "pibo.sqlite",
|
|
20
|
+
description: "primary Pibo sessions, rooms, navigation, read-state, and workflow persistence",
|
|
21
|
+
tables: [
|
|
22
|
+
ownerTable("sessions"),
|
|
23
|
+
ownerTable("rooms"),
|
|
24
|
+
ownerTable("session_navigation"),
|
|
25
|
+
principalTable("room_members"),
|
|
26
|
+
principalTable("principal_session_stats"),
|
|
27
|
+
principalTable("principal_room_stats"),
|
|
28
|
+
ownerTable("workflow_lifecycle_events"),
|
|
29
|
+
ownerTable("workflow_prompt_assets"),
|
|
30
|
+
ownerTable("workflow_prompt_asset_revisions"),
|
|
31
|
+
ownerTable("workflow_ui_drafts"),
|
|
32
|
+
],
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: "chat-agents",
|
|
36
|
+
file: "chat-agents.sqlite",
|
|
37
|
+
description: "custom Agent Designer profiles",
|
|
38
|
+
tables: [ownerTable("chat_agents")],
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: "ralph",
|
|
42
|
+
file: "pibo-ralph.sqlite",
|
|
43
|
+
description: "Ralph jobs, runs, and run facts",
|
|
44
|
+
tables: [ownerTable("pibo_ralph_jobs"), ownerTable("pibo_ralph_runs"), ownerTable("pibo_ralph_run_facts")],
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: "cron",
|
|
48
|
+
file: "pibo-cron.sqlite",
|
|
49
|
+
description: "Cron schedules and run history",
|
|
50
|
+
tables: [ownerTable("pibo_cron_jobs"), ownerTable("pibo_cron_runs")],
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: "web-annotations",
|
|
54
|
+
file: "web-annotations.sqlite",
|
|
55
|
+
description: "Web Annotation bindings and annotations",
|
|
56
|
+
tables: [ownerTable("web_annotation_bindings"), ownerTable("web_annotations")],
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: "web-projects",
|
|
60
|
+
file: "web-projects.sqlite",
|
|
61
|
+
description: "legacy standalone Project store if present",
|
|
62
|
+
tables: [ownerTable("projects")],
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: "reliability",
|
|
66
|
+
file: "pibo-events.sqlite",
|
|
67
|
+
description: "reliable event core and yielded-run lifecycle state",
|
|
68
|
+
tables: [
|
|
69
|
+
{
|
|
70
|
+
name: "pibo_runs",
|
|
71
|
+
columns: [{ column: "owner_pibo_session_id", kind: "technical_session_owner", plannedMutation: false }],
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
];
|
|
76
|
+
function ownerTable(name) {
|
|
77
|
+
return { name, columns: [{ column: "owner_scope", kind: "owner_scope", targetValue: SHARED_APP_VALUE, plannedMutation: true }] };
|
|
78
|
+
}
|
|
79
|
+
function principalTable(name) {
|
|
80
|
+
return { name, columns: [{ column: "principal_id", kind: "principal_id", targetValue: SHARED_APP_VALUE, plannedMutation: true }] };
|
|
81
|
+
}
|
|
82
|
+
function validateApplyBackup(root, backupPath) {
|
|
83
|
+
if (!backupPath)
|
|
84
|
+
throw new Error("pibo data shared-app apply requires --backup <backup-path> before any mutation can run");
|
|
85
|
+
if (!existsSync(backupPath))
|
|
86
|
+
throw new Error(`pibo data shared-app apply backup path does not exist: ${backupPath}`);
|
|
87
|
+
const backupStat = statSync(backupPath);
|
|
88
|
+
if (!backupStat.isDirectory())
|
|
89
|
+
throw new Error(`pibo data shared-app apply backup path must be a directory: ${backupPath}`);
|
|
90
|
+
const checkedFiles = new Set();
|
|
91
|
+
for (const store of MIGRATION_STORES) {
|
|
92
|
+
if (checkedFiles.has(store.file))
|
|
93
|
+
continue;
|
|
94
|
+
checkedFiles.add(store.file);
|
|
95
|
+
const sourcePath = resolve(root, store.file);
|
|
96
|
+
if (!existsSync(sourcePath))
|
|
97
|
+
continue;
|
|
98
|
+
const backupFilePath = resolve(backupPath, store.file);
|
|
99
|
+
if (!existsSync(backupFilePath))
|
|
100
|
+
throw new Error(`pibo data shared-app apply backup is missing required SQLite copy: ${backupFilePath}`);
|
|
101
|
+
const fileStat = statSync(backupFilePath);
|
|
102
|
+
if (!fileStat.isFile())
|
|
103
|
+
throw new Error(`pibo data shared-app apply backup entry is not a file: ${backupFilePath}`);
|
|
104
|
+
assertSqliteQuickCheck(backupFilePath);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function assertSqliteQuickCheck(path) {
|
|
108
|
+
const db = new DatabaseSync(path, { readOnly: true });
|
|
109
|
+
try {
|
|
110
|
+
const row = db.prepare("PRAGMA quick_check").get();
|
|
111
|
+
if (row?.quick_check !== "ok")
|
|
112
|
+
throw new Error(`quick_check returned ${row?.quick_check ?? "no result"}`);
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
116
|
+
throw new Error(`pibo data shared-app apply backup SQLite quick_check failed for ${path}: ${message}`);
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
db.close();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
function validateNoUnresolvedApplyConflicts(root) {
|
|
123
|
+
const unresolved = MIGRATION_STORES.flatMap((store) => inspectStore(root, store).tables.flatMap((table) => table.conflicts
|
|
124
|
+
.filter((conflict) => !isHandledApplyConflict(store.name, table.table, conflict))
|
|
125
|
+
.map((conflict) => `${store.name}.${table.table}.${conflict.indexName}`)));
|
|
126
|
+
if (unresolved.length > 0) {
|
|
127
|
+
throw new Error(`pibo data shared-app apply found unresolved unique-index conflicts after owner/principal normalization: ${unresolved.join(", ")}. Run dry-run, resolve these conflicts, then retry.`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
function isHandledApplyConflict(store, table, conflict) {
|
|
131
|
+
if (store === "pibo" && ["room_members", "principal_session_stats", "principal_room_stats"].includes(table))
|
|
132
|
+
return true;
|
|
133
|
+
if (store === "chat-agents" && table === "chat_agents" && conflict.columns.includes("profile_name"))
|
|
134
|
+
return true;
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
export function inspectSharedAppMigration(options) {
|
|
138
|
+
const root = options.root ? resolve(options.root) : piboHomePath("").replace(/[\\/]$/, "");
|
|
139
|
+
const warnings = [];
|
|
140
|
+
if (options.mode === "apply") {
|
|
141
|
+
validateApplyBackup(root, options.backupPath);
|
|
142
|
+
validateNoUnresolvedApplyConflicts(root);
|
|
143
|
+
}
|
|
144
|
+
const apply = options.mode === "apply";
|
|
145
|
+
const piboMigration = planOrApplyPiboSqliteMigration(root, apply);
|
|
146
|
+
const auxiliaryMigration = planOrApplyAuxiliaryMigrations(root, apply);
|
|
147
|
+
warnings.push(...piboMigration.warnings, ...auxiliaryMigration.warnings);
|
|
148
|
+
const actions = [...piboMigration.actions, ...auxiliaryMigration.actions];
|
|
149
|
+
const postChecks = [...piboMigration.postChecks, ...auxiliaryMigration.postChecks];
|
|
150
|
+
const stores = MIGRATION_STORES.map((store) => inspectStore(root, store));
|
|
151
|
+
const appliedUpdates = actions.reduce((sum, action) => sum + action.applied, 0);
|
|
152
|
+
const summary = stores.reduce((acc, store) => {
|
|
153
|
+
acc.stores++;
|
|
154
|
+
if (store.exists)
|
|
155
|
+
acc.existingStores++;
|
|
156
|
+
acc.tables += store.tables.length;
|
|
157
|
+
acc.existingTables += store.tables.filter((table) => table.exists).length;
|
|
158
|
+
acc.rows += store.totalRows;
|
|
159
|
+
acc.plannedUpdates += store.totalPlannedUpdates;
|
|
160
|
+
acc.conflicts += store.totalConflicts;
|
|
161
|
+
return acc;
|
|
162
|
+
}, { stores: 0, existingStores: 0, tables: 0, existingTables: 0, rows: 0, plannedUpdates: 0, appliedUpdates, conflicts: 0 });
|
|
163
|
+
return {
|
|
164
|
+
kind: "shared-app-migration",
|
|
165
|
+
mode: options.mode,
|
|
166
|
+
root,
|
|
167
|
+
generatedAt: new Date().toISOString(),
|
|
168
|
+
dryRun: options.mode !== "apply",
|
|
169
|
+
willWrite: options.mode === "apply" && appliedUpdates > 0,
|
|
170
|
+
stores,
|
|
171
|
+
actions,
|
|
172
|
+
postChecks,
|
|
173
|
+
summary,
|
|
174
|
+
backup: {
|
|
175
|
+
requiredForApply: true,
|
|
176
|
+
providedPath: options.backupPath,
|
|
177
|
+
providedPathExists: options.backupPath ? existsSync(options.backupPath) : undefined,
|
|
178
|
+
rollbackInstructions: "Before mutation, create a fresh backup of the Pibo home or affected SQLite files. To roll back, stop Pibo, restore the affected SQLite files from that backup, then restart through the Pibo gateway CLI.",
|
|
179
|
+
},
|
|
180
|
+
warnings,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
function planOrApplyAuxiliaryMigrations(root, apply) {
|
|
184
|
+
const actions = [];
|
|
185
|
+
const postChecks = [];
|
|
186
|
+
const warnings = [];
|
|
187
|
+
for (const spec of AUXILIARY_MIGRATION_STORES) {
|
|
188
|
+
const path = resolve(root, spec.file);
|
|
189
|
+
if (!existsSync(path))
|
|
190
|
+
continue;
|
|
191
|
+
const db = new DatabaseSync(path, apply ? {} : { readOnly: true });
|
|
192
|
+
try {
|
|
193
|
+
if (!apply)
|
|
194
|
+
db.exec("PRAGMA query_only = ON");
|
|
195
|
+
const tables = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((row) => row.name));
|
|
196
|
+
const columns = new Map();
|
|
197
|
+
const tableColumns = (table) => {
|
|
198
|
+
let value = columns.get(table);
|
|
199
|
+
if (!value) {
|
|
200
|
+
value = tables.has(table) ? new Set(db.prepare(`PRAGMA table_info(${quoteIdent(table)})`).all().map((row) => row.name)) : new Set();
|
|
201
|
+
columns.set(table, value);
|
|
202
|
+
}
|
|
203
|
+
return value;
|
|
204
|
+
};
|
|
205
|
+
const collectActions = () => {
|
|
206
|
+
if (spec.customAgentProfileNames)
|
|
207
|
+
actions.push(planCustomAgentProfileNameNormalization(db, spec.store, spec.file, tables, tableColumns, apply));
|
|
208
|
+
for (const table of spec.ownerTables)
|
|
209
|
+
actions.push(planGenericOwnerScopeNormalization(db, spec.store, spec.file, table, tables, tableColumns, apply));
|
|
210
|
+
for (const table of spec.targetTables ?? [])
|
|
211
|
+
actions.push(planPersonalTargetNormalization(db, spec.store, spec.file, table, tables, tableColumns, apply));
|
|
212
|
+
};
|
|
213
|
+
if (apply) {
|
|
214
|
+
db.exec("BEGIN IMMEDIATE");
|
|
215
|
+
try {
|
|
216
|
+
collectActions();
|
|
217
|
+
db.exec("COMMIT");
|
|
218
|
+
}
|
|
219
|
+
catch (error) {
|
|
220
|
+
db.exec("ROLLBACK");
|
|
221
|
+
throw error;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
collectActions();
|
|
226
|
+
}
|
|
227
|
+
postChecks.push(buildAuxiliaryPostCheck(db, spec, tables, tableColumns));
|
|
228
|
+
if ((spec.store === "ralph" || spec.store === "cron") && tables.has(spec.store === "ralph" ? "pibo_ralph_jobs" : "pibo_cron_jobs")) {
|
|
229
|
+
warnings.push(`${spec.store} migration is metadata-only: owner_scope and personal target principal values are normalized without changing job/run ids, status, schedules, prompts, resources, or working directories.`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
finally {
|
|
233
|
+
db.close();
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return { actions, postChecks, warnings: uniqueStrings(warnings) };
|
|
237
|
+
}
|
|
238
|
+
function planGenericOwnerScopeNormalization(db, store, file, table, tables, tableColumns, apply) {
|
|
239
|
+
if (!tables.has(table) || !tableColumns(table).has("owner_scope"))
|
|
240
|
+
return storeAction(store, file, table, "normalize-owner-scope", 0, 0);
|
|
241
|
+
const planned = countRows(db, `SELECT COUNT(*) AS count FROM ${quoteIdent(table)} WHERE owner_scope IS NULL OR owner_scope != ?`, [SHARED_APP_VALUE]);
|
|
242
|
+
let applied = 0;
|
|
243
|
+
if (apply && planned > 0) {
|
|
244
|
+
applied = Number(db.prepare(`UPDATE ${quoteIdent(table)} SET owner_scope = ? WHERE owner_scope IS NULL OR owner_scope != ?`).run(SHARED_APP_VALUE, SHARED_APP_VALUE).changes ?? 0);
|
|
245
|
+
}
|
|
246
|
+
return storeAction(store, file, table, "normalize-owner-scope", planned, applied);
|
|
247
|
+
}
|
|
248
|
+
function planPersonalTargetNormalization(db, store, file, table, tables, tableColumns, apply) {
|
|
249
|
+
const columns = tableColumns(table);
|
|
250
|
+
if (!tables.has(table) || !columns.has("id") || !columns.has("target_json"))
|
|
251
|
+
return storeAction(store, file, table, "normalize-personal-target", 0, 0);
|
|
252
|
+
const plan = buildTargetJsonMigrationPlan(db, table, columns.has("state_json"));
|
|
253
|
+
let applied = 0;
|
|
254
|
+
if (apply && plan.updates.length > 0) {
|
|
255
|
+
const update = db.prepare(`UPDATE ${quoteIdent(table)} SET target_json = ? WHERE id = ?`);
|
|
256
|
+
for (const item of plan.updates)
|
|
257
|
+
applied += Number(update.run(item.targetJson, item.id).changes ?? 0);
|
|
258
|
+
}
|
|
259
|
+
return storeAction(store, file, table, "normalize-personal-target", plan.planned, applied, { activeJobs: plan.activeJobs });
|
|
260
|
+
}
|
|
261
|
+
function buildTargetJsonMigrationPlan(db, table, hasStateJson) {
|
|
262
|
+
const rows = db.prepare(`SELECT id, target_json, ${hasStateJson ? "state_json" : "NULL AS state_json"} FROM ${quoteIdent(table)} ORDER BY id ASC`).all();
|
|
263
|
+
const updates = [];
|
|
264
|
+
let activeJobs = 0;
|
|
265
|
+
for (const row of rows) {
|
|
266
|
+
const state = parseJsonObject(row.state_json);
|
|
267
|
+
if (typeof state.runningAt === "string" && state.runningAt.length > 0)
|
|
268
|
+
activeJobs++;
|
|
269
|
+
const target = parseJsonObject(row.target_json);
|
|
270
|
+
if (target.kind !== "personal" || target.principalId === SHARED_APP_VALUE)
|
|
271
|
+
continue;
|
|
272
|
+
updates.push({ id: row.id, targetJson: JSON.stringify({ ...target, principalId: SHARED_APP_VALUE }) });
|
|
273
|
+
}
|
|
274
|
+
return { planned: updates.length, activeJobs, updates };
|
|
275
|
+
}
|
|
276
|
+
function planCustomAgentProfileNameNormalization(db, store, file, tables, tableColumns, apply) {
|
|
277
|
+
const table = "chat_agents";
|
|
278
|
+
const columns = tableColumns(table);
|
|
279
|
+
if (!tables.has(table) || !columns.has("id") || !columns.has("profile_name"))
|
|
280
|
+
return storeAction(store, file, table, "rename-duplicate-profile-names", 0, 0);
|
|
281
|
+
const rows = db.prepare(`SELECT id, profile_name, ${columns.has("owner_scope") ? "owner_scope" : "NULL AS owner_scope"}, ${columns.has("display_name") ? "display_name" : "NULL AS display_name"}, ${columns.has("created_at") ? "created_at" : "NULL AS created_at"}, ${columns.has("updated_at") ? "updated_at" : "NULL AS updated_at"} FROM ${quoteIdent(table)} ORDER BY profile_name ASC, id ASC`).all();
|
|
282
|
+
const byName = groupRows(rows, "profile_name");
|
|
283
|
+
const used = new Set(rows.map((row) => String(row.profile_name ?? "")));
|
|
284
|
+
const renames = [];
|
|
285
|
+
for (const group of byName.values()) {
|
|
286
|
+
if (group.length <= 1)
|
|
287
|
+
continue;
|
|
288
|
+
const canonical = chooseCanonicalCustomAgent(group);
|
|
289
|
+
for (const row of group) {
|
|
290
|
+
if (String(row.id) === String(canonical.id))
|
|
291
|
+
continue;
|
|
292
|
+
used.delete(String(row.profile_name ?? ""));
|
|
293
|
+
const from = String(row.profile_name ?? "agent");
|
|
294
|
+
const to = uniqueProfileName(`${from} legacy ${shortLegacyHash(`${row.owner_scope ?? ""}:${row.id ?? ""}`)}`, used);
|
|
295
|
+
used.add(to);
|
|
296
|
+
renames.push({ id: String(row.id), from, to });
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
let applied = 0;
|
|
300
|
+
if (apply && renames.length > 0) {
|
|
301
|
+
const sql = columns.has("display_name")
|
|
302
|
+
? `UPDATE ${quoteIdent(table)} SET profile_name = ?, display_name = ? WHERE id = ?`
|
|
303
|
+
: `UPDATE ${quoteIdent(table)} SET profile_name = ? WHERE id = ?`;
|
|
304
|
+
const update = db.prepare(sql);
|
|
305
|
+
for (const rename of renames) {
|
|
306
|
+
applied += columns.has("display_name")
|
|
307
|
+
? Number(update.run(rename.to, rename.to, rename.id).changes ?? 0)
|
|
308
|
+
: Number(update.run(rename.to, rename.id).changes ?? 0);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return storeAction(store, file, table, "rename-duplicate-profile-names", renames.length, applied, { renames });
|
|
312
|
+
}
|
|
313
|
+
function chooseCanonicalCustomAgent(group) {
|
|
314
|
+
return [...group].sort((a, b) => {
|
|
315
|
+
const ownerCompare = (a.owner_scope === SHARED_APP_VALUE ? 0 : 1) - (b.owner_scope === SHARED_APP_VALUE ? 0 : 1);
|
|
316
|
+
if (ownerCompare !== 0)
|
|
317
|
+
return ownerCompare;
|
|
318
|
+
const updatedCompare = String(b.updated_at ?? "").localeCompare(String(a.updated_at ?? ""));
|
|
319
|
+
if (updatedCompare !== 0)
|
|
320
|
+
return updatedCompare;
|
|
321
|
+
const createdCompare = String(a.created_at ?? "").localeCompare(String(b.created_at ?? ""));
|
|
322
|
+
if (createdCompare !== 0)
|
|
323
|
+
return createdCompare;
|
|
324
|
+
return String(a.id ?? "").localeCompare(String(b.id ?? ""));
|
|
325
|
+
})[0];
|
|
326
|
+
}
|
|
327
|
+
function buildAuxiliaryPostCheck(db, spec, tables, tableColumns) {
|
|
328
|
+
const checks = {};
|
|
329
|
+
for (const table of spec.ownerTables) {
|
|
330
|
+
checks[`${table}.nonSharedOwnerRows`] = tables.has(table) && tableColumns(table).has("owner_scope") ? countRows(db, `SELECT COUNT(*) AS count FROM ${quoteIdent(table)} WHERE owner_scope IS NULL OR owner_scope != ?`, [SHARED_APP_VALUE]) : 0;
|
|
331
|
+
}
|
|
332
|
+
for (const table of spec.targetTables ?? []) {
|
|
333
|
+
checks[`${table}.nonSharedPersonalTargetRows`] = tables.has(table) && tableColumns(table).has("target_json") ? buildTargetJsonMigrationPlan(db, table, tableColumns(table).has("state_json")).planned : 0;
|
|
334
|
+
}
|
|
335
|
+
if (spec.customAgentProfileNames && tables.has("chat_agents") && tableColumns("chat_agents").has("profile_name")) {
|
|
336
|
+
const rows = db.prepare("SELECT profile_name FROM chat_agents").all();
|
|
337
|
+
checks["chat_agents.duplicateProfileNameGroups"] = [...groupRows(rows, "profile_name").values()].filter((group) => group.length > 1).length;
|
|
338
|
+
}
|
|
339
|
+
return { store: spec.store, file: spec.file, checks };
|
|
340
|
+
}
|
|
341
|
+
function uniqueProfileName(candidate, used) {
|
|
342
|
+
let next = candidate;
|
|
343
|
+
let suffix = 2;
|
|
344
|
+
while (used.has(next))
|
|
345
|
+
next = `${candidate} ${suffix++}`;
|
|
346
|
+
return next;
|
|
347
|
+
}
|
|
348
|
+
function shortLegacyHash(value) {
|
|
349
|
+
return createHash("sha1").update(value).digest("hex").slice(0, 8);
|
|
350
|
+
}
|
|
351
|
+
function uniqueStrings(values) {
|
|
352
|
+
return [...new Set(values)];
|
|
353
|
+
}
|
|
354
|
+
function planOrApplyPiboSqliteMigration(root, apply) {
|
|
355
|
+
const file = "pibo.sqlite";
|
|
356
|
+
const path = resolve(root, file);
|
|
357
|
+
if (!existsSync(path))
|
|
358
|
+
return { actions: [], postChecks: [], warnings: [] };
|
|
359
|
+
const db = new DatabaseSync(path, apply ? {} : { readOnly: true });
|
|
360
|
+
const actions = [];
|
|
361
|
+
const warnings = [];
|
|
362
|
+
try {
|
|
363
|
+
if (!apply)
|
|
364
|
+
db.exec("PRAGMA query_only = ON");
|
|
365
|
+
const tables = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((row) => row.name));
|
|
366
|
+
const columns = new Map();
|
|
367
|
+
const tableColumns = (table) => {
|
|
368
|
+
let value = columns.get(table);
|
|
369
|
+
if (!value) {
|
|
370
|
+
value = tables.has(table) ? new Set(db.prepare(`PRAGMA table_info(${quoteIdent(table)})`).all().map((row) => row.name)) : new Set();
|
|
371
|
+
columns.set(table, value);
|
|
372
|
+
}
|
|
373
|
+
return value;
|
|
374
|
+
};
|
|
375
|
+
const collectActions = () => {
|
|
376
|
+
actions.push(...planDefaultRoomNormalization(db, file, tables, tableColumns, apply));
|
|
377
|
+
for (const table of ["sessions", "rooms", "session_navigation"])
|
|
378
|
+
actions.push(planOwnerScopeNormalization(db, file, table, tables, tableColumns, apply));
|
|
379
|
+
actions.push(planRoomMemberNormalization(db, file, tables, tableColumns, apply));
|
|
380
|
+
actions.push(planPrincipalStatsNormalization(db, file, "principal_session_stats", "session_id", tables, tableColumns, apply));
|
|
381
|
+
actions.push(planPrincipalStatsNormalization(db, file, "principal_room_stats", "room_id", tables, tableColumns, apply));
|
|
382
|
+
};
|
|
383
|
+
if (apply) {
|
|
384
|
+
db.exec("BEGIN IMMEDIATE");
|
|
385
|
+
try {
|
|
386
|
+
collectActions();
|
|
387
|
+
db.exec("COMMIT");
|
|
388
|
+
}
|
|
389
|
+
catch (error) {
|
|
390
|
+
db.exec("ROLLBACK");
|
|
391
|
+
throw error;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
else {
|
|
395
|
+
collectActions();
|
|
396
|
+
}
|
|
397
|
+
return { actions, postChecks: [buildPiboSqlitePostCheck(db, file, tables, tableColumns)], warnings };
|
|
398
|
+
}
|
|
399
|
+
finally {
|
|
400
|
+
db.close();
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
function planOwnerScopeNormalization(db, file, table, tables, tableColumns, apply) {
|
|
404
|
+
if (!tables.has(table) || !tableColumns(table).has("owner_scope"))
|
|
405
|
+
return action(file, table, "normalize-owner-scope", 0, 0);
|
|
406
|
+
const planned = countRows(db, `SELECT COUNT(*) AS count FROM ${quoteIdent(table)} WHERE owner_scope IS NULL OR owner_scope != ?`, [SHARED_APP_VALUE]);
|
|
407
|
+
let applied = 0;
|
|
408
|
+
if (apply && planned > 0) {
|
|
409
|
+
applied = Number(db.prepare(`UPDATE ${quoteIdent(table)} SET owner_scope = ? WHERE owner_scope IS NULL OR owner_scope != ?`).run(SHARED_APP_VALUE, SHARED_APP_VALUE).changes ?? 0);
|
|
410
|
+
}
|
|
411
|
+
return action(file, table, "normalize-owner-scope", planned, applied);
|
|
412
|
+
}
|
|
413
|
+
function planDefaultRoomNormalization(db, file, tables, tableColumns, apply) {
|
|
414
|
+
if (!tables.has("rooms") || !tableColumns("rooms").has("metadata_json"))
|
|
415
|
+
return [];
|
|
416
|
+
const roomColumns = tableColumns("rooms");
|
|
417
|
+
const rooms = db.prepare(`SELECT id, owner_scope, name, metadata_json, ${roomColumns.has("updated_at") ? "updated_at" : "NULL AS updated_at"} FROM rooms ORDER BY id ASC`).all();
|
|
418
|
+
const defaultRooms = rooms.filter((room) => parseJsonObject(room.metadata_json).default === true);
|
|
419
|
+
if (defaultRooms.length <= 1)
|
|
420
|
+
return [action(file, "rooms", "retire-duplicate-default-rooms", 0, 0, { defaultRooms: defaultRooms.length })];
|
|
421
|
+
const canonical = [...defaultRooms].sort((a, b) => {
|
|
422
|
+
const ownerCompare = (a.owner_scope === SHARED_APP_VALUE ? 0 : 1) - (b.owner_scope === SHARED_APP_VALUE ? 0 : 1);
|
|
423
|
+
if (ownerCompare !== 0)
|
|
424
|
+
return ownerCompare;
|
|
425
|
+
const updatedCompare = String(b.updated_at ?? "").localeCompare(String(a.updated_at ?? ""));
|
|
426
|
+
if (updatedCompare !== 0)
|
|
427
|
+
return updatedCompare;
|
|
428
|
+
return a.id.localeCompare(b.id);
|
|
429
|
+
})[0];
|
|
430
|
+
const duplicates = defaultRooms.filter((room) => room.id !== canonical.id);
|
|
431
|
+
let applied = 0;
|
|
432
|
+
if (apply) {
|
|
433
|
+
const update = roomColumns.has("updated_at")
|
|
434
|
+
? db.prepare("UPDATE rooms SET metadata_json = ?, updated_at = ? WHERE id = ?")
|
|
435
|
+
: db.prepare("UPDATE rooms SET metadata_json = ? WHERE id = ?");
|
|
436
|
+
const now = new Date().toISOString();
|
|
437
|
+
for (const duplicate of duplicates) {
|
|
438
|
+
const metadata = parseJsonObject(duplicate.metadata_json);
|
|
439
|
+
delete metadata.default;
|
|
440
|
+
const nextMetadata = JSON.stringify({ ...metadata, legacyDefaultRoomRetiredAt: now, legacyDefaultRoomCanonicalId: canonical.id });
|
|
441
|
+
const result = roomColumns.has("updated_at") ? update.run(nextMetadata, now, duplicate.id) : update.run(nextMetadata, duplicate.id);
|
|
442
|
+
applied += Number(result.changes ?? 0);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return [action(file, "rooms", "retire-duplicate-default-rooms", duplicates.length, applied, { canonicalRoomId: canonical.id, duplicateRoomIds: duplicates.map((room) => room.id) })];
|
|
446
|
+
}
|
|
447
|
+
function planRoomMemberNormalization(db, file, tables, tableColumns, apply) {
|
|
448
|
+
const table = "room_members";
|
|
449
|
+
if (!tables.has(table))
|
|
450
|
+
return action(file, table, "merge-principal-rows", 0, 0);
|
|
451
|
+
const columns = tableColumns(table);
|
|
452
|
+
if (!columns.has("room_id") || !columns.has("principal_id"))
|
|
453
|
+
return action(file, table, "merge-principal-rows", 0, 0);
|
|
454
|
+
const rows = db.prepare(`SELECT * FROM ${quoteIdent(table)} ORDER BY room_id ASC, principal_id ASC`).all();
|
|
455
|
+
const groups = groupRows(rows, "room_id");
|
|
456
|
+
const changedGroups = [...groups.values()].filter((group) => groupNeedsPrincipalNormalization(group));
|
|
457
|
+
let applied = 0;
|
|
458
|
+
if (apply) {
|
|
459
|
+
const deleteRows = db.prepare(`DELETE FROM ${quoteIdent(table)} WHERE room_id = ?`);
|
|
460
|
+
const insertColumns = ["room_id", "principal_id", ...(columns.has("role") ? ["role"] : []), ...(columns.has("joined_at") ? ["joined_at"] : [])];
|
|
461
|
+
const insert = db.prepare(`INSERT INTO ${quoteIdent(table)} (${insertColumns.map(quoteIdent).join(", ")}) VALUES (${insertColumns.map(() => "?").join(", ")})`);
|
|
462
|
+
for (const group of changedGroups) {
|
|
463
|
+
const merged = mergeRoomMemberRows(group);
|
|
464
|
+
deleteRows.run(String(merged.room_id));
|
|
465
|
+
const values = insertColumns.map((column) => sqlValue(merged[column]));
|
|
466
|
+
applied += Number(insert.run(...values).changes ?? 0);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
return action(file, table, "merge-principal-rows", changedGroups.length, applied, { rows: changedGroups.reduce((sum, group) => sum + group.length, 0) });
|
|
470
|
+
}
|
|
471
|
+
function planPrincipalStatsNormalization(db, file, table, keyColumn, tables, tableColumns, apply) {
|
|
472
|
+
if (!tables.has(table))
|
|
473
|
+
return action(file, table, "merge-principal-stats", 0, 0);
|
|
474
|
+
const columns = tableColumns(table);
|
|
475
|
+
if (!columns.has(keyColumn) || !columns.has("principal_id"))
|
|
476
|
+
return action(file, table, "merge-principal-stats", 0, 0);
|
|
477
|
+
const rows = db.prepare(`SELECT * FROM ${quoteIdent(table)} ORDER BY ${quoteIdent(keyColumn)} ASC, principal_id ASC`).all();
|
|
478
|
+
const groups = groupRows(rows, keyColumn);
|
|
479
|
+
const changedGroups = [...groups.values()].filter((group) => groupNeedsPrincipalNormalization(group));
|
|
480
|
+
let applied = 0;
|
|
481
|
+
if (apply) {
|
|
482
|
+
const deleteRows = db.prepare(`DELETE FROM ${quoteIdent(table)} WHERE ${quoteIdent(keyColumn)} = ?`);
|
|
483
|
+
const insertColumns = [
|
|
484
|
+
keyColumn,
|
|
485
|
+
"principal_id",
|
|
486
|
+
...(columns.has("unread_count") ? ["unread_count"] : []),
|
|
487
|
+
...(columns.has("last_read_stream_id") ? ["last_read_stream_id"] : []),
|
|
488
|
+
...(columns.has("last_read_message_sequence") ? ["last_read_message_sequence"] : []),
|
|
489
|
+
...(columns.has("last_read_at") ? ["last_read_at"] : []),
|
|
490
|
+
...(columns.has("updated_at") ? ["updated_at"] : []),
|
|
491
|
+
];
|
|
492
|
+
const insert = db.prepare(`INSERT INTO ${quoteIdent(table)} (${insertColumns.map(quoteIdent).join(", ")}) VALUES (${insertColumns.map(() => "?").join(", ")})`);
|
|
493
|
+
for (const group of changedGroups) {
|
|
494
|
+
const merged = mergePrincipalStatsRows(group, keyColumn);
|
|
495
|
+
deleteRows.run(String(merged[keyColumn]));
|
|
496
|
+
applied += Number(insert.run(...insertColumns.map((column) => sqlValue(merged[column]))).changes ?? 0);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
return action(file, table, "merge-principal-stats", changedGroups.length, applied, { rows: changedGroups.reduce((sum, group) => sum + group.length, 0) });
|
|
500
|
+
}
|
|
501
|
+
function groupNeedsPrincipalNormalization(group) {
|
|
502
|
+
return group.length > 1 || group.some((row) => String(row.principal_id ?? "") !== SHARED_APP_VALUE);
|
|
503
|
+
}
|
|
504
|
+
function mergeRoomMemberRows(group) {
|
|
505
|
+
const roleRank = { owner: 4, admin: 3, member: 2, viewer: 1 };
|
|
506
|
+
const bestRole = group.reduce((best, row) => roleRank[String(row.role ?? "")] > roleRank[best] ? String(row.role) : best, "viewer");
|
|
507
|
+
const joinedAt = minString(group.map((row) => nullableString(row.joined_at))) ?? new Date(0).toISOString();
|
|
508
|
+
return { room_id: group[0].room_id, principal_id: SHARED_APP_VALUE, role: bestRole, joined_at: joinedAt };
|
|
509
|
+
}
|
|
510
|
+
function mergePrincipalStatsRows(group, keyColumn) {
|
|
511
|
+
const latest = [...group].sort((a, b) => {
|
|
512
|
+
const updated = String(b.updated_at ?? "").localeCompare(String(a.updated_at ?? ""));
|
|
513
|
+
if (updated !== 0)
|
|
514
|
+
return updated;
|
|
515
|
+
return String(a.principal_id ?? "").localeCompare(String(b.principal_id ?? ""));
|
|
516
|
+
})[0];
|
|
517
|
+
return {
|
|
518
|
+
[keyColumn]: group[0][keyColumn],
|
|
519
|
+
principal_id: SHARED_APP_VALUE,
|
|
520
|
+
unread_count: numberValue(latest.unread_count),
|
|
521
|
+
last_read_stream_id: maxNumber(group.map((row) => row.last_read_stream_id)),
|
|
522
|
+
last_read_message_sequence: maxNumber(group.map((row) => row.last_read_message_sequence)),
|
|
523
|
+
last_read_at: maxString(group.map((row) => nullableString(row.last_read_at))),
|
|
524
|
+
updated_at: maxString(group.map((row) => nullableString(row.updated_at))) ?? new Date().toISOString(),
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
function buildPiboSqlitePostCheck(db, file, tables, tableColumns) {
|
|
528
|
+
const checks = {};
|
|
529
|
+
for (const table of ["sessions", "rooms", "session_navigation"]) {
|
|
530
|
+
checks[`${table}.nonSharedOwnerRows`] = tables.has(table) && tableColumns(table).has("owner_scope") ? countRows(db, `SELECT COUNT(*) AS count FROM ${quoteIdent(table)} WHERE owner_scope IS NULL OR owner_scope != ?`, [SHARED_APP_VALUE]) : 0;
|
|
531
|
+
}
|
|
532
|
+
for (const table of ["room_members", "principal_session_stats", "principal_room_stats"]) {
|
|
533
|
+
checks[`${table}.nonSharedPrincipalRows`] = tables.has(table) && tableColumns(table).has("principal_id") ? countRows(db, `SELECT COUNT(*) AS count FROM ${quoteIdent(table)} WHERE principal_id IS NULL OR principal_id != ?`, [SHARED_APP_VALUE]) : 0;
|
|
534
|
+
}
|
|
535
|
+
if (tables.has("rooms") && tableColumns("rooms").has("metadata_json")) {
|
|
536
|
+
const rows = db.prepare("SELECT metadata_json FROM rooms").all();
|
|
537
|
+
checks["rooms.defaultRoomRows"] = rows.filter((row) => parseJsonObject(row.metadata_json).default === true).length;
|
|
538
|
+
}
|
|
539
|
+
return { store: "pibo", file, checks };
|
|
540
|
+
}
|
|
541
|
+
function action(file, table, actionName, planned, applied, details) {
|
|
542
|
+
return storeAction("pibo", file, table, actionName, planned, applied, details);
|
|
543
|
+
}
|
|
544
|
+
function storeAction(store, file, table, actionName, planned, applied, details) {
|
|
545
|
+
return { store, file, table, action: actionName, planned, applied, ...(details ? { details } : {}) };
|
|
546
|
+
}
|
|
547
|
+
function groupRows(rows, key) {
|
|
548
|
+
const groups = new Map();
|
|
549
|
+
for (const row of rows) {
|
|
550
|
+
const value = String(row[key] ?? "<null>");
|
|
551
|
+
groups.set(value, [...(groups.get(value) ?? []), row]);
|
|
552
|
+
}
|
|
553
|
+
return groups;
|
|
554
|
+
}
|
|
555
|
+
function countRows(db, sql, bindings = []) {
|
|
556
|
+
return Number(db.prepare(sql).get(...bindings.map(sqlValue))?.count ?? 0);
|
|
557
|
+
}
|
|
558
|
+
function sqlValue(value) {
|
|
559
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "bigint" || value === null || value instanceof Uint8Array)
|
|
560
|
+
return value;
|
|
561
|
+
if (value === undefined)
|
|
562
|
+
return null;
|
|
563
|
+
return JSON.stringify(value);
|
|
564
|
+
}
|
|
565
|
+
function numberValue(value) {
|
|
566
|
+
if (typeof value === "number")
|
|
567
|
+
return value;
|
|
568
|
+
if (typeof value === "bigint")
|
|
569
|
+
return Number(value);
|
|
570
|
+
return Number(value ?? 0);
|
|
571
|
+
}
|
|
572
|
+
function maxNumber(values) {
|
|
573
|
+
return Math.max(0, ...values.map(numberValue));
|
|
574
|
+
}
|
|
575
|
+
function nullableString(value) {
|
|
576
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
577
|
+
}
|
|
578
|
+
function minString(values) {
|
|
579
|
+
return values.filter((value) => Boolean(value)).sort((a, b) => a.localeCompare(b))[0];
|
|
580
|
+
}
|
|
581
|
+
function maxString(values) {
|
|
582
|
+
return values.filter((value) => Boolean(value)).sort((a, b) => b.localeCompare(a))[0];
|
|
583
|
+
}
|
|
584
|
+
function inspectStore(root, spec) {
|
|
585
|
+
const path = resolve(root, spec.file);
|
|
586
|
+
const exists = existsSync(path);
|
|
587
|
+
const report = {
|
|
588
|
+
store: spec.name,
|
|
589
|
+
file: spec.file,
|
|
590
|
+
path,
|
|
591
|
+
description: spec.description,
|
|
592
|
+
exists,
|
|
593
|
+
bytes: exists ? statSync(path).size : 0,
|
|
594
|
+
tables: spec.tables.map((table) => ({ table: table.name, exists: false, rowCount: 0, columns: table.columns.map((column) => ({ ...column, counts: [], plannedUpdates: 0 })), conflicts: [] })),
|
|
595
|
+
totalRows: 0,
|
|
596
|
+
totalPlannedUpdates: 0,
|
|
597
|
+
totalConflicts: 0,
|
|
598
|
+
};
|
|
599
|
+
if (!exists)
|
|
600
|
+
return report;
|
|
601
|
+
const db = new DatabaseSync(path, { readOnly: true });
|
|
602
|
+
try {
|
|
603
|
+
db.exec("PRAGMA query_only = ON");
|
|
604
|
+
const existingTables = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((row) => row.name));
|
|
605
|
+
report.tables = spec.tables.map((table) => inspectTable(db, table, existingTables));
|
|
606
|
+
report.totalRows = report.tables.reduce((sum, table) => sum + table.rowCount, 0);
|
|
607
|
+
report.totalPlannedUpdates = report.tables.reduce((sum, table) => sum + table.columns.reduce((columnSum, column) => columnSum + column.plannedUpdates, 0), 0);
|
|
608
|
+
report.totalConflicts = report.tables.reduce((sum, table) => sum + table.conflicts.reduce((conflictSum, conflict) => conflictSum + conflict.groups, 0), 0);
|
|
609
|
+
}
|
|
610
|
+
finally {
|
|
611
|
+
db.close();
|
|
612
|
+
}
|
|
613
|
+
return report;
|
|
614
|
+
}
|
|
615
|
+
function inspectTable(db, spec, existingTables) {
|
|
616
|
+
if (!existingTables.has(spec.name)) {
|
|
617
|
+
return {
|
|
618
|
+
table: spec.name,
|
|
619
|
+
exists: false,
|
|
620
|
+
rowCount: 0,
|
|
621
|
+
columns: spec.columns.map((column) => ({ ...column, counts: [], plannedUpdates: 0 })),
|
|
622
|
+
conflicts: [],
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
const existingColumns = new Set(db.prepare(`PRAGMA table_info(${quoteIdent(spec.name)})`).all().map((row) => row.name));
|
|
626
|
+
const rowCount = Number(db.prepare(`SELECT COUNT(*) AS count FROM ${quoteIdent(spec.name)}`).get()?.count ?? 0);
|
|
627
|
+
const columns = spec.columns.map((column) => inspectColumn(db, spec.name, column, existingColumns));
|
|
628
|
+
return {
|
|
629
|
+
table: spec.name,
|
|
630
|
+
exists: true,
|
|
631
|
+
rowCount,
|
|
632
|
+
columns,
|
|
633
|
+
conflicts: inspectConflicts(db, spec.name, spec.columns, existingColumns),
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
function inspectColumn(db, table, column, existingColumns) {
|
|
637
|
+
if (!existingColumns.has(column.column))
|
|
638
|
+
return { ...column, counts: [], plannedUpdates: 0 };
|
|
639
|
+
const columnSql = quoteIdent(column.column);
|
|
640
|
+
const rows = db.prepare(`
|
|
641
|
+
SELECT COALESCE(CAST(${columnSql} AS TEXT), '<null>') AS value, COUNT(*) AS count
|
|
642
|
+
FROM ${quoteIdent(table)}
|
|
643
|
+
GROUP BY COALESCE(CAST(${columnSql} AS TEXT), '<null>')
|
|
644
|
+
ORDER BY count DESC, value ASC
|
|
645
|
+
`).all();
|
|
646
|
+
const counts = rows.map((row) => ({ value: row.value, count: Number(row.count) }));
|
|
647
|
+
const plannedUpdates = column.plannedMutation && column.targetValue
|
|
648
|
+
? Number(db.prepare(`SELECT COUNT(*) AS count FROM ${quoteIdent(table)} WHERE ${columnSql} IS NULL OR ${columnSql} != ?`).get(column.targetValue)?.count ?? 0)
|
|
649
|
+
: 0;
|
|
650
|
+
return { ...column, counts, plannedUpdates };
|
|
651
|
+
}
|
|
652
|
+
function inspectConflicts(db, table, columns, existingColumns) {
|
|
653
|
+
const normalizingColumns = columns.filter((column) => column.plannedMutation && column.targetValue && existingColumns.has(column.column));
|
|
654
|
+
if (!normalizingColumns.length)
|
|
655
|
+
return [];
|
|
656
|
+
const uniqueIndexes = db.prepare(`PRAGMA index_list(${quoteIdent(table)})`).all().filter((index) => Number(index.unique) === 1);
|
|
657
|
+
const conflicts = [];
|
|
658
|
+
for (const index of uniqueIndexes) {
|
|
659
|
+
const indexedColumns = db.prepare(`PRAGMA index_info(${quoteIdent(index.name)})`).all().map((row) => row.name).filter((name) => Boolean(name));
|
|
660
|
+
const legacyColumns = indexedColumns.filter((column) => normalizingColumns.some((legacyColumn) => legacyColumn.column === column));
|
|
661
|
+
if (!legacyColumns.length)
|
|
662
|
+
continue;
|
|
663
|
+
const expressions = indexedColumns.map((column) => {
|
|
664
|
+
const legacyColumn = normalizingColumns.find((candidate) => candidate.column === column);
|
|
665
|
+
return legacyColumn?.targetValue ? `? AS ${quoteIdent(column)}` : quoteIdent(column);
|
|
666
|
+
});
|
|
667
|
+
const bindings = indexedColumns.flatMap((column) => {
|
|
668
|
+
const legacyColumn = normalizingColumns.find((candidate) => candidate.column === column);
|
|
669
|
+
return legacyColumn?.targetValue ? [legacyColumn.targetValue] : [];
|
|
670
|
+
});
|
|
671
|
+
const normalizedRowsSql = `SELECT ${expressions.join(", ")} FROM ${quoteIdent(table)}`;
|
|
672
|
+
const groupBySql = indexedColumns.map(quoteIdent).join(", ");
|
|
673
|
+
const conflictRows = db.prepare(`
|
|
674
|
+
SELECT COUNT(*) AS rows
|
|
675
|
+
FROM (
|
|
676
|
+
SELECT ${groupBySql}, COUNT(*) AS duplicate_count
|
|
677
|
+
FROM (${normalizedRowsSql}) normalized
|
|
678
|
+
GROUP BY ${groupBySql}
|
|
679
|
+
HAVING duplicate_count > 1
|
|
680
|
+
)
|
|
681
|
+
`).all(...bindings);
|
|
682
|
+
const groups = Number(conflictRows[0]?.rows ?? 0);
|
|
683
|
+
if (groups <= 0)
|
|
684
|
+
continue;
|
|
685
|
+
const duplicateRows = db.prepare(`
|
|
686
|
+
SELECT SUM(duplicate_count) AS rows
|
|
687
|
+
FROM (
|
|
688
|
+
SELECT ${groupBySql}, COUNT(*) AS duplicate_count
|
|
689
|
+
FROM (${normalizedRowsSql}) normalized
|
|
690
|
+
GROUP BY ${groupBySql}
|
|
691
|
+
HAVING duplicate_count > 1
|
|
692
|
+
)
|
|
693
|
+
`).get(...bindings);
|
|
694
|
+
conflicts.push({
|
|
695
|
+
indexName: index.name,
|
|
696
|
+
columns: indexedColumns,
|
|
697
|
+
legacyColumns,
|
|
698
|
+
groups,
|
|
699
|
+
rows: Number(duplicateRows?.rows ?? 0),
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
return conflicts;
|
|
703
|
+
}
|
|
704
|
+
export function formatSharedAppMigrationText(report) {
|
|
705
|
+
const lines = [
|
|
706
|
+
`shared-app migration ${report.mode}`,
|
|
707
|
+
`root\t${report.root}`,
|
|
708
|
+
`dryRun\t${report.dryRun}`,
|
|
709
|
+
`willWrite\t${report.willWrite}`,
|
|
710
|
+
`backupRequiredForApply\t${report.backup.requiredForApply}`,
|
|
711
|
+
];
|
|
712
|
+
if (report.backup.providedPath)
|
|
713
|
+
lines.push(`backup\t${report.backup.providedPath}\texists=${report.backup.providedPathExists}`);
|
|
714
|
+
lines.push(`summary\tstores=${report.summary.existingStores}/${report.summary.stores}\ttables=${report.summary.existingTables}/${report.summary.tables}\trows=${report.summary.rows}\tplannedUpdates=${report.summary.plannedUpdates}\tappliedUpdates=${report.summary.appliedUpdates}\tconflicts=${report.summary.conflicts}`);
|
|
715
|
+
for (const warning of report.warnings)
|
|
716
|
+
lines.push(`warning\t${warning}`);
|
|
717
|
+
if (report.actions.length) {
|
|
718
|
+
lines.push("action\tstore\ttable\taction\tplanned\tapplied\tdetails");
|
|
719
|
+
for (const migrationAction of report.actions)
|
|
720
|
+
lines.push(`action\t${migrationAction.store}\t${migrationAction.table}\t${migrationAction.action}\t${migrationAction.planned}\t${migrationAction.applied}\t${migrationAction.details ? JSON.stringify(migrationAction.details) : "-"}`);
|
|
721
|
+
}
|
|
722
|
+
if (report.postChecks.length) {
|
|
723
|
+
lines.push("postCheck\tstore\tchecks");
|
|
724
|
+
for (const postCheck of report.postChecks)
|
|
725
|
+
lines.push(`postCheck\t${postCheck.store}\t${JSON.stringify(postCheck.checks)}`);
|
|
726
|
+
}
|
|
727
|
+
lines.push("store\ttable\tcolumn\trows\tplannedUpdates\tvalues\tconflicts\tpath");
|
|
728
|
+
for (const store of report.stores) {
|
|
729
|
+
for (const table of store.tables) {
|
|
730
|
+
if (!table.exists) {
|
|
731
|
+
lines.push(`${store.store}\t${table.table}\t-\t0\t0\tmissing\t0\t${store.path}`);
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
734
|
+
for (const column of table.columns) {
|
|
735
|
+
const values = column.counts.map((count) => `${count.value}:${count.count}`).join(",") || "-";
|
|
736
|
+
const conflicts = table.conflicts.map((conflict) => `${conflict.indexName}:${conflict.groups}/${conflict.rows}`).join(",") || "0";
|
|
737
|
+
lines.push(`${store.store}\t${table.table}\t${column.column}\t${table.rowCount}\t${column.plannedUpdates}\t${values}\t${conflicts}\t${store.path}`);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
lines.push(`rollback\t${report.backup.rollbackInstructions}`);
|
|
742
|
+
return lines.join("\n");
|
|
743
|
+
}
|
|
744
|
+
function parseJsonObject(json) {
|
|
745
|
+
if (!json)
|
|
746
|
+
return {};
|
|
747
|
+
try {
|
|
748
|
+
const value = JSON.parse(json);
|
|
749
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
750
|
+
}
|
|
751
|
+
catch {
|
|
752
|
+
return {};
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
function quoteIdent(name) {
|
|
756
|
+
return `"${name.replaceAll('"', '""')}"`;
|
|
757
|
+
}
|