@pasko70/pibo 1.3.4 → 1.3.5
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-ext-1.3.5.vsix +0 -0
- package/package.json +1 -1
- 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/core/shared-app.js +0 -17
- package/dist/data/final-app-space-cutover-migration.js +0 -728
- package/dist/data/shared-app-migration.js +0 -757
- package/dist/session-ui/ownerViewModel.js +0 -27
- package/dist/shared-app.js +0 -4
|
@@ -1,728 +0,0 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
|
-
import { existsSync, mkdirSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { join, resolve, sep } from "node:path";
|
|
4
|
-
import { DatabaseSync } from "node:sqlite";
|
|
5
|
-
const FORBIDDEN_PRODUCTION_ROOT = "/root/.pibo";
|
|
6
|
-
const LEGACY_COLUMN_NAMES = new Set(["owner_scope", "principal_id"]);
|
|
7
|
-
const LEGACY_DROP_TABLES = new Set(["room_members", "principal_session_stats", "principal_room_stats"]);
|
|
8
|
-
const FINAL_CUTOVER_DATABASES = [
|
|
9
|
-
"pibo.sqlite",
|
|
10
|
-
"pibo-sessions.sqlite",
|
|
11
|
-
"chat-agents.sqlite",
|
|
12
|
-
"pibo-ralph.sqlite",
|
|
13
|
-
"pibo-cron.sqlite",
|
|
14
|
-
"web-annotations.sqlite",
|
|
15
|
-
"web-projects.sqlite",
|
|
16
|
-
"pibo-workflows.sqlite",
|
|
17
|
-
];
|
|
18
|
-
export function inspectFinalAppSpaceCutoverMigration(input = {}) {
|
|
19
|
-
const mode = input.mode ?? "inspect";
|
|
20
|
-
const root = resolveFinalCutoverRoot(input.root, input.env ?? process.env);
|
|
21
|
-
if (mode === "apply")
|
|
22
|
-
return applyFinalAppSpaceCutoverMigration({ root, backupPath: input.backupPath });
|
|
23
|
-
const databases = FINAL_CUTOVER_DATABASES.map((name) => inspectCutoverDatabase(root, name, mode));
|
|
24
|
-
return {
|
|
25
|
-
kind: "final-app-space-cutover",
|
|
26
|
-
mode,
|
|
27
|
-
root,
|
|
28
|
-
databases,
|
|
29
|
-
totals: summarizeCutoverDatabases(databases),
|
|
30
|
-
};
|
|
31
|
-
}
|
|
32
|
-
export function formatFinalAppSpaceCutoverReport(report) {
|
|
33
|
-
const lines = [
|
|
34
|
-
`kind\t${report.kind}`,
|
|
35
|
-
`mode\t${report.mode}`,
|
|
36
|
-
`root\t${report.root}`,
|
|
37
|
-
`databases\t${report.totals.databases}`,
|
|
38
|
-
`affectedDatabases\t${report.totals.affectedDatabases}`,
|
|
39
|
-
`legacyColumns\t${report.totals.legacyColumns}`,
|
|
40
|
-
`legacyIndexes\t${report.totals.legacyIndexes}`,
|
|
41
|
-
`legacyRows\t${report.totals.legacyRows}`,
|
|
42
|
-
`conflictGroups\t${report.totals.conflictGroups}`,
|
|
43
|
-
`plannedActions\t${report.totals.plannedActions}`,
|
|
44
|
-
`unresolvedBlockers\t${report.totals.unresolvedBlockers}`,
|
|
45
|
-
...(report.backupPath ? [`backupPath\t${report.backupPath}`] : []),
|
|
46
|
-
...(report.apply ? [`applyReportPath\t${report.apply.reportPath}`, `appliedDatabases\t${report.apply.appliedDatabases}`] : []),
|
|
47
|
-
"database\texists\tbytes\tquickCheck\tlegacyColumns\tlegacyIndexes\tlegacyRows\tconflicts\tplannedActions\tpath",
|
|
48
|
-
];
|
|
49
|
-
if (report.apply) {
|
|
50
|
-
for (const check of report.apply.rowCountChecks)
|
|
51
|
-
lines.push(`rowCount\t${check.database}\t${check.table}\t${check.beforeRows}\t${check.afterRows}\t${check.status}`);
|
|
52
|
-
for (const check of report.apply.quickChecks)
|
|
53
|
-
lines.push(`quickCheck\t${check.database}\t${check.result}`);
|
|
54
|
-
for (const instruction of report.apply.rollbackInstructions)
|
|
55
|
-
lines.push(`rollback\t${instruction}`);
|
|
56
|
-
}
|
|
57
|
-
for (const database of report.databases) {
|
|
58
|
-
const legacyRows = database.legacyValues.reduce((sum, value) => sum + value.count, 0);
|
|
59
|
-
const legacyColumns = database.tables.reduce((sum, table) => sum + table.legacyColumns.length, 0);
|
|
60
|
-
const legacyIndexes = database.tables.reduce((sum, table) => sum + table.legacyIndexes.length, 0);
|
|
61
|
-
lines.push(`${database.name}\t${database.exists}\t${database.bytes}\t${database.quickCheck ?? "-"}\t${legacyColumns}\t${legacyIndexes}\t${legacyRows}\t${database.conflictGroups.length}\t${database.plannedActions.length}\t${database.path}`);
|
|
62
|
-
for (const value of database.legacyValues)
|
|
63
|
-
lines.push(`legacyValue\t${database.name}\t${value.table}\t${value.column}\t${value.value}\t${value.count}`);
|
|
64
|
-
for (const conflict of database.conflictGroups)
|
|
65
|
-
lines.push(`conflict\t${database.name}\t${conflict.kind}\t${conflict.table}\t${conflict.key}\t${conflict.rowCount}\t${conflict.decision}`);
|
|
66
|
-
for (const action of database.plannedActions)
|
|
67
|
-
lines.push(`plan\t${database.name}\t${action.table}\t${action.action}\t${action.details}`);
|
|
68
|
-
for (const blocker of database.unresolvedBlockers)
|
|
69
|
-
lines.push(`blocker\t${database.name}\t${blocker}`);
|
|
70
|
-
}
|
|
71
|
-
return `${lines.join("\n")}\n`;
|
|
72
|
-
}
|
|
73
|
-
function resolveFinalCutoverRoot(root, env) {
|
|
74
|
-
const candidate = root ?? env.PIBO_MIGRATION_SANDBOX_HOME;
|
|
75
|
-
if (!candidate)
|
|
76
|
-
throw new Error("pibo data final-cutover requires --root <isolated-pibo-home> or PIBO_MIGRATION_SANDBOX_HOME");
|
|
77
|
-
const resolved = resolve(candidate);
|
|
78
|
-
if (resolved === FORBIDDEN_PRODUCTION_ROOT || resolved.startsWith(`${FORBIDDEN_PRODUCTION_ROOT}${sep}`)) {
|
|
79
|
-
throw new Error("pibo data final-cutover refuses to target /root/.pibo; use a Docker sandbox or temporary fixture root");
|
|
80
|
-
}
|
|
81
|
-
if (!existsSync(resolved))
|
|
82
|
-
throw new Error(`pibo data final-cutover root does not exist: ${resolved}`);
|
|
83
|
-
if (!statSync(resolved).isDirectory())
|
|
84
|
-
throw new Error(`pibo data final-cutover root is not a directory: ${resolved}`);
|
|
85
|
-
return resolved;
|
|
86
|
-
}
|
|
87
|
-
function inspectCutoverDatabase(root, name, mode) {
|
|
88
|
-
const path = join(root, name);
|
|
89
|
-
const exists = existsSync(path);
|
|
90
|
-
const report = { name, path, exists, bytes: exists ? statSync(path).size : 0, tables: [], legacyValues: [], conflictGroups: [], plannedActions: [], unresolvedBlockers: [] };
|
|
91
|
-
if (!exists)
|
|
92
|
-
return report;
|
|
93
|
-
const db = new DatabaseSync(path, { readOnly: true });
|
|
94
|
-
try {
|
|
95
|
-
report.quickCheck = "not-run-read-only-inspect";
|
|
96
|
-
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name").all();
|
|
97
|
-
const indexRows = db.prepare("SELECT name, tbl_name, sql FROM sqlite_master WHERE type = 'index' AND name NOT LIKE 'sqlite_%' ORDER BY name").all();
|
|
98
|
-
for (const { name: tableName } of tables) {
|
|
99
|
-
const columns = [...tableColumns(db, tableName)];
|
|
100
|
-
const legacyColumns = columns.filter((column) => LEGACY_COLUMN_NAMES.has(column));
|
|
101
|
-
const legacyIndexes = indexRows.filter((index) => index.tbl_name === tableName && legacyIndexMatches(index)).map((index) => index.name);
|
|
102
|
-
if (legacyColumns.length || legacyIndexes.length || LEGACY_DROP_TABLES.has(tableName)) {
|
|
103
|
-
report.tables.push({ name: tableName, rowCount: countRows(db, tableName), legacyColumns, legacyIndexes });
|
|
104
|
-
}
|
|
105
|
-
for (const column of legacyColumns)
|
|
106
|
-
report.legacyValues.push(...summarizeLegacyColumnValues(db, tableName, column));
|
|
107
|
-
}
|
|
108
|
-
report.conflictGroups.push(...collectCutoverConflicts(db, name));
|
|
109
|
-
if (mode === "dry-run")
|
|
110
|
-
report.plannedActions.push(...planCutoverActions(name, report));
|
|
111
|
-
}
|
|
112
|
-
catch (error) {
|
|
113
|
-
report.unresolvedBlockers.push(error instanceof Error ? error.message : String(error));
|
|
114
|
-
}
|
|
115
|
-
finally {
|
|
116
|
-
db.close();
|
|
117
|
-
}
|
|
118
|
-
return report;
|
|
119
|
-
}
|
|
120
|
-
function applyFinalAppSpaceCutoverMigration(input) {
|
|
121
|
-
const backupPath = resolveAndVerifyFinalCutoverBackup(input.root, input.backupPath);
|
|
122
|
-
const preDatabases = FINAL_CUTOVER_DATABASES.map((name) => inspectCutoverDatabase(input.root, name, "dry-run"));
|
|
123
|
-
const preTotals = summarizeCutoverDatabases(preDatabases);
|
|
124
|
-
if (preTotals.unresolvedBlockers > 0) {
|
|
125
|
-
const blockers = preDatabases.flatMap((database) => database.unresolvedBlockers.map((blocker) => `${database.name}: ${blocker}`));
|
|
126
|
-
throw new Error(`pibo data final-cutover apply refuses unresolved blockers: ${blockers.join("; ")}`);
|
|
127
|
-
}
|
|
128
|
-
const apply = applyCutoverDatabases(input.root, backupPath, preDatabases);
|
|
129
|
-
const databases = FINAL_CUTOVER_DATABASES.map((name) => inspectCutoverDatabase(input.root, name, "inspect"));
|
|
130
|
-
const report = {
|
|
131
|
-
kind: "final-app-space-cutover",
|
|
132
|
-
mode: "apply",
|
|
133
|
-
root: input.root,
|
|
134
|
-
backupPath,
|
|
135
|
-
apply,
|
|
136
|
-
databases,
|
|
137
|
-
totals: summarizeCutoverDatabases(databases),
|
|
138
|
-
};
|
|
139
|
-
writeFinalCutoverApplyReport(report);
|
|
140
|
-
return report;
|
|
141
|
-
}
|
|
142
|
-
function resolveAndVerifyFinalCutoverBackup(root, backupPath) {
|
|
143
|
-
if (!backupPath)
|
|
144
|
-
throw new Error("pibo data final-cutover apply requires --backup <verified-backup-dir>");
|
|
145
|
-
const resolved = resolve(backupPath);
|
|
146
|
-
if (resolved === root || resolved.startsWith(`${root}${sep}`))
|
|
147
|
-
throw new Error("pibo data final-cutover apply backup must be outside the target root");
|
|
148
|
-
if (!existsSync(resolved))
|
|
149
|
-
throw new Error(`pibo data final-cutover backup does not exist: ${resolved}`);
|
|
150
|
-
if (!statSync(resolved).isDirectory())
|
|
151
|
-
throw new Error(`pibo data final-cutover backup is not a directory: ${resolved}`);
|
|
152
|
-
for (const databaseName of FINAL_CUTOVER_DATABASES) {
|
|
153
|
-
const targetPath = join(root, databaseName);
|
|
154
|
-
if (!existsSync(targetPath))
|
|
155
|
-
continue;
|
|
156
|
-
const backupFile = join(resolved, databaseName);
|
|
157
|
-
if (!existsSync(backupFile))
|
|
158
|
-
throw new Error(`pibo data final-cutover backup is missing ${databaseName}`);
|
|
159
|
-
const db = new DatabaseSync(backupFile, { readOnly: true });
|
|
160
|
-
try {
|
|
161
|
-
const result = String(db.prepare("PRAGMA quick_check").get()?.quick_check ?? "unknown");
|
|
162
|
-
if (result !== "ok")
|
|
163
|
-
throw new Error(`pibo data final-cutover backup quick_check failed for ${databaseName}: ${result}`);
|
|
164
|
-
}
|
|
165
|
-
finally {
|
|
166
|
-
db.close();
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
return resolved;
|
|
170
|
-
}
|
|
171
|
-
function applyCutoverDatabases(root, backupPath, preDatabases) {
|
|
172
|
-
const appliedActions = preDatabases.flatMap((database) => database.plannedActions);
|
|
173
|
-
const rowCountChecks = [];
|
|
174
|
-
const quickChecks = [];
|
|
175
|
-
let appliedDatabases = 0;
|
|
176
|
-
for (const database of preDatabases) {
|
|
177
|
-
if (!database.exists)
|
|
178
|
-
continue;
|
|
179
|
-
const hasWork = database.tables.length > 0 || database.conflictGroups.length > 0;
|
|
180
|
-
if (!hasWork) {
|
|
181
|
-
quickChecks.push({ database: database.name, result: quickCheckDatabase(database.path) });
|
|
182
|
-
continue;
|
|
183
|
-
}
|
|
184
|
-
const db = new DatabaseSync(database.path);
|
|
185
|
-
try {
|
|
186
|
-
const beforeCounts = countReportedTables(db, database);
|
|
187
|
-
db.exec("BEGIN IMMEDIATE");
|
|
188
|
-
try {
|
|
189
|
-
applyDatabaseCutover(db, database.name);
|
|
190
|
-
db.exec("COMMIT");
|
|
191
|
-
}
|
|
192
|
-
catch (error) {
|
|
193
|
-
db.exec("ROLLBACK");
|
|
194
|
-
throw error;
|
|
195
|
-
}
|
|
196
|
-
const afterCounts = countReportedTables(db, database);
|
|
197
|
-
for (const table of database.tables) {
|
|
198
|
-
const beforeRows = beforeCounts.get(table.name) ?? 0;
|
|
199
|
-
const afterRows = afterCounts.get(table.name) ?? 0;
|
|
200
|
-
rowCountChecks.push({ database: database.name, table: table.name, beforeRows, afterRows, status: rowCountStatus(table.name, beforeRows, afterRows) });
|
|
201
|
-
}
|
|
202
|
-
const quickCheck = String(db.prepare("PRAGMA quick_check").get()?.quick_check ?? "unknown");
|
|
203
|
-
quickChecks.push({ database: database.name, result: quickCheck });
|
|
204
|
-
if (quickCheck !== "ok")
|
|
205
|
-
throw new Error(`pibo data final-cutover post-check failed for ${database.name}: ${quickCheck}`);
|
|
206
|
-
appliedDatabases++;
|
|
207
|
-
}
|
|
208
|
-
finally {
|
|
209
|
-
db.close();
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
return {
|
|
213
|
-
backupPath,
|
|
214
|
-
reportPath: finalCutoverReportPath(root),
|
|
215
|
-
appliedDatabases,
|
|
216
|
-
appliedActions,
|
|
217
|
-
rowCountChecks,
|
|
218
|
-
quickChecks,
|
|
219
|
-
rollbackInstructions: [
|
|
220
|
-
"Do not run this autonomous loop against Production; real cutover requires separate user approval.",
|
|
221
|
-
`To roll back this isolated root, stop any worker-local gateway, copy SQLite files from ${backupPath} back to ${root}, then rerun final-cutover inspect.`,
|
|
222
|
-
"For host Production, restore only after stopping the gateway through the Pibo CLI and redeploying the previous approved build.",
|
|
223
|
-
],
|
|
224
|
-
};
|
|
225
|
-
}
|
|
226
|
-
function applyDatabaseCutover(db, databaseName) {
|
|
227
|
-
if (databaseName === "pibo.sqlite")
|
|
228
|
-
migrateLegacyChatDataSchemaToOwnerless(db);
|
|
229
|
-
if (databaseName === "chat-agents.sqlite")
|
|
230
|
-
resolveCustomAgentProfileNameConflicts(db);
|
|
231
|
-
if (databaseName === "pibo-ralph.sqlite")
|
|
232
|
-
normalizeAutomationTargets(db, "pibo_ralph_jobs");
|
|
233
|
-
if (databaseName === "pibo-cron.sqlite")
|
|
234
|
-
normalizeAutomationTargets(db, "pibo_cron_jobs");
|
|
235
|
-
for (const tableName of [...LEGACY_DROP_TABLES])
|
|
236
|
-
dropTableIfExists(db, tableName);
|
|
237
|
-
for (const tableName of listUserTables(db))
|
|
238
|
-
rebuildTableWithoutLegacyColumns(db, tableName);
|
|
239
|
-
}
|
|
240
|
-
function countReportedTables(db, database) {
|
|
241
|
-
const counts = new Map();
|
|
242
|
-
for (const table of database.tables)
|
|
243
|
-
counts.set(table.name, tableExists(db, table.name) ? countRows(db, table.name) : 0);
|
|
244
|
-
return counts;
|
|
245
|
-
}
|
|
246
|
-
function rowCountStatus(tableName, beforeRows, afterRows) {
|
|
247
|
-
if (LEGACY_DROP_TABLES.has(tableName))
|
|
248
|
-
return "dropped";
|
|
249
|
-
if (afterRows < beforeRows)
|
|
250
|
-
return "merged";
|
|
251
|
-
if (afterRows === beforeRows)
|
|
252
|
-
return "preserved";
|
|
253
|
-
return "unchanged";
|
|
254
|
-
}
|
|
255
|
-
function quickCheckDatabase(path) {
|
|
256
|
-
const db = new DatabaseSync(path, { readOnly: true });
|
|
257
|
-
try {
|
|
258
|
-
return String(db.prepare("PRAGMA quick_check").get()?.quick_check ?? "unknown");
|
|
259
|
-
}
|
|
260
|
-
finally {
|
|
261
|
-
db.close();
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
function writeFinalCutoverApplyReport(report) {
|
|
265
|
-
if (!report.apply)
|
|
266
|
-
return;
|
|
267
|
-
mkdirSync(join(report.root, "migration-reports"), { recursive: true });
|
|
268
|
-
writeFileSync(report.apply.reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
|
|
269
|
-
}
|
|
270
|
-
function finalCutoverReportPath(root) {
|
|
271
|
-
return join(root, "migration-reports", `final-cutover-apply-${new Date().toISOString().replaceAll(/[:.]/g, "-")}.json`);
|
|
272
|
-
}
|
|
273
|
-
function summarizeCutoverDatabases(databases) {
|
|
274
|
-
let legacyColumns = 0;
|
|
275
|
-
let legacyIndexes = 0;
|
|
276
|
-
let legacyRows = 0;
|
|
277
|
-
let conflictGroups = 0;
|
|
278
|
-
let plannedActions = 0;
|
|
279
|
-
let unresolvedBlockers = 0;
|
|
280
|
-
let affectedDatabases = 0;
|
|
281
|
-
for (const database of databases) {
|
|
282
|
-
const databaseLegacyColumns = database.tables.reduce((sum, table) => sum + table.legacyColumns.length, 0);
|
|
283
|
-
const databaseLegacyIndexes = database.tables.reduce((sum, table) => sum + table.legacyIndexes.length, 0);
|
|
284
|
-
const databaseLegacyRows = database.legacyValues.reduce((sum, value) => sum + value.count, 0);
|
|
285
|
-
legacyColumns += databaseLegacyColumns;
|
|
286
|
-
legacyIndexes += databaseLegacyIndexes;
|
|
287
|
-
legacyRows += databaseLegacyRows;
|
|
288
|
-
conflictGroups += database.conflictGroups.length;
|
|
289
|
-
plannedActions += database.plannedActions.length;
|
|
290
|
-
unresolvedBlockers += database.unresolvedBlockers.length;
|
|
291
|
-
if (databaseLegacyColumns || databaseLegacyIndexes || databaseLegacyRows || database.conflictGroups.length || database.plannedActions.length || database.unresolvedBlockers.length)
|
|
292
|
-
affectedDatabases++;
|
|
293
|
-
}
|
|
294
|
-
return { databases: databases.length, affectedDatabases, legacyColumns, legacyIndexes, legacyRows, conflictGroups, plannedActions, unresolvedBlockers };
|
|
295
|
-
}
|
|
296
|
-
export function migrateLegacyChatDataSchemaToOwnerless(db) {
|
|
297
|
-
const ownsTransaction = !db.isTransaction;
|
|
298
|
-
if (ownsTransaction)
|
|
299
|
-
db.exec("BEGIN IMMEDIATE");
|
|
300
|
-
try {
|
|
301
|
-
ensureAppReadStateTables(db);
|
|
302
|
-
retireDuplicateDefaultRooms(db);
|
|
303
|
-
rebuildRoomsWithoutOwnerScope(db);
|
|
304
|
-
rebuildSessionNavigationWithoutOwnerScope(db);
|
|
305
|
-
mergePrincipalSessionStats(db);
|
|
306
|
-
mergePrincipalRoomStats(db);
|
|
307
|
-
dropTableIfExists(db, "room_members");
|
|
308
|
-
dropTableIfExists(db, "principal_session_stats");
|
|
309
|
-
dropTableIfExists(db, "principal_room_stats");
|
|
310
|
-
if (ownsTransaction)
|
|
311
|
-
db.exec("COMMIT");
|
|
312
|
-
}
|
|
313
|
-
catch (error) {
|
|
314
|
-
if (ownsTransaction)
|
|
315
|
-
db.exec("ROLLBACK");
|
|
316
|
-
throw error;
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
function ensureAppReadStateTables(db) {
|
|
320
|
-
db.exec(`
|
|
321
|
-
CREATE TABLE IF NOT EXISTS app_session_read_state (
|
|
322
|
-
session_id TEXT PRIMARY KEY,
|
|
323
|
-
unread_count INTEGER NOT NULL DEFAULT 0,
|
|
324
|
-
last_read_stream_id INTEGER NOT NULL DEFAULT 0,
|
|
325
|
-
last_read_message_sequence INTEGER NOT NULL DEFAULT 0,
|
|
326
|
-
last_read_at TEXT,
|
|
327
|
-
updated_at TEXT NOT NULL
|
|
328
|
-
);
|
|
329
|
-
CREATE TABLE IF NOT EXISTS app_room_read_state (
|
|
330
|
-
room_id TEXT PRIMARY KEY,
|
|
331
|
-
unread_count INTEGER NOT NULL DEFAULT 0,
|
|
332
|
-
last_read_stream_id INTEGER NOT NULL DEFAULT 0,
|
|
333
|
-
last_read_at TEXT,
|
|
334
|
-
updated_at TEXT NOT NULL
|
|
335
|
-
);
|
|
336
|
-
`);
|
|
337
|
-
}
|
|
338
|
-
function retireDuplicateDefaultRooms(db) {
|
|
339
|
-
if (!tableExists(db, "rooms"))
|
|
340
|
-
return;
|
|
341
|
-
const columns = tableColumns(db, "rooms");
|
|
342
|
-
if (!columns.has("id") || !columns.has("metadata_json"))
|
|
343
|
-
return;
|
|
344
|
-
const rows = db.prepare(`SELECT id, metadata_json, ${columns.has("archived_at") ? "archived_at" : "NULL AS archived_at"}, ${columns.has("updated_at") ? "updated_at" : "NULL AS updated_at"} FROM rooms ORDER BY id ASC`).all();
|
|
345
|
-
const defaultRows = rows.filter((row) => parseMetadata(row.metadata_json).default === true);
|
|
346
|
-
if (defaultRows.length <= 1)
|
|
347
|
-
return;
|
|
348
|
-
const [canonical] = [...defaultRows].sort((left, right) => {
|
|
349
|
-
const archivedCompare = Number(Boolean(left.archived_at)) - Number(Boolean(right.archived_at));
|
|
350
|
-
if (archivedCompare !== 0)
|
|
351
|
-
return archivedCompare;
|
|
352
|
-
const updatedCompare = String(right.updated_at ?? "").localeCompare(String(left.updated_at ?? ""));
|
|
353
|
-
if (updatedCompare !== 0)
|
|
354
|
-
return updatedCompare;
|
|
355
|
-
return left.id.localeCompare(right.id);
|
|
356
|
-
});
|
|
357
|
-
const update = db.prepare("UPDATE rooms SET metadata_json = ? WHERE id = ?");
|
|
358
|
-
for (const row of defaultRows) {
|
|
359
|
-
if (row.id === canonical.id)
|
|
360
|
-
continue;
|
|
361
|
-
const metadata = parseMetadata(row.metadata_json);
|
|
362
|
-
delete metadata.default;
|
|
363
|
-
update.run(JSON.stringify(metadata), row.id);
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
function rebuildRoomsWithoutOwnerScope(db) {
|
|
367
|
-
if (!tableExists(db, "rooms") || !tableColumns(db, "rooms").has("owner_scope"))
|
|
368
|
-
return;
|
|
369
|
-
const columns = tableColumns(db, "rooms");
|
|
370
|
-
db.exec(`
|
|
371
|
-
CREATE TABLE __pibo_ownerless_rooms (
|
|
372
|
-
id TEXT PRIMARY KEY,
|
|
373
|
-
name TEXT NOT NULL,
|
|
374
|
-
topic TEXT,
|
|
375
|
-
type TEXT NOT NULL,
|
|
376
|
-
parent_room_id TEXT,
|
|
377
|
-
workspace TEXT,
|
|
378
|
-
archived_at TEXT,
|
|
379
|
-
retention_policy_id TEXT,
|
|
380
|
-
metadata_json TEXT NOT NULL DEFAULT '{}',
|
|
381
|
-
created_at TEXT NOT NULL,
|
|
382
|
-
updated_at TEXT NOT NULL
|
|
383
|
-
);
|
|
384
|
-
`);
|
|
385
|
-
const now = new Date().toISOString();
|
|
386
|
-
const rows = db.prepare(`SELECT ${selectExpression(columns, "id", "NULL")}, ${selectExpression(columns, "name", "NULL")}, ${selectExpression(columns, "topic", "NULL")}, ${selectExpression(columns, "type", "NULL")}, ${selectExpression(columns, "parent_room_id", "NULL")}, ${selectExpression(columns, "workspace", "NULL")}, ${selectExpression(columns, "archived_at", "NULL")}, ${selectExpression(columns, "retention_policy_id", "NULL")}, ${selectExpression(columns, "metadata_json", "'{}'")}, ${selectExpression(columns, "created_at", "NULL")}, ${selectExpression(columns, "updated_at", "NULL")} FROM rooms ORDER BY id ASC`).all();
|
|
387
|
-
const insert = db.prepare("INSERT INTO __pibo_ownerless_rooms (id, name, topic, type, parent_room_id, workspace, archived_at, retention_policy_id, metadata_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
|
|
388
|
-
for (const row of rows) {
|
|
389
|
-
const id = stringValue(row.id);
|
|
390
|
-
if (!id)
|
|
391
|
-
continue;
|
|
392
|
-
const createdAt = stringValue(row.created_at) ?? stringValue(row.updated_at) ?? now;
|
|
393
|
-
insert.run(id, stringValue(row.name) ?? "Untitled Room", stringValue(row.topic) ?? null, stringValue(row.type) ?? "chat", stringValue(row.parent_room_id) ?? null, stringValue(row.workspace) ?? null, stringValue(row.archived_at) ?? null, stringValue(row.retention_policy_id) ?? null, stringValue(row.metadata_json) ?? "{}", createdAt, stringValue(row.updated_at) ?? createdAt);
|
|
394
|
-
}
|
|
395
|
-
db.exec("DROP TABLE rooms");
|
|
396
|
-
db.exec("ALTER TABLE __pibo_ownerless_rooms RENAME TO rooms");
|
|
397
|
-
}
|
|
398
|
-
function rebuildSessionNavigationWithoutOwnerScope(db) {
|
|
399
|
-
if (!tableExists(db, "session_navigation") || !tableColumns(db, "session_navigation").has("owner_scope"))
|
|
400
|
-
return;
|
|
401
|
-
const columns = tableColumns(db, "session_navigation");
|
|
402
|
-
db.exec(`
|
|
403
|
-
CREATE TABLE __pibo_ownerless_session_navigation (
|
|
404
|
-
room_id TEXT,
|
|
405
|
-
session_id TEXT PRIMARY KEY,
|
|
406
|
-
root_session_id TEXT,
|
|
407
|
-
parent_id TEXT,
|
|
408
|
-
origin_id TEXT,
|
|
409
|
-
title TEXT NOT NULL,
|
|
410
|
-
profile TEXT NOT NULL,
|
|
411
|
-
status TEXT NOT NULL,
|
|
412
|
-
archived_at TEXT,
|
|
413
|
-
last_activity_at TEXT NOT NULL,
|
|
414
|
-
last_message_preview TEXT,
|
|
415
|
-
child_count INTEGER NOT NULL DEFAULT 0,
|
|
416
|
-
sort_key TEXT NOT NULL,
|
|
417
|
-
updated_at TEXT NOT NULL
|
|
418
|
-
);
|
|
419
|
-
`);
|
|
420
|
-
const now = new Date().toISOString();
|
|
421
|
-
const rows = db.prepare(`SELECT ${selectExpression(columns, "room_id", "NULL")}, ${selectExpression(columns, "session_id", "NULL")}, ${selectExpression(columns, "root_session_id", "NULL")}, ${selectExpression(columns, "parent_id", "NULL")}, ${selectExpression(columns, "origin_id", "NULL")}, ${selectExpression(columns, "title", "NULL")}, ${selectExpression(columns, "profile", "NULL")}, ${selectExpression(columns, "status", "NULL")}, ${selectExpression(columns, "archived_at", "NULL")}, ${selectExpression(columns, "last_activity_at", "NULL")}, ${selectExpression(columns, "last_message_preview", "NULL")}, ${selectExpression(columns, "child_count", "0")}, ${selectExpression(columns, "sort_key", "NULL")}, ${selectExpression(columns, "updated_at", "NULL")} FROM session_navigation ORDER BY session_id ASC, updated_at DESC`).all();
|
|
422
|
-
const insert = db.prepare("INSERT OR IGNORE INTO __pibo_ownerless_session_navigation (room_id, session_id, root_session_id, parent_id, origin_id, title, profile, status, archived_at, last_activity_at, last_message_preview, child_count, sort_key, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
|
|
423
|
-
for (const row of rows) {
|
|
424
|
-
const sessionId = stringValue(row.session_id);
|
|
425
|
-
if (!sessionId)
|
|
426
|
-
continue;
|
|
427
|
-
const updatedAt = stringValue(row.updated_at) ?? now;
|
|
428
|
-
const lastActivityAt = stringValue(row.last_activity_at) ?? updatedAt;
|
|
429
|
-
insert.run(stringValue(row.room_id) ?? null, sessionId, stringValue(row.root_session_id) ?? sessionId, stringValue(row.parent_id) ?? null, stringValue(row.origin_id) ?? null, stringValue(row.title) ?? "Untitled Session", stringValue(row.profile) ?? "default", stringValue(row.status) ?? "idle", stringValue(row.archived_at) ?? null, lastActivityAt, stringValue(row.last_message_preview) ?? null, numberValue(row.child_count) ?? 0, stringValue(row.sort_key) ?? lastActivityAt, updatedAt);
|
|
430
|
-
}
|
|
431
|
-
db.exec("DROP TABLE session_navigation");
|
|
432
|
-
db.exec("ALTER TABLE __pibo_ownerless_session_navigation RENAME TO session_navigation");
|
|
433
|
-
db.exec(`
|
|
434
|
-
CREATE INDEX IF NOT EXISTS idx_session_navigation_room_sort
|
|
435
|
-
ON session_navigation(room_id, archived_at, sort_key DESC);
|
|
436
|
-
CREATE INDEX IF NOT EXISTS idx_session_navigation_root
|
|
437
|
-
ON session_navigation(root_session_id, parent_id);
|
|
438
|
-
`);
|
|
439
|
-
}
|
|
440
|
-
function mergePrincipalSessionStats(db) {
|
|
441
|
-
if (!tableExists(db, "principal_session_stats"))
|
|
442
|
-
return;
|
|
443
|
-
const columns = tableColumns(db, "principal_session_stats");
|
|
444
|
-
if (!columns.has("session_id"))
|
|
445
|
-
return;
|
|
446
|
-
const rows = db.prepare(`SELECT session_id, ${selectExpression(columns, "unread_count", "0")}, ${selectExpression(columns, "last_read_stream_id", "0")}, ${selectExpression(columns, "last_read_message_sequence", "0")}, ${selectExpression(columns, "last_read_at", "NULL")}, ${selectExpression(columns, "updated_at", "NULL")} FROM principal_session_stats ORDER BY session_id ASC`).all();
|
|
447
|
-
const merged = new Map();
|
|
448
|
-
const now = new Date().toISOString();
|
|
449
|
-
for (const row of rows) {
|
|
450
|
-
const sessionId = stringValue(row.session_id);
|
|
451
|
-
if (!sessionId)
|
|
452
|
-
continue;
|
|
453
|
-
const current = merged.get(sessionId) ?? { unreadCount: 0, lastReadStreamId: 0, lastReadMessageSequence: 0, lastReadAt: null, updatedAt: now };
|
|
454
|
-
current.unreadCount = Math.max(current.unreadCount, numberValue(row.unread_count) ?? 0);
|
|
455
|
-
current.lastReadStreamId = Math.max(current.lastReadStreamId, numberValue(row.last_read_stream_id) ?? 0);
|
|
456
|
-
current.lastReadMessageSequence = Math.max(current.lastReadMessageSequence, numberValue(row.last_read_message_sequence) ?? 0);
|
|
457
|
-
current.lastReadAt = newestTimestamp(current.lastReadAt, stringValue(row.last_read_at));
|
|
458
|
-
current.updatedAt = newestTimestamp(current.updatedAt, stringValue(row.updated_at)) ?? current.updatedAt;
|
|
459
|
-
merged.set(sessionId, current);
|
|
460
|
-
}
|
|
461
|
-
const upsert = db.prepare(`
|
|
462
|
-
INSERT INTO app_session_read_state (session_id, unread_count, last_read_stream_id, last_read_message_sequence, last_read_at, updated_at)
|
|
463
|
-
VALUES (?, ?, ?, ?, ?, ?)
|
|
464
|
-
ON CONFLICT(session_id) DO UPDATE SET
|
|
465
|
-
unread_count = MAX(app_session_read_state.unread_count, excluded.unread_count),
|
|
466
|
-
last_read_stream_id = MAX(app_session_read_state.last_read_stream_id, excluded.last_read_stream_id),
|
|
467
|
-
last_read_message_sequence = MAX(app_session_read_state.last_read_message_sequence, excluded.last_read_message_sequence),
|
|
468
|
-
last_read_at = CASE WHEN COALESCE(excluded.last_read_at, '') > COALESCE(app_session_read_state.last_read_at, '') THEN excluded.last_read_at ELSE app_session_read_state.last_read_at END,
|
|
469
|
-
updated_at = CASE WHEN excluded.updated_at > app_session_read_state.updated_at THEN excluded.updated_at ELSE app_session_read_state.updated_at END
|
|
470
|
-
`);
|
|
471
|
-
for (const [sessionId, state] of merged)
|
|
472
|
-
upsert.run(sessionId, state.unreadCount, state.lastReadStreamId, state.lastReadMessageSequence, state.lastReadAt, state.updatedAt);
|
|
473
|
-
}
|
|
474
|
-
function mergePrincipalRoomStats(db) {
|
|
475
|
-
if (!tableExists(db, "principal_room_stats"))
|
|
476
|
-
return;
|
|
477
|
-
const columns = tableColumns(db, "principal_room_stats");
|
|
478
|
-
if (!columns.has("room_id"))
|
|
479
|
-
return;
|
|
480
|
-
const rows = db.prepare(`SELECT room_id, ${selectExpression(columns, "unread_count", "0")}, ${selectExpression(columns, "last_read_stream_id", "0")}, ${selectExpression(columns, "last_read_at", "NULL")}, ${selectExpression(columns, "updated_at", "NULL")} FROM principal_room_stats ORDER BY room_id ASC`).all();
|
|
481
|
-
const merged = new Map();
|
|
482
|
-
const now = new Date().toISOString();
|
|
483
|
-
for (const row of rows) {
|
|
484
|
-
const roomId = stringValue(row.room_id);
|
|
485
|
-
if (!roomId)
|
|
486
|
-
continue;
|
|
487
|
-
const current = merged.get(roomId) ?? { unreadCount: 0, lastReadStreamId: 0, lastReadAt: null, updatedAt: now };
|
|
488
|
-
current.unreadCount = Math.max(current.unreadCount, numberValue(row.unread_count) ?? 0);
|
|
489
|
-
current.lastReadStreamId = Math.max(current.lastReadStreamId, numberValue(row.last_read_stream_id) ?? 0);
|
|
490
|
-
current.lastReadAt = newestTimestamp(current.lastReadAt, stringValue(row.last_read_at));
|
|
491
|
-
current.updatedAt = newestTimestamp(current.updatedAt, stringValue(row.updated_at)) ?? current.updatedAt;
|
|
492
|
-
merged.set(roomId, current);
|
|
493
|
-
}
|
|
494
|
-
const upsert = db.prepare(`
|
|
495
|
-
INSERT INTO app_room_read_state (room_id, unread_count, last_read_stream_id, last_read_at, updated_at)
|
|
496
|
-
VALUES (?, ?, ?, ?, ?)
|
|
497
|
-
ON CONFLICT(room_id) DO UPDATE SET
|
|
498
|
-
unread_count = MAX(app_room_read_state.unread_count, excluded.unread_count),
|
|
499
|
-
last_read_stream_id = MAX(app_room_read_state.last_read_stream_id, excluded.last_read_stream_id),
|
|
500
|
-
last_read_at = CASE WHEN COALESCE(excluded.last_read_at, '') > COALESCE(app_room_read_state.last_read_at, '') THEN excluded.last_read_at ELSE app_room_read_state.last_read_at END,
|
|
501
|
-
updated_at = CASE WHEN excluded.updated_at > app_room_read_state.updated_at THEN excluded.updated_at ELSE app_room_read_state.updated_at END
|
|
502
|
-
`);
|
|
503
|
-
for (const [roomId, state] of merged)
|
|
504
|
-
upsert.run(roomId, state.unreadCount, state.lastReadStreamId, state.lastReadAt, state.updatedAt);
|
|
505
|
-
}
|
|
506
|
-
function legacyIndexMatches(index) {
|
|
507
|
-
const text = `${index.name}\n${index.sql ?? ""}`.toLowerCase();
|
|
508
|
-
return text.includes("owner") || text.includes("principal");
|
|
509
|
-
}
|
|
510
|
-
function countRows(db, tableName) {
|
|
511
|
-
return Number(db.prepare(`SELECT COUNT(*) AS count FROM ${quoteIdentifier(tableName)}`).get()?.count ?? 0);
|
|
512
|
-
}
|
|
513
|
-
function summarizeLegacyColumnValues(db, tableName, columnName) {
|
|
514
|
-
return db.prepare(`SELECT ${quoteIdentifier(columnName)} AS value, COUNT(*) AS count FROM ${quoteIdentifier(tableName)} GROUP BY ${quoteIdentifier(columnName)} ORDER BY count DESC, value ASC`).all().map((row) => ({
|
|
515
|
-
table: tableName,
|
|
516
|
-
column: columnName,
|
|
517
|
-
value: redactLegacyValue(row.value),
|
|
518
|
-
count: Number(row.count ?? 0),
|
|
519
|
-
}));
|
|
520
|
-
}
|
|
521
|
-
function redactLegacyValue(value) {
|
|
522
|
-
if (value === null || value === undefined)
|
|
523
|
-
return "<null>";
|
|
524
|
-
const text = String(value);
|
|
525
|
-
if (text === "shared:app")
|
|
526
|
-
return text;
|
|
527
|
-
if (text.startsWith("user:"))
|
|
528
|
-
return `user:<redacted:${hashShort(text)}>`;
|
|
529
|
-
if (text.length <= 32 && /^[a-z0-9:_-]+$/i.test(text))
|
|
530
|
-
return text;
|
|
531
|
-
return `<redacted:${hashShort(text)}>`;
|
|
532
|
-
}
|
|
533
|
-
function hashShort(value) {
|
|
534
|
-
return createHash("sha256").update(value).digest("hex").slice(0, 12);
|
|
535
|
-
}
|
|
536
|
-
function collectCutoverConflicts(db, databaseName) {
|
|
537
|
-
const conflicts = [];
|
|
538
|
-
if (databaseName === "pibo.sqlite") {
|
|
539
|
-
conflicts.push(...collectDuplicateDefaultRoomConflicts(db));
|
|
540
|
-
conflicts.push(...collectDuplicateNavigationConflicts(db));
|
|
541
|
-
}
|
|
542
|
-
if (databaseName === "chat-agents.sqlite")
|
|
543
|
-
conflicts.push(...collectCustomAgentProfileConflicts(db));
|
|
544
|
-
if (databaseName === "pibo-ralph.sqlite")
|
|
545
|
-
conflicts.push(...collectAutomationTargetConflicts(db, "pibo_ralph_jobs"));
|
|
546
|
-
if (databaseName === "pibo-cron.sqlite")
|
|
547
|
-
conflicts.push(...collectAutomationTargetConflicts(db, "pibo_cron_jobs"));
|
|
548
|
-
return conflicts;
|
|
549
|
-
}
|
|
550
|
-
function collectDuplicateDefaultRoomConflicts(db) {
|
|
551
|
-
if (!tableExists(db, "rooms"))
|
|
552
|
-
return [];
|
|
553
|
-
const columns = tableColumns(db, "rooms");
|
|
554
|
-
if (!columns.has("id") || !columns.has("metadata_json"))
|
|
555
|
-
return [];
|
|
556
|
-
const rows = db.prepare(`SELECT id, metadata_json, ${selectExpression(columns, "archived_at", "NULL")}, ${selectExpression(columns, "updated_at", "NULL")} FROM rooms ORDER BY id ASC`).all();
|
|
557
|
-
const defaults = rows.filter((row) => parseMetadata(row.metadata_json).default === true);
|
|
558
|
-
if (defaults.length <= 1)
|
|
559
|
-
return [];
|
|
560
|
-
const [selected] = [...defaults].sort((left, right) => {
|
|
561
|
-
const archivedCompare = Number(Boolean(left.archived_at)) - Number(Boolean(right.archived_at));
|
|
562
|
-
if (archivedCompare !== 0)
|
|
563
|
-
return archivedCompare;
|
|
564
|
-
const updatedCompare = String(right.updated_at ?? "").localeCompare(String(left.updated_at ?? ""));
|
|
565
|
-
if (updatedCompare !== 0)
|
|
566
|
-
return updatedCompare;
|
|
567
|
-
return left.id.localeCompare(right.id);
|
|
568
|
-
});
|
|
569
|
-
return [{ kind: "duplicate-default-room", table: "rooms", key: "app-default-room", rowCount: defaults.length, rowIds: defaults.map((row) => row.id), decision: `keep ${selected.id}; clear default metadata on older duplicates` }];
|
|
570
|
-
}
|
|
571
|
-
function collectDuplicateNavigationConflicts(db) {
|
|
572
|
-
if (!tableExists(db, "session_navigation"))
|
|
573
|
-
return [];
|
|
574
|
-
const columns = tableColumns(db, "session_navigation");
|
|
575
|
-
if (!columns.has("session_id"))
|
|
576
|
-
return [];
|
|
577
|
-
const rows = db.prepare(`SELECT session_id, COUNT(*) AS count, GROUP_CONCAT(rowid) AS rowids FROM session_navigation GROUP BY session_id HAVING COUNT(*) > 1 ORDER BY session_id ASC`).all();
|
|
578
|
-
return rows.map((row) => ({ kind: "duplicate-navigation", table: "session_navigation", key: row.session_id, rowCount: Number(row.count), rowIds: String(row.rowids ?? "").split(",").filter(Boolean), decision: "keep newest updated_at row for each session_id" }));
|
|
579
|
-
}
|
|
580
|
-
function collectCustomAgentProfileConflicts(db) {
|
|
581
|
-
if (!tableExists(db, "chat_agents"))
|
|
582
|
-
return [];
|
|
583
|
-
const columns = tableColumns(db, "chat_agents");
|
|
584
|
-
if (!columns.has("profile_name") || !columns.has("id"))
|
|
585
|
-
return [];
|
|
586
|
-
const rows = db.prepare("SELECT profile_name, COUNT(*) AS count, GROUP_CONCAT(id) AS ids FROM chat_agents GROUP BY profile_name HAVING COUNT(*) > 1 ORDER BY profile_name ASC").all();
|
|
587
|
-
return rows.map((row) => ({ kind: "duplicate-custom-agent-profile", table: "chat_agents", key: `<redacted:${hashShort(row.profile_name)}>`, rowCount: Number(row.count), rowIds: String(row.ids ?? "").split(",").filter(Boolean), decision: "keep newest updated_at row on original profile name; rename older rows with deterministic legacy hash suffix" }));
|
|
588
|
-
}
|
|
589
|
-
function collectAutomationTargetConflicts(db, tableName) {
|
|
590
|
-
if (!tableExists(db, tableName))
|
|
591
|
-
return [];
|
|
592
|
-
const columns = tableColumns(db, tableName);
|
|
593
|
-
if (!columns.has("target_json") || !columns.has("id"))
|
|
594
|
-
return [];
|
|
595
|
-
const rows = db.prepare(`SELECT id, target_json FROM ${quoteIdentifier(tableName)} ORDER BY id ASC`).all();
|
|
596
|
-
const legacyRows = rows.filter((row) => {
|
|
597
|
-
const target = parseMetadata(row.target_json);
|
|
598
|
-
return target.kind === "personal" || typeof target.principalId === "string";
|
|
599
|
-
});
|
|
600
|
-
if (legacyRows.length === 0)
|
|
601
|
-
return [];
|
|
602
|
-
return [{ kind: "legacy-automation-target", table: tableName, key: "default-chat-normalization", rowCount: legacyRows.length, rowIds: legacyRows.map((row) => row.id), decision: "normalize legacy personal/principal target to default-chat" }];
|
|
603
|
-
}
|
|
604
|
-
function planCutoverActions(databaseName, report) {
|
|
605
|
-
const actions = [];
|
|
606
|
-
for (const table of report.tables) {
|
|
607
|
-
if (LEGACY_DROP_TABLES.has(table.name)) {
|
|
608
|
-
const action = table.name.startsWith("principal_") ? "merge-then-drop-table" : "drop-table";
|
|
609
|
-
actions.push({ database: databaseName, table: table.name, action, details: `${table.rowCount} historical rows` });
|
|
610
|
-
continue;
|
|
611
|
-
}
|
|
612
|
-
if (table.legacyColumns.length || table.legacyIndexes.length) {
|
|
613
|
-
actions.push({ database: databaseName, table: table.name, action: "rebuild-table", details: `remove columns [${table.legacyColumns.join(", ") || "-"}] and indexes [${table.legacyIndexes.join(", ") || "-"}]` });
|
|
614
|
-
}
|
|
615
|
-
}
|
|
616
|
-
for (const conflict of report.conflictGroups) {
|
|
617
|
-
actions.push({ database: databaseName, table: conflict.table, action: `resolve-${conflict.kind}`, details: conflict.decision });
|
|
618
|
-
}
|
|
619
|
-
return actions;
|
|
620
|
-
}
|
|
621
|
-
function listUserTables(db) {
|
|
622
|
-
return db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name").all().map((row) => row.name);
|
|
623
|
-
}
|
|
624
|
-
function rebuildTableWithoutLegacyColumns(db, tableName) {
|
|
625
|
-
if (!tableExists(db, tableName))
|
|
626
|
-
return;
|
|
627
|
-
const info = db.prepare(`PRAGMA table_info(${quoteIdentifier(tableName)})`).all();
|
|
628
|
-
const legacyColumns = info.filter((column) => LEGACY_COLUMN_NAMES.has(column.name));
|
|
629
|
-
if (legacyColumns.length === 0)
|
|
630
|
-
return;
|
|
631
|
-
const kept = info.filter((column) => !LEGACY_COLUMN_NAMES.has(column.name));
|
|
632
|
-
if (kept.length === 0) {
|
|
633
|
-
dropTableIfExists(db, tableName);
|
|
634
|
-
return;
|
|
635
|
-
}
|
|
636
|
-
const tempName = `__pibo_ownerless_${tableName}_${Date.now().toString(36)}`;
|
|
637
|
-
const pkColumns = kept.filter((column) => column.pk > 0).sort((left, right) => left.pk - right.pk);
|
|
638
|
-
const singleColumnPrimaryKey = pkColumns.length === 1;
|
|
639
|
-
const definitions = kept.map((column) => columnDefinition(column, singleColumnPrimaryKey));
|
|
640
|
-
if (pkColumns.length > 1)
|
|
641
|
-
definitions.push(`PRIMARY KEY (${pkColumns.map((column) => quoteIdentifier(column.name)).join(", ")})`);
|
|
642
|
-
db.exec(`CREATE TABLE ${quoteIdentifier(tempName)} (${definitions.join(", ")})`);
|
|
643
|
-
const columnList = kept.map((column) => quoteIdentifier(column.name)).join(", ");
|
|
644
|
-
db.exec(`INSERT INTO ${quoteIdentifier(tempName)} (${columnList}) SELECT ${columnList} FROM ${quoteIdentifier(tableName)}`);
|
|
645
|
-
db.exec(`DROP TABLE ${quoteIdentifier(tableName)}`);
|
|
646
|
-
db.exec(`ALTER TABLE ${quoteIdentifier(tempName)} RENAME TO ${quoteIdentifier(tableName)}`);
|
|
647
|
-
}
|
|
648
|
-
function columnDefinition(column, singleColumnPrimaryKey) {
|
|
649
|
-
const parts = [quoteIdentifier(column.name), column.type || "TEXT"];
|
|
650
|
-
if (singleColumnPrimaryKey && column.pk > 0)
|
|
651
|
-
parts.push("PRIMARY KEY");
|
|
652
|
-
if (column.notnull && !(singleColumnPrimaryKey && column.pk > 0))
|
|
653
|
-
parts.push("NOT NULL");
|
|
654
|
-
if (column.dflt_value !== null && column.dflt_value !== undefined)
|
|
655
|
-
parts.push(`DEFAULT ${String(column.dflt_value)}`);
|
|
656
|
-
return parts.join(" ");
|
|
657
|
-
}
|
|
658
|
-
function resolveCustomAgentProfileNameConflicts(db) {
|
|
659
|
-
if (!tableExists(db, "chat_agents"))
|
|
660
|
-
return;
|
|
661
|
-
const columns = tableColumns(db, "chat_agents");
|
|
662
|
-
if (!columns.has("id") || !columns.has("profile_name"))
|
|
663
|
-
return;
|
|
664
|
-
const duplicates = db.prepare("SELECT profile_name FROM chat_agents GROUP BY profile_name HAVING COUNT(*) > 1 ORDER BY profile_name ASC").all();
|
|
665
|
-
for (const duplicate of duplicates) {
|
|
666
|
-
const rows = db.prepare(`SELECT id, profile_name, ${selectExpression(columns, "display_name", "NULL")}, ${selectExpression(columns, "updated_at", "NULL")} FROM chat_agents WHERE profile_name = ? ORDER BY updated_at DESC, id ASC`).all(duplicate.profile_name);
|
|
667
|
-
for (const row of rows.slice(1)) {
|
|
668
|
-
const nextName = `${duplicate.profile_name}-legacy-${hashShort(`${row.id}:${duplicate.profile_name}`).slice(0, 8)}`;
|
|
669
|
-
if (columns.has("display_name") && row.display_name === duplicate.profile_name)
|
|
670
|
-
db.prepare("UPDATE chat_agents SET profile_name = ?, display_name = ? WHERE id = ?").run(nextName, nextName, row.id);
|
|
671
|
-
else
|
|
672
|
-
db.prepare("UPDATE chat_agents SET profile_name = ? WHERE id = ?").run(nextName, row.id);
|
|
673
|
-
}
|
|
674
|
-
}
|
|
675
|
-
}
|
|
676
|
-
function normalizeAutomationTargets(db, tableName) {
|
|
677
|
-
if (!tableExists(db, tableName))
|
|
678
|
-
return;
|
|
679
|
-
const columns = tableColumns(db, tableName);
|
|
680
|
-
if (!columns.has("id") || !columns.has("target_json"))
|
|
681
|
-
return;
|
|
682
|
-
const rows = db.prepare(`SELECT id, target_json FROM ${quoteIdentifier(tableName)} ORDER BY id ASC`).all();
|
|
683
|
-
const update = db.prepare(`UPDATE ${quoteIdentifier(tableName)} SET target_json = ? WHERE id = ?`);
|
|
684
|
-
for (const row of rows) {
|
|
685
|
-
const target = parseMetadata(row.target_json);
|
|
686
|
-
if (target.kind === "personal" || typeof target.principalId === "string")
|
|
687
|
-
update.run(JSON.stringify({ kind: "default-chat" }), row.id);
|
|
688
|
-
}
|
|
689
|
-
}
|
|
690
|
-
function tableExists(db, tableName) {
|
|
691
|
-
return Boolean(db.prepare("SELECT 1 AS found FROM sqlite_master WHERE type = 'table' AND name = ?").get(tableName));
|
|
692
|
-
}
|
|
693
|
-
function tableColumns(db, tableName) {
|
|
694
|
-
if (!tableExists(db, tableName))
|
|
695
|
-
return new Set();
|
|
696
|
-
return new Set(db.prepare(`PRAGMA table_info(${quoteIdentifier(tableName)})`).all().map((column) => column.name));
|
|
697
|
-
}
|
|
698
|
-
function dropTableIfExists(db, tableName) {
|
|
699
|
-
db.exec(`DROP TABLE IF EXISTS ${quoteIdentifier(tableName)}`);
|
|
700
|
-
}
|
|
701
|
-
function selectExpression(columns, columnName, fallback) {
|
|
702
|
-
return columns.has(columnName) ? quoteIdentifier(columnName) : `${fallback} AS ${quoteIdentifier(columnName)}`;
|
|
703
|
-
}
|
|
704
|
-
function parseMetadata(value) {
|
|
705
|
-
try {
|
|
706
|
-
const parsed = value ? JSON.parse(value) : {};
|
|
707
|
-
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
708
|
-
}
|
|
709
|
-
catch {
|
|
710
|
-
return {};
|
|
711
|
-
}
|
|
712
|
-
}
|
|
713
|
-
function stringValue(value) {
|
|
714
|
-
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
715
|
-
}
|
|
716
|
-
function numberValue(value) {
|
|
717
|
-
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
718
|
-
}
|
|
719
|
-
function newestTimestamp(left, right) {
|
|
720
|
-
if (!right)
|
|
721
|
-
return left;
|
|
722
|
-
if (!left)
|
|
723
|
-
return right;
|
|
724
|
-
return right > left ? right : left;
|
|
725
|
-
}
|
|
726
|
-
function quoteIdentifier(value) {
|
|
727
|
-
return `"${value.replaceAll('"', '""')}"`;
|
|
728
|
-
}
|