@withone/cli 1.42.0 → 1.43.2
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/README.md +57 -17
- package/dist/chunk-44CV5IMX.js +38 -0
- package/dist/chunk-5TT27ORC.js +1698 -0
- package/dist/chunk-7N5EJTYW.js +59 -0
- package/dist/chunk-AD4QM6R5.js +762 -0
- package/dist/chunk-AU2ZEEMS.js +477 -0
- package/dist/{chunk-PK2RAVAF.js → chunk-EMAQVPRD.js} +6 -36
- package/dist/chunk-MRGKKO54.js +158 -0
- package/dist/embedding-O4XWBL6T.js +10 -0
- package/dist/{flow-runner-Y2CXXU3U.js → flow-runner-XSJ4US5S.js} +2 -1
- package/dist/index.js +2854 -1691
- package/dist/migrate-LHFWCO6M.js +16 -0
- package/dist/runtime-O3PX562N.js +13 -0
- package/dist/sql-JEBAPGJP.js +11 -0
- package/package.json +6 -2
- package/profiles/attio/attioCompanies.json +5 -3
- package/profiles/attio/attioPeople.json +5 -4
- package/skills/one/SKILL.md +83 -10
|
@@ -0,0 +1,762 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getByDotPath
|
|
3
|
+
} from "./chunk-44CV5IMX.js";
|
|
4
|
+
import {
|
|
5
|
+
isAgentMode,
|
|
6
|
+
note,
|
|
7
|
+
okJson,
|
|
8
|
+
requireMemoryInit
|
|
9
|
+
} from "./chunk-MRGKKO54.js";
|
|
10
|
+
import {
|
|
11
|
+
getBackend,
|
|
12
|
+
upsertRecord
|
|
13
|
+
} from "./chunk-5TT27ORC.js";
|
|
14
|
+
|
|
15
|
+
// src/commands/mem/migrate.ts
|
|
16
|
+
import fs4 from "fs";
|
|
17
|
+
import path4 from "path";
|
|
18
|
+
import * as p from "@clack/prompts";
|
|
19
|
+
|
|
20
|
+
// src/lib/memory/sync/profile.ts
|
|
21
|
+
import fs from "fs";
|
|
22
|
+
import path from "path";
|
|
23
|
+
var PROFILES_DIR = path.join(".one", "sync", "profiles");
|
|
24
|
+
function profilePath(platform, model) {
|
|
25
|
+
return path.join(PROFILES_DIR, `${platform}_${model}.json`);
|
|
26
|
+
}
|
|
27
|
+
function readProfile(platform, model) {
|
|
28
|
+
const filePath = profilePath(platform, model);
|
|
29
|
+
try {
|
|
30
|
+
if (!fs.existsSync(filePath)) return null;
|
|
31
|
+
const raw = fs.readFileSync(filePath, "utf-8");
|
|
32
|
+
return JSON.parse(raw);
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function writeProfile(profile) {
|
|
38
|
+
const required = ["platform", "model", "actionId", "idField", "pagination"];
|
|
39
|
+
for (const field of required) {
|
|
40
|
+
if (!profile[field]) {
|
|
41
|
+
throw new Error(`Missing required field: ${field}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const hasKey = !!profile.connectionKey;
|
|
45
|
+
const hasRef = !!profile.connection?.platform;
|
|
46
|
+
if (hasKey && hasRef) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
"Profile has both `connectionKey` and `connection` \u2014 set exactly one. Prefer `connection: { platform, tag? }` so re-auth doesn't break the profile."
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
if (!hasKey && !hasRef) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
'Missing connection: set `connection: { platform: "<name>" }` (or legacy `connectionKey: "<key>"`).'
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
if (profile.resultsPath === void 0) {
|
|
57
|
+
throw new Error('Missing required field: resultsPath (use "" or "$" for root-array responses)');
|
|
58
|
+
}
|
|
59
|
+
if (!profile.pagination.type) {
|
|
60
|
+
throw new Error("Missing required field: pagination.type");
|
|
61
|
+
}
|
|
62
|
+
fs.mkdirSync(PROFILES_DIR, { recursive: true });
|
|
63
|
+
const filePath = profilePath(profile.platform, profile.model);
|
|
64
|
+
fs.writeFileSync(filePath, JSON.stringify(profile, null, 2));
|
|
65
|
+
}
|
|
66
|
+
async function resolveProfileConnectionKey(api, profile, cache) {
|
|
67
|
+
if (profile.connectionKey) return profile.connectionKey;
|
|
68
|
+
if (!profile.connection?.platform) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`Profile ${profile.platform}/${profile.model} has no connectionKey or connection ref.`
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
const conn = await api.resolveConnection(profile.connection, cache);
|
|
74
|
+
return conn.key;
|
|
75
|
+
}
|
|
76
|
+
function writeDraftProfile(platform, model, draft) {
|
|
77
|
+
fs.mkdirSync(PROFILES_DIR, { recursive: true });
|
|
78
|
+
const filePath = profilePath(platform, model);
|
|
79
|
+
fs.writeFileSync(filePath, JSON.stringify(draft, null, 2));
|
|
80
|
+
}
|
|
81
|
+
function listProfiles(platform) {
|
|
82
|
+
if (!fs.existsSync(PROFILES_DIR)) return [];
|
|
83
|
+
const files = fs.readdirSync(PROFILES_DIR).filter((f) => f.endsWith(".json"));
|
|
84
|
+
const profiles = [];
|
|
85
|
+
for (const file of files) {
|
|
86
|
+
try {
|
|
87
|
+
const raw = fs.readFileSync(path.join(PROFILES_DIR, file), "utf-8");
|
|
88
|
+
const profile = JSON.parse(raw);
|
|
89
|
+
if (!platform || profile.platform === platform) {
|
|
90
|
+
profiles.push(profile);
|
|
91
|
+
}
|
|
92
|
+
} catch {
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return profiles;
|
|
96
|
+
}
|
|
97
|
+
function generateTemplate(platform, model, actionId) {
|
|
98
|
+
return {
|
|
99
|
+
platform,
|
|
100
|
+
model,
|
|
101
|
+
// Late-bound ref — survives re-auth. Use { platform, tag } when the
|
|
102
|
+
// platform has multiple connections (e.g. multiple Gmail accounts).
|
|
103
|
+
connection: { platform },
|
|
104
|
+
actionId: actionId ?? "FILL_IN",
|
|
105
|
+
resultsPath: "FILL_IN",
|
|
106
|
+
idField: "FILL_IN",
|
|
107
|
+
pagination: {
|
|
108
|
+
type: "FILL_IN (cursor | token | offset | id | link | none)",
|
|
109
|
+
nextPath: "FILL_IN",
|
|
110
|
+
passAs: "FILL_IN (query:name | body:name | header:name)"
|
|
111
|
+
}
|
|
112
|
+
// Optional. Set to "body" for POST-body list endpoints (e.g. Notion /v1/search).
|
|
113
|
+
// limitLocation: "query",
|
|
114
|
+
// Optional. Page size param name. Set to "" to disable sending any page size.
|
|
115
|
+
// limitParam: "limit",
|
|
116
|
+
// Optional. Default page size (100).
|
|
117
|
+
// defaultLimit: 100,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// src/lib/memory/sync/builtin-profiles.ts
|
|
122
|
+
import fs2 from "fs";
|
|
123
|
+
import path2 from "path";
|
|
124
|
+
import { fileURLToPath } from "url";
|
|
125
|
+
function getProfilesDir() {
|
|
126
|
+
const thisFile = fileURLToPath(import.meta.url);
|
|
127
|
+
const thisDir = path2.dirname(thisFile);
|
|
128
|
+
for (let i = 1; i <= 4; i++) {
|
|
129
|
+
const candidate = path2.resolve(thisDir, ...Array(i).fill(".."), "profiles");
|
|
130
|
+
if (fs2.existsSync(candidate)) return candidate;
|
|
131
|
+
}
|
|
132
|
+
return "";
|
|
133
|
+
}
|
|
134
|
+
function loadBuiltinProfile(platform, model) {
|
|
135
|
+
const dir = getProfilesDir();
|
|
136
|
+
if (!dir) return null;
|
|
137
|
+
const filePath = path2.join(dir, platform, `${model}.json`);
|
|
138
|
+
try {
|
|
139
|
+
if (!fs2.existsSync(filePath)) return null;
|
|
140
|
+
const raw = fs2.readFileSync(filePath, "utf-8");
|
|
141
|
+
return JSON.parse(raw);
|
|
142
|
+
} catch {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function listBuiltinProfiles(platform) {
|
|
147
|
+
const dir = getProfilesDir();
|
|
148
|
+
if (!dir) return [];
|
|
149
|
+
const profiles = [];
|
|
150
|
+
try {
|
|
151
|
+
const platforms = platform ? [platform] : fs2.readdirSync(dir).filter((f) => {
|
|
152
|
+
try {
|
|
153
|
+
return fs2.statSync(path2.join(dir, f)).isDirectory();
|
|
154
|
+
} catch {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
for (const plat of platforms) {
|
|
159
|
+
const platDir = path2.join(dir, plat);
|
|
160
|
+
if (!fs2.existsSync(platDir)) continue;
|
|
161
|
+
const files = fs2.readdirSync(platDir).filter((f) => f.endsWith(".json"));
|
|
162
|
+
for (const file of files) {
|
|
163
|
+
try {
|
|
164
|
+
const raw = fs2.readFileSync(path2.join(platDir, file), "utf-8");
|
|
165
|
+
const profile = JSON.parse(raw);
|
|
166
|
+
profiles.push(profile);
|
|
167
|
+
} catch {
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
} catch {
|
|
172
|
+
}
|
|
173
|
+
return profiles;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// src/lib/memory/sync/db.ts
|
|
177
|
+
import fs3 from "fs";
|
|
178
|
+
import path3 from "path";
|
|
179
|
+
|
|
180
|
+
// src/lib/memory/sync/sqlite-loader.ts
|
|
181
|
+
var cached = null;
|
|
182
|
+
async function loadSqlite() {
|
|
183
|
+
if (cached) return cached;
|
|
184
|
+
try {
|
|
185
|
+
const modName = "better-sqlite3";
|
|
186
|
+
const mod = await import(modName);
|
|
187
|
+
cached = mod.default;
|
|
188
|
+
return cached;
|
|
189
|
+
} catch (err) {
|
|
190
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
191
|
+
throw new Error(
|
|
192
|
+
`The local sync engine (better-sqlite3) is not installed.
|
|
193
|
+
|
|
194
|
+
Install it with:
|
|
195
|
+
one sync install
|
|
196
|
+
|
|
197
|
+
Or manually:
|
|
198
|
+
npm install -g better-sqlite3
|
|
199
|
+
|
|
200
|
+
Underlying error: ${detail}`
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
async function isSqliteAvailable() {
|
|
205
|
+
try {
|
|
206
|
+
await loadSqlite();
|
|
207
|
+
return true;
|
|
208
|
+
} catch {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// src/lib/memory/sync/db.ts
|
|
214
|
+
var DATA_DIR = path3.join(".one", "sync", "data");
|
|
215
|
+
function listSyncedPlatforms() {
|
|
216
|
+
if (!fs3.existsSync(DATA_DIR)) return [];
|
|
217
|
+
return fs3.readdirSync(DATA_DIR).filter((f) => f.endsWith(".db")).map((f) => f.replace(/\.db$/, ""));
|
|
218
|
+
}
|
|
219
|
+
async function openDatabase(platform, opts = {}) {
|
|
220
|
+
const Database = await loadSqlite();
|
|
221
|
+
fs3.mkdirSync(DATA_DIR, { recursive: true });
|
|
222
|
+
const dbPath = path3.join(DATA_DIR, `${platform}.db`);
|
|
223
|
+
if (opts.readonly) {
|
|
224
|
+
return new Database(dbPath, { readonly: true, fileMustExist: true });
|
|
225
|
+
}
|
|
226
|
+
let db;
|
|
227
|
+
try {
|
|
228
|
+
db = new Database(dbPath);
|
|
229
|
+
} catch {
|
|
230
|
+
const backupPath = dbPath + ".bak";
|
|
231
|
+
if (fs3.existsSync(dbPath)) {
|
|
232
|
+
fs3.renameSync(dbPath, backupPath);
|
|
233
|
+
process.stderr.write(`Database corrupted, starting fresh. Backup saved at ${backupPath}
|
|
234
|
+
`);
|
|
235
|
+
}
|
|
236
|
+
db = new Database(dbPath);
|
|
237
|
+
}
|
|
238
|
+
db.pragma("journal_mode = WAL");
|
|
239
|
+
db.pragma("busy_timeout = 15000");
|
|
240
|
+
db.pragma("foreign_keys = OFF");
|
|
241
|
+
return db;
|
|
242
|
+
}
|
|
243
|
+
function getDatabasePath(platform) {
|
|
244
|
+
return path3.join(DATA_DIR, `${platform}.db`);
|
|
245
|
+
}
|
|
246
|
+
function getDatabaseSize(platform) {
|
|
247
|
+
const dbPath = getDatabasePath(platform);
|
|
248
|
+
if (!fs3.existsSync(dbPath)) return "0 B";
|
|
249
|
+
const stats = fs3.statSync(dbPath);
|
|
250
|
+
const bytes = stats.size;
|
|
251
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
252
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
253
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
254
|
+
}
|
|
255
|
+
function detectColumnType(value) {
|
|
256
|
+
if (value === null || value === void 0) return "TEXT";
|
|
257
|
+
if (typeof value === "string") return "TEXT";
|
|
258
|
+
if (typeof value === "boolean") return "INTEGER";
|
|
259
|
+
if (typeof value === "number") return Number.isInteger(value) ? "INTEGER" : "REAL";
|
|
260
|
+
if (typeof value === "object") return "TEXT";
|
|
261
|
+
return "TEXT";
|
|
262
|
+
}
|
|
263
|
+
function sanitizeTableName(name) {
|
|
264
|
+
return name.replace(/[^a-zA-Z0-9_]/g, "_");
|
|
265
|
+
}
|
|
266
|
+
function getTableColumns(db, model) {
|
|
267
|
+
const table = sanitizeTableName(model);
|
|
268
|
+
const rows = db.prepare(`PRAGMA table_info("${table}")`).all();
|
|
269
|
+
return rows.map((r) => ({ name: r.name, type: r.type }));
|
|
270
|
+
}
|
|
271
|
+
function tableExists(db, model) {
|
|
272
|
+
const table = sanitizeTableName(model);
|
|
273
|
+
const row = db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name=?`).get(table);
|
|
274
|
+
return !!row;
|
|
275
|
+
}
|
|
276
|
+
function ensureTable(db, model, firstRecord, idField) {
|
|
277
|
+
const table = sanitizeTableName(model);
|
|
278
|
+
if (tableExists(db, model)) {
|
|
279
|
+
return getTableColumns(db, model).map((c) => c.name);
|
|
280
|
+
}
|
|
281
|
+
const columns = [];
|
|
282
|
+
const colDefs = [];
|
|
283
|
+
for (const [key, value] of Object.entries(firstRecord)) {
|
|
284
|
+
const colType = detectColumnType(value);
|
|
285
|
+
colDefs.push(`"${key}" ${colType}`);
|
|
286
|
+
columns.push(key);
|
|
287
|
+
}
|
|
288
|
+
if (!columns.includes("_synced_at")) {
|
|
289
|
+
colDefs.push('"_synced_at" TEXT');
|
|
290
|
+
columns.push("_synced_at");
|
|
291
|
+
}
|
|
292
|
+
db.exec(`CREATE TABLE IF NOT EXISTS "${table}" (${colDefs.join(", ")})`);
|
|
293
|
+
const safeIdField = idField.replace(/"/g, '""');
|
|
294
|
+
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS "idx_${table}_${sanitizeTableName(idField)}" ON "${table}" ("${safeIdField}")`);
|
|
295
|
+
return columns;
|
|
296
|
+
}
|
|
297
|
+
function rebuildFtsIndex(db, model) {
|
|
298
|
+
const table = sanitizeTableName(model);
|
|
299
|
+
const ftsTable = `${table}_fts`;
|
|
300
|
+
const columns = getTableColumns(db, model);
|
|
301
|
+
const textCols = columns.filter((c) => c.type === "TEXT" && c.name !== "_synced_at").map((c) => c.name);
|
|
302
|
+
if (textCols.length === 0) return;
|
|
303
|
+
const quotedCols = textCols.map((c) => `"${c}"`).join(", ");
|
|
304
|
+
db.exec(`DROP TABLE IF EXISTS "${ftsTable}"`);
|
|
305
|
+
db.exec(`DROP TRIGGER IF EXISTS "${table}_ai"`);
|
|
306
|
+
db.exec(`DROP TRIGGER IF EXISTS "${table}_au"`);
|
|
307
|
+
db.exec(`CREATE VIRTUAL TABLE "${ftsTable}" USING fts5(${quotedCols})`);
|
|
308
|
+
db.exec(`INSERT INTO "${ftsTable}"(rowid, ${quotedCols}) SELECT rowid, ${quotedCols} FROM "${table}"`);
|
|
309
|
+
}
|
|
310
|
+
function evolveSchema(db, model, record) {
|
|
311
|
+
const table = sanitizeTableName(model);
|
|
312
|
+
const existingCols = new Set(getTableColumns(db, model).map((c) => c.name));
|
|
313
|
+
for (const [key, value] of Object.entries(record)) {
|
|
314
|
+
if (!existingCols.has(key)) {
|
|
315
|
+
const colType = detectColumnType(value);
|
|
316
|
+
db.exec(`ALTER TABLE "${table}" ADD COLUMN "${key}" ${colType}`);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
function prepareValue(value) {
|
|
321
|
+
if (value === null || value === void 0) return null;
|
|
322
|
+
if (typeof value === "boolean") return value ? 1 : 0;
|
|
323
|
+
if (typeof value === "number") return value;
|
|
324
|
+
if (typeof value === "string") return value;
|
|
325
|
+
if (typeof value === "object") return JSON.stringify(value);
|
|
326
|
+
return String(value);
|
|
327
|
+
}
|
|
328
|
+
function upsertRecords(db, model, records, idField) {
|
|
329
|
+
if (records.length === 0) return 0;
|
|
330
|
+
const table = sanitizeTableName(model);
|
|
331
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
332
|
+
const existingCols = getTableColumns(db, model).map((c) => c.name);
|
|
333
|
+
const insertMany = db.transaction((recs) => {
|
|
334
|
+
let count = 0;
|
|
335
|
+
for (const record of recs) {
|
|
336
|
+
const recordKeys = Object.keys(record);
|
|
337
|
+
const newKeys = recordKeys.filter((k) => !existingCols.includes(k));
|
|
338
|
+
if (newKeys.length > 0) {
|
|
339
|
+
for (const key of newKeys) {
|
|
340
|
+
const colType = detectColumnType(record[key]);
|
|
341
|
+
db.exec(`ALTER TABLE "${table}" ADD COLUMN "${key}" ${colType}`);
|
|
342
|
+
existingCols.push(key);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
const fullRecord = { ...record, _synced_at: now };
|
|
346
|
+
const cols = Object.keys(fullRecord).filter((k) => existingCols.includes(k) || k === "_synced_at");
|
|
347
|
+
const quotedCols = cols.map((c) => `"${c}"`).join(", ");
|
|
348
|
+
const placeholders = cols.map(() => "?").join(", ");
|
|
349
|
+
const values = cols.map((c) => prepareValue(fullRecord[c]));
|
|
350
|
+
const safeIdField = idField.replace(/"/g, '""');
|
|
351
|
+
const updateCols = cols.filter((c) => c !== idField).map((c) => `"${c}" = excluded."${c}"`).join(", ");
|
|
352
|
+
db.prepare(
|
|
353
|
+
`INSERT INTO "${table}" (${quotedCols}) VALUES (${placeholders}) ON CONFLICT("${safeIdField}") DO UPDATE SET ${updateCols}`
|
|
354
|
+
).run(...values);
|
|
355
|
+
count++;
|
|
356
|
+
}
|
|
357
|
+
return count;
|
|
358
|
+
});
|
|
359
|
+
return insertMany(records);
|
|
360
|
+
}
|
|
361
|
+
function deleteRecords(db, model, where, params) {
|
|
362
|
+
const table = sanitizeTableName(model);
|
|
363
|
+
const result = db.prepare(`DELETE FROM "${table}" WHERE ${where}`).run(...params);
|
|
364
|
+
return result.changes;
|
|
365
|
+
}
|
|
366
|
+
function dropTable(db, model) {
|
|
367
|
+
const table = sanitizeTableName(model);
|
|
368
|
+
db.exec(`DROP TABLE IF EXISTS "${table}_fts"`);
|
|
369
|
+
db.exec(`DROP TRIGGER IF EXISTS "${table}_ai"`);
|
|
370
|
+
db.exec(`DROP TRIGGER IF EXISTS "${table}_au"`);
|
|
371
|
+
db.exec(`DROP TABLE IF EXISTS "${table}"`);
|
|
372
|
+
}
|
|
373
|
+
function listTables(db) {
|
|
374
|
+
const rows = db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE '%_fts%' AND name NOT LIKE 'sqlite_%'`).all();
|
|
375
|
+
return rows.map((r) => r.name);
|
|
376
|
+
}
|
|
377
|
+
function countRecords(db, model) {
|
|
378
|
+
const table = sanitizeTableName(model);
|
|
379
|
+
if (!tableExists(db, model)) return 0;
|
|
380
|
+
const row = db.prepare(`SELECT COUNT(*) as count FROM "${table}"`).get();
|
|
381
|
+
return row.count;
|
|
382
|
+
}
|
|
383
|
+
function deleteDatabase(platform) {
|
|
384
|
+
const dbPath = getDatabasePath(platform);
|
|
385
|
+
if (fs3.existsSync(dbPath)) fs3.unlinkSync(dbPath);
|
|
386
|
+
if (fs3.existsSync(dbPath + "-wal")) fs3.unlinkSync(dbPath + "-wal");
|
|
387
|
+
if (fs3.existsSync(dbPath + "-shm")) fs3.unlinkSync(dbPath + "-shm");
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// src/commands/mem/migrate.ts
|
|
391
|
+
async function memMigrateCommand(flags) {
|
|
392
|
+
requireMemoryInit();
|
|
393
|
+
const backend = await getBackend();
|
|
394
|
+
const platforms = flags.platform ? [flags.platform] : listSyncedPlatforms();
|
|
395
|
+
if (platforms.length === 0) {
|
|
396
|
+
okJson({ status: "noop", reason: "no legacy .one/sync/data/*.db files found" });
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
const canPredict = backend.capabilities().rawSql && typeof backend.raw === "function";
|
|
400
|
+
const keyExists = async (keys) => {
|
|
401
|
+
if (!canPredict || keys.length === 0) return false;
|
|
402
|
+
try {
|
|
403
|
+
const res = await backend.raw(
|
|
404
|
+
`SELECT 1 FROM mem_records WHERE keys && $1::text[] LIMIT 1`,
|
|
405
|
+
[keys]
|
|
406
|
+
);
|
|
407
|
+
return res.rowCount > 0;
|
|
408
|
+
} catch {
|
|
409
|
+
return false;
|
|
410
|
+
}
|
|
411
|
+
};
|
|
412
|
+
const reports = [];
|
|
413
|
+
for (const platform of platforms) {
|
|
414
|
+
const db = await openDatabase(platform, { readonly: true });
|
|
415
|
+
try {
|
|
416
|
+
const tables = listTables(db).filter((t) => !t.endsWith("_fts"));
|
|
417
|
+
for (const model of tables) {
|
|
418
|
+
const total = countRecords(db, model);
|
|
419
|
+
const rows = db.prepare(`SELECT * FROM "${model}"`).all();
|
|
420
|
+
const report = {
|
|
421
|
+
platform,
|
|
422
|
+
model,
|
|
423
|
+
rowsSeen: total,
|
|
424
|
+
inserted: 0,
|
|
425
|
+
updated: 0,
|
|
426
|
+
mergedByIdentity: 0,
|
|
427
|
+
skipped: 0,
|
|
428
|
+
skippedUnresolvedId: 0,
|
|
429
|
+
skippedError: 0
|
|
430
|
+
};
|
|
431
|
+
const profile = readProfile(platform, model);
|
|
432
|
+
const builtin = loadBuiltinProfile(platform, model);
|
|
433
|
+
const idField = profile?.idField;
|
|
434
|
+
const healEnabled = flags.heal !== false;
|
|
435
|
+
const builtinIdField = healEnabled && typeof builtin?.idField === "string" && builtin.idField !== idField ? builtin.idField : void 0;
|
|
436
|
+
const builtinNewer = isBuiltinNewerThanInstalled(platform, model);
|
|
437
|
+
const identityKey = profile?.identityKey;
|
|
438
|
+
const type = `${platform}/${model}`;
|
|
439
|
+
try {
|
|
440
|
+
report.activeBefore = await backend.count(type, { status: "active" });
|
|
441
|
+
} catch {
|
|
442
|
+
}
|
|
443
|
+
const identityMap = await buildIdentityMap(backend, type, identityKey);
|
|
444
|
+
for (const row of rows) {
|
|
445
|
+
const hydrated = reviveStringifiedJson(row);
|
|
446
|
+
let activeIdField = idField;
|
|
447
|
+
let externalRaw = idField ? getByDotPath(hydrated, idField) : void 0;
|
|
448
|
+
if (builtinIdField && (externalRaw === void 0 || externalRaw === null || externalRaw === "" || typeof externalRaw === "object")) {
|
|
449
|
+
const healed2 = getByDotPath(hydrated, builtinIdField);
|
|
450
|
+
if (healed2 !== void 0 && healed2 !== null && healed2 !== "" && typeof healed2 !== "object") {
|
|
451
|
+
activeIdField = builtinIdField;
|
|
452
|
+
externalRaw = healed2;
|
|
453
|
+
if (!report.healedIdField) {
|
|
454
|
+
report.healedIdField = builtinIdField;
|
|
455
|
+
report.originalIdField = idField;
|
|
456
|
+
report.builtinNewerThanInstalled = builtinNewer;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
if (!activeIdField || externalRaw === void 0 || externalRaw === null || externalRaw === "" || typeof externalRaw === "object") {
|
|
461
|
+
report.skippedUnresolvedId++;
|
|
462
|
+
report.skipped++;
|
|
463
|
+
if (!isAgentMode() && report.skippedUnresolvedId <= 3) {
|
|
464
|
+
const hint = !activeIdField ? "no profile found" : typeof externalRaw === "object" ? `idField "${activeIdField}" resolved to a nested object (stringifies to [object Object]) \u2014 profile needs a dotted path` : `idField "${activeIdField}" resolved to undefined/empty`;
|
|
465
|
+
process.stderr.write(` skip row ${report.skippedUnresolvedId}: ${hint}
|
|
466
|
+
`);
|
|
467
|
+
}
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
const external = String(externalRaw);
|
|
471
|
+
const sourceKey = `${platform}/${model}:${external}`;
|
|
472
|
+
const keys = [sourceKey];
|
|
473
|
+
let identityValueNorm = null;
|
|
474
|
+
if (identityKey) {
|
|
475
|
+
const idValue = getByDotPath(hydrated, identityKey);
|
|
476
|
+
if (idValue !== void 0 && idValue !== null && idValue !== "" && typeof idValue !== "object") {
|
|
477
|
+
identityValueNorm = String(idValue).toLowerCase().trim();
|
|
478
|
+
keys.push(`${deriveIdentityPrefix(identityKey)}:${identityValueNorm}`);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
let mergeTarget = false;
|
|
482
|
+
if (identityValueNorm && identityMap.has(identityValueNorm)) {
|
|
483
|
+
const hit = identityMap.get(identityValueNorm);
|
|
484
|
+
for (const k of hit.keys) keys.push(k);
|
|
485
|
+
mergeTarget = true;
|
|
486
|
+
}
|
|
487
|
+
const data = { ...hydrated };
|
|
488
|
+
for (const k of Object.keys(data)) {
|
|
489
|
+
if (k.startsWith("_") || k === "rowid") delete data[k];
|
|
490
|
+
}
|
|
491
|
+
if (flags.dryRun) {
|
|
492
|
+
if (await keyExists(keys)) {
|
|
493
|
+
if (mergeTarget) report.mergedByIdentity++;
|
|
494
|
+
else report.updated++;
|
|
495
|
+
} else {
|
|
496
|
+
report.inserted++;
|
|
497
|
+
}
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
try {
|
|
501
|
+
const res = await upsertRecord(
|
|
502
|
+
{
|
|
503
|
+
type,
|
|
504
|
+
data,
|
|
505
|
+
keys,
|
|
506
|
+
sources: {
|
|
507
|
+
[sourceKey]: {
|
|
508
|
+
last_synced_at: row._synced_at ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
509
|
+
metadata: { migrated_from: "legacy-sqlite" }
|
|
510
|
+
}
|
|
511
|
+
},
|
|
512
|
+
tags: ["synced", platform],
|
|
513
|
+
embed: false
|
|
514
|
+
},
|
|
515
|
+
// `replace: true` — legacy .db is the declared source of
|
|
516
|
+
// truth for the run. Without this, identity-merged rows
|
|
517
|
+
// keep their garbage stringified-JSON `data` shape from
|
|
518
|
+
// the pre-fix migrate because the merge would union the
|
|
519
|
+
// hydrated payload with the stringified one.
|
|
520
|
+
{ embed: false, replace: true }
|
|
521
|
+
);
|
|
522
|
+
if (res.action === "inserted") report.inserted++;
|
|
523
|
+
else if (mergeTarget) report.mergedByIdentity++;
|
|
524
|
+
else report.updated++;
|
|
525
|
+
} catch (err) {
|
|
526
|
+
report.skippedError++;
|
|
527
|
+
report.skipped++;
|
|
528
|
+
if (!isAgentMode()) {
|
|
529
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
530
|
+
process.stderr.write(` skip ${sourceKey}: ${msg}
|
|
531
|
+
`);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
try {
|
|
536
|
+
report.activeAfter = await backend.count(type, { status: "active" });
|
|
537
|
+
} catch {
|
|
538
|
+
}
|
|
539
|
+
reports.push(report);
|
|
540
|
+
if (!isAgentMode()) {
|
|
541
|
+
const skipDetail = report.skipped > 0 ? ` skipped (${report.skippedUnresolvedId} unresolved id, ${report.skippedError} errors)` : " skipped";
|
|
542
|
+
const mergedSuffix = report.mergedByIdentity > 0 ? `, ${report.mergedByIdentity} merged by identity` : "";
|
|
543
|
+
process.stderr.write(
|
|
544
|
+
` ${platform}/${model}: ${report.inserted} inserted, ${report.updated} updated${mergedSuffix}, ${report.skipped}${skipDetail} (${report.rowsSeen} seen)
|
|
545
|
+
`
|
|
546
|
+
);
|
|
547
|
+
if (report.skippedUnresolvedId === report.rowsSeen && report.rowsSeen > 0) {
|
|
548
|
+
process.stderr.write(
|
|
549
|
+
` \u26A0 Every row skipped \u2014 profile for ${platform}/${model} is missing or its idField doesn't resolve on legacy rows.
|
|
550
|
+
`
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
if (report.healedIdField) {
|
|
554
|
+
process.stderr.write(
|
|
555
|
+
` \u21AA Self-healed stale idField "${idField}" \u2192 "${report.healedIdField}" for ${type} (installed profile is out of date \u2014 refresh with \`one sync init ${platform} ${model} --force\`).
|
|
556
|
+
`
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
const before = report.activeBefore ?? 0;
|
|
560
|
+
const after = report.activeAfter ?? 0;
|
|
561
|
+
const growth = after - before;
|
|
562
|
+
const expectedMaxGrowth = report.inserted;
|
|
563
|
+
if (before > 10 && growth > expectedMaxGrowth + 2) {
|
|
564
|
+
process.stderr.write(
|
|
565
|
+
` \u26A0 Memory for ${type} grew from ${before} \u2192 ${after} (+${growth}) but only ${report.inserted} new inserts were logged. Likely a re-migrate under a different idField created duplicates. Inspect with:
|
|
566
|
+
one mem sql "SELECT jsonb_typeof(data->'id') t, COUNT(*) FROM mem_records WHERE type='${type}' GROUP BY 1"
|
|
567
|
+
Rows where t='string' are the pre-fix cohort and can be dropped.
|
|
568
|
+
`
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
} finally {
|
|
574
|
+
try {
|
|
575
|
+
db.close();
|
|
576
|
+
} catch {
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
const dataDir = path4.join(".one", "sync", "data");
|
|
581
|
+
let cleanupFiles = [];
|
|
582
|
+
if (flags.cleanup && fs4.existsSync(dataDir)) {
|
|
583
|
+
cleanupFiles = fs4.readdirSync(dataDir).map((f) => path4.join(dataDir, f));
|
|
584
|
+
}
|
|
585
|
+
let cleanupDeleted = false;
|
|
586
|
+
if (flags.cleanup) {
|
|
587
|
+
if (!flags.dryRun) {
|
|
588
|
+
const confirm2 = flags.yes ?? await p.confirm({
|
|
589
|
+
message: `Delete ${cleanupFiles.length} legacy .one/sync/data/* file(s) now?`,
|
|
590
|
+
initialValue: false
|
|
591
|
+
});
|
|
592
|
+
if (p.isCancel(confirm2) || !confirm2) {
|
|
593
|
+
note("Leaving legacy files in place. Run with --cleanup --yes to force.", "one mem migrate");
|
|
594
|
+
} else {
|
|
595
|
+
for (const full of cleanupFiles) {
|
|
596
|
+
try {
|
|
597
|
+
fs4.unlinkSync(full);
|
|
598
|
+
} catch {
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
cleanupDeleted = true;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
const healed = reports.filter((r) => r.healedIdField).map((r) => {
|
|
606
|
+
let note2;
|
|
607
|
+
if (r.builtinNewerThanInstalled === true) {
|
|
608
|
+
note2 = "Installed profile was older than the built-in; safely healed against the current built-in. Run `one sync init <platform> <model> --force` to update the installed profile permanently.";
|
|
609
|
+
} else if (r.builtinNewerThanInstalled === false) {
|
|
610
|
+
note2 = "Installed profile is newer than the built-in but its idField did not resolve on legacy rows. Healing was applied because the data required it, but you may have intentional customizations \u2014 verify the result and consider `one sync init <platform> <model> --force` to refresh.";
|
|
611
|
+
} else {
|
|
612
|
+
note2 = "Healed using the built-in profile, but the mtime comparison between the installed and built-in profiles could not be made. Verify the result and consider `one sync init <platform> <model> --force` to refresh the installed profile.";
|
|
613
|
+
}
|
|
614
|
+
return {
|
|
615
|
+
type: `${r.platform}/${r.model}`,
|
|
616
|
+
originalIdField: r.originalIdField ?? null,
|
|
617
|
+
healedTo: r.healedIdField,
|
|
618
|
+
builtinNewerThanInstalled: r.builtinNewerThanInstalled ?? null,
|
|
619
|
+
note: note2
|
|
620
|
+
};
|
|
621
|
+
});
|
|
622
|
+
okJson({
|
|
623
|
+
status: flags.dryRun ? "dry-run" : "ok",
|
|
624
|
+
reports,
|
|
625
|
+
totals: {
|
|
626
|
+
inserted: reports.reduce((a, r) => a + r.inserted, 0),
|
|
627
|
+
updated: reports.reduce((a, r) => a + r.updated, 0),
|
|
628
|
+
mergedByIdentity: reports.reduce((a, r) => a + r.mergedByIdentity, 0),
|
|
629
|
+
skipped: reports.reduce((a, r) => a + r.skipped, 0),
|
|
630
|
+
skippedUnresolvedId: reports.reduce((a, r) => a + r.skippedUnresolvedId, 0),
|
|
631
|
+
skippedError: reports.reduce((a, r) => a + r.skippedError, 0),
|
|
632
|
+
seen: reports.reduce((a, r) => a + r.rowsSeen, 0)
|
|
633
|
+
},
|
|
634
|
+
...healed.length > 0 ? { healedProfiles: healed } : {},
|
|
635
|
+
...flags.cleanup ? {
|
|
636
|
+
cleanup: {
|
|
637
|
+
files: cleanupFiles,
|
|
638
|
+
deleted: cleanupDeleted,
|
|
639
|
+
...flags.dryRun ? { dryRun: true } : {}
|
|
640
|
+
}
|
|
641
|
+
} : {}
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
async function buildIdentityMap(backend, type, identityKey) {
|
|
645
|
+
const map = /* @__PURE__ */ new Map();
|
|
646
|
+
if (!identityKey || !backend.capabilities().rawSql || typeof backend.raw !== "function") {
|
|
647
|
+
return map;
|
|
648
|
+
}
|
|
649
|
+
const jsonbExpr = dotPathToJsonbExpr(identityKey);
|
|
650
|
+
if (!jsonbExpr) return map;
|
|
651
|
+
try {
|
|
652
|
+
const res = await backend.raw(
|
|
653
|
+
`SELECT id, keys, (${jsonbExpr}) AS ident
|
|
654
|
+
FROM mem_records
|
|
655
|
+
WHERE type = $1 AND status = 'active'`,
|
|
656
|
+
[type]
|
|
657
|
+
);
|
|
658
|
+
for (const row of res.rows) {
|
|
659
|
+
const ident = row.ident;
|
|
660
|
+
if (ident === null || ident === void 0 || typeof ident !== "string") continue;
|
|
661
|
+
const norm = ident.toLowerCase().trim();
|
|
662
|
+
if (!norm) continue;
|
|
663
|
+
if (!map.has(norm)) {
|
|
664
|
+
map.set(norm, {
|
|
665
|
+
id: String(row.id),
|
|
666
|
+
keys: Array.isArray(row.keys) ? row.keys : []
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
} catch {
|
|
671
|
+
}
|
|
672
|
+
return map;
|
|
673
|
+
}
|
|
674
|
+
function dotPathToJsonbExpr(dotPath) {
|
|
675
|
+
const parts = dotPath.split(".").flatMap((p2) => {
|
|
676
|
+
const match = p2.match(/^([^[]+)\[(\d+)\]$/);
|
|
677
|
+
if (match) return [match[1], match[2]];
|
|
678
|
+
return [p2];
|
|
679
|
+
});
|
|
680
|
+
if (parts.length === 0) return null;
|
|
681
|
+
let expr = "data";
|
|
682
|
+
for (let i = 0; i < parts.length; i++) {
|
|
683
|
+
const seg = parts[i];
|
|
684
|
+
const isLast = i === parts.length - 1;
|
|
685
|
+
if (/^\d+$/.test(seg)) {
|
|
686
|
+
expr += (isLast ? "->>" : "->") + seg;
|
|
687
|
+
} else if (/^\w+$/.test(seg)) {
|
|
688
|
+
expr += (isLast ? "->>'" : "->'") + seg + "'";
|
|
689
|
+
} else {
|
|
690
|
+
return null;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
return expr;
|
|
694
|
+
}
|
|
695
|
+
function reviveStringifiedJson(row) {
|
|
696
|
+
const out = { ...row };
|
|
697
|
+
for (const [key, value] of Object.entries(out)) {
|
|
698
|
+
if (typeof value !== "string") continue;
|
|
699
|
+
const trimmed = value.trim();
|
|
700
|
+
if (!(trimmed.startsWith("{") || trimmed.startsWith("["))) continue;
|
|
701
|
+
try {
|
|
702
|
+
out[key] = JSON.parse(trimmed);
|
|
703
|
+
} catch {
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
return out;
|
|
707
|
+
}
|
|
708
|
+
function isBuiltinNewerThanInstalled(platform, model) {
|
|
709
|
+
try {
|
|
710
|
+
const installed = path4.join(".one", "sync", "profiles", `${platform}_${model}.json`);
|
|
711
|
+
if (!fs4.existsSync(installed)) return void 0;
|
|
712
|
+
const installedStat = fs4.statSync(installed);
|
|
713
|
+
const builtinPath = resolveBuiltinProfilePath(platform, model);
|
|
714
|
+
if (!builtinPath) return void 0;
|
|
715
|
+
const builtinStat = fs4.statSync(builtinPath);
|
|
716
|
+
return builtinStat.mtimeMs > installedStat.mtimeMs;
|
|
717
|
+
} catch {
|
|
718
|
+
return void 0;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
function resolveBuiltinProfilePath(platform, model) {
|
|
722
|
+
const dir = getProfilesDir();
|
|
723
|
+
if (!dir) return null;
|
|
724
|
+
const candidate = path4.join(dir, platform, `${model}.json`);
|
|
725
|
+
return fs4.existsSync(candidate) ? candidate : null;
|
|
726
|
+
}
|
|
727
|
+
function deriveIdentityPrefix(path5) {
|
|
728
|
+
const lower = path5.toLowerCase();
|
|
729
|
+
if (lower.includes("email")) return "email";
|
|
730
|
+
if (lower.includes("phone")) return "phone";
|
|
731
|
+
if (lower.includes("domain")) return "domain";
|
|
732
|
+
return "id";
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
export {
|
|
736
|
+
readProfile,
|
|
737
|
+
writeProfile,
|
|
738
|
+
resolveProfileConnectionKey,
|
|
739
|
+
writeDraftProfile,
|
|
740
|
+
listProfiles,
|
|
741
|
+
generateTemplate,
|
|
742
|
+
loadSqlite,
|
|
743
|
+
isSqliteAvailable,
|
|
744
|
+
openDatabase,
|
|
745
|
+
getDatabaseSize,
|
|
746
|
+
sanitizeTableName,
|
|
747
|
+
tableExists,
|
|
748
|
+
ensureTable,
|
|
749
|
+
rebuildFtsIndex,
|
|
750
|
+
evolveSchema,
|
|
751
|
+
upsertRecords,
|
|
752
|
+
deleteRecords,
|
|
753
|
+
dropTable,
|
|
754
|
+
countRecords,
|
|
755
|
+
deleteDatabase,
|
|
756
|
+
loadBuiltinProfile,
|
|
757
|
+
listBuiltinProfiles,
|
|
758
|
+
memMigrateCommand,
|
|
759
|
+
buildIdentityMap,
|
|
760
|
+
dotPathToJsonbExpr,
|
|
761
|
+
reviveStringifiedJson
|
|
762
|
+
};
|