@vxil/cli 0.27.0 → 0.28.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/vxil-main.js +291 -52
- package/package.json +1 -1
package/dist/vxil-main.js
CHANGED
|
@@ -5586,7 +5586,7 @@ function lowerAllTriggerBindings(def, name) {
|
|
|
5586
5586
|
var CONFIG_FILENAMES = ["vxil.config.ts", "vxil.config.mjs", "vxil.config.js"];
|
|
5587
5587
|
var VXIL_CONFIG_PKG_VERSION = "0.20.0";
|
|
5588
5588
|
var VXIL_SDK_PKG_VERSION = "0.27.0";
|
|
5589
|
-
var VXIL_CLI_PKG_VERSION = "0.
|
|
5589
|
+
var VXIL_CLI_PKG_VERSION = "0.28.0";
|
|
5590
5590
|
function ensureScaffoldPackageJson(cwd, opts = {}) {
|
|
5591
5591
|
const file = resolve(cwd, "package.json");
|
|
5592
5592
|
const wanted = {
|
|
@@ -16805,6 +16805,8 @@ var EXTRA_KNOWN_FLAGS = [
|
|
|
16805
16805
|
"dir",
|
|
16806
16806
|
"file-links",
|
|
16807
16807
|
"from-provider",
|
|
16808
|
+
"include",
|
|
16809
|
+
"owner",
|
|
16808
16810
|
"include-empty",
|
|
16809
16811
|
"include-password-hashes",
|
|
16810
16812
|
"with-password-hashes",
|
|
@@ -17077,6 +17079,15 @@ import { dirname as dirname4, resolve as resolve5 } from "node:path";
|
|
|
17077
17079
|
import { appendFileSync, existsSync as existsSync5, mkdirSync as mkdirSync3, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "node:fs";
|
|
17078
17080
|
import { basename as basename2, resolve as resolve4 } from "node:path";
|
|
17079
17081
|
|
|
17082
|
+
// src/migrate/types.ts
|
|
17083
|
+
var OWNER_NONE = "none";
|
|
17084
|
+
function hasOwnerField(m) {
|
|
17085
|
+
return m.ownerField !== void 0 && m.ownerField !== OWNER_NONE;
|
|
17086
|
+
}
|
|
17087
|
+
function newMigrateState() {
|
|
17088
|
+
return { version: 1, schemaPushed: false, cursors: {}, idMap: {} };
|
|
17089
|
+
}
|
|
17090
|
+
|
|
17080
17091
|
// src/migrate/inference.ts
|
|
17081
17092
|
var VECTOR_DIMS = [256, 384, 512, 768, 1024, 1536, 3072];
|
|
17082
17093
|
var SOURCE_ID_FIELD = "source_id";
|
|
@@ -17160,7 +17171,92 @@ function parseNumeric(pgType) {
|
|
|
17160
17171
|
const m = /^numeric\s*\(\s*(\d+)\s*,\s*(\d+)\s*\)$/i.exec(pgType.trim());
|
|
17161
17172
|
return m ? { precision: Number(m[1]), scale: Number(m[2]) } : void 0;
|
|
17162
17173
|
}
|
|
17163
|
-
var
|
|
17174
|
+
var PAYMENTS_TABLE_WORDS = [
|
|
17175
|
+
"purchase",
|
|
17176
|
+
"payment",
|
|
17177
|
+
"invoice",
|
|
17178
|
+
"credit",
|
|
17179
|
+
// en (the original pattern)
|
|
17180
|
+
"pago",
|
|
17181
|
+
"compra",
|
|
17182
|
+
"factura",
|
|
17183
|
+
"suscripci",
|
|
17184
|
+
// es
|
|
17185
|
+
"pagamento",
|
|
17186
|
+
"fatura",
|
|
17187
|
+
"assinatura",
|
|
17188
|
+
// pt
|
|
17189
|
+
"paiement",
|
|
17190
|
+
"achat",
|
|
17191
|
+
"facture",
|
|
17192
|
+
"abonnement",
|
|
17193
|
+
"cr\xE9dit",
|
|
17194
|
+
// fr (abonnement: de too)
|
|
17195
|
+
"zahlung",
|
|
17196
|
+
"kauf",
|
|
17197
|
+
"rechnung",
|
|
17198
|
+
"guthaben",
|
|
17199
|
+
// de
|
|
17200
|
+
"odeme",
|
|
17201
|
+
"\xF6deme",
|
|
17202
|
+
"abonelik",
|
|
17203
|
+
"kredi",
|
|
17204
|
+
// tr
|
|
17205
|
+
"pembayaran",
|
|
17206
|
+
"pembelian",
|
|
17207
|
+
"tagihan",
|
|
17208
|
+
"langganan",
|
|
17209
|
+
// id
|
|
17210
|
+
"oplat",
|
|
17211
|
+
"platezh",
|
|
17212
|
+
"\u043F\u043B\u0430\u0442\u0435\u0436",
|
|
17213
|
+
"\u043F\u043B\u0430\u0442\u0451\u0436",
|
|
17214
|
+
"\u043E\u043F\u043B\u0430\u0442",
|
|
17215
|
+
"\u043F\u043E\u043A\u0443\u043F\u043A",
|
|
17216
|
+
"\u0441\u0447\u0435\u0442",
|
|
17217
|
+
"\u0441\u0447\u0451\u0442",
|
|
17218
|
+
"\u043F\u043E\u0434\u043F\u0438\u0441\u043A",
|
|
17219
|
+
"\u043A\u0440\u0435\u0434\u0438\u0442",
|
|
17220
|
+
// ru
|
|
17221
|
+
"\u062F\u0641\u0639",
|
|
17222
|
+
"\u0645\u062F\u0641\u0648\u0639",
|
|
17223
|
+
"\u0641\u0627\u062A\u0648\u0631",
|
|
17224
|
+
"\u0627\u0634\u062A\u0631\u0627\u0643",
|
|
17225
|
+
"\u0634\u0631\u0627\u0621",
|
|
17226
|
+
"\u0631\u0635\u064A\u062F",
|
|
17227
|
+
// ar
|
|
17228
|
+
"\u0627\u062F\u0627\u0626\u06CC\u06AF\u06CC",
|
|
17229
|
+
"\u062E\u0631\u06CC\u062F\u0627\u0631\u06CC",
|
|
17230
|
+
"\u0627\u0646\u0648\u0627\u0626\u0633",
|
|
17231
|
+
"\u0631\u06A9\u0646\u06CC\u062A",
|
|
17232
|
+
// ur
|
|
17233
|
+
"\u092D\u0941\u0917\u0924\u093E\u0928",
|
|
17234
|
+
"\u0916\u0930\u0940\u0926",
|
|
17235
|
+
"\u091A\u093E\u0932\u093E\u0928",
|
|
17236
|
+
"\u0938\u0926\u0938\u094D\u092F\u0924\u093E",
|
|
17237
|
+
// hi
|
|
17238
|
+
"\u09AA\u09C7\u09AE\u09C7\u09A8\u09CD\u099F",
|
|
17239
|
+
"\u0985\u09B0\u09CD\u09A5\u09AA\u09CD\u09B0\u09A6\u09BE\u09A8",
|
|
17240
|
+
"\u0995\u09CD\u09B0\u09AF\u09BC",
|
|
17241
|
+
"\u099A\u09BE\u09B2\u09BE\u09A8",
|
|
17242
|
+
// bn
|
|
17243
|
+
"\u652F\u4ED8",
|
|
17244
|
+
"\u4ED8\u6B3E",
|
|
17245
|
+
"\u8D2D\u4E70",
|
|
17246
|
+
"\u8CFC\u8CB7",
|
|
17247
|
+
"\u53D1\u7968",
|
|
17248
|
+
"\u767C\u7968",
|
|
17249
|
+
"\u8BA2\u9605",
|
|
17250
|
+
"\u8A02\u95B1",
|
|
17251
|
+
"\u5145\u503C",
|
|
17252
|
+
// zh
|
|
17253
|
+
"\uACB0\uC81C",
|
|
17254
|
+
"\uAD6C\uB9E4",
|
|
17255
|
+
"\uCCAD\uAD6C",
|
|
17256
|
+
"\uAD6C\uB3C5"
|
|
17257
|
+
// ko
|
|
17258
|
+
];
|
|
17259
|
+
var PAYMENTS_TABLE_RE = new RegExp(PAYMENTS_TABLE_WORDS.join("|"), "iu");
|
|
17164
17260
|
var WEBHOOK_LOG_RE = /webhook/i;
|
|
17165
17261
|
var SUBSCRIPTION_TABLE_RE = /(subscription|entitlement)/i;
|
|
17166
17262
|
var PUSH_TOKEN_RE = /(push_token|device_token|fcm_token|apns|expo_token)/i;
|
|
@@ -17173,6 +17269,17 @@ function isAuthParent(parentTable, authTable) {
|
|
|
17173
17269
|
return !parentTable.includes(".") || !authTable.includes(".");
|
|
17174
17270
|
}
|
|
17175
17271
|
var OWNER_NAMES = ["user_id", "owner_id", "author_id"];
|
|
17272
|
+
var OWNER_LIKE_RE = new RegExp(
|
|
17273
|
+
"^(?:uid|(?:owner|author|creator|createdby|postedby|uploader|user|member|usuario|propietario|dueno|due\xF1o|autor|auteur|utilisateur|proprietaire|propri\xE9taire|benutzer|nutzer|besitzer|kullanici|kullan\u0131c\u0131|sahip|pengguna|pemilik|polzovatel|vladelets|usu\xE1rio|dono)(?:id|uuid|ref)?)$",
|
|
17274
|
+
"iu"
|
|
17275
|
+
);
|
|
17276
|
+
function isOwnerLikeName(column) {
|
|
17277
|
+
return OWNER_LIKE_RE.test(column.replace(/[_-]/g, ""));
|
|
17278
|
+
}
|
|
17279
|
+
function ownerLikeType(c) {
|
|
17280
|
+
if (c.pgType) return !["boolean", "bool", "json", "jsonb"].includes(pgBase(c.pgType)) && !/^(?:timestamp|date|time)/.test(pgBase(c.pgType));
|
|
17281
|
+
return c.inferredType === void 0 || c.inferredType === "string" || c.inferredType === "number";
|
|
17282
|
+
}
|
|
17176
17283
|
function ruleOwnerColumn(definition) {
|
|
17177
17284
|
const m = /auth\.uid\(\)(?:\s+AS\s+\w+\s*\))?\s*=\s*(?:[\w"]+\.)?"?([A-Za-z_]\w*)"?/i.exec(definition) ?? /(?:[\w"]+\.)?"?([A-Za-z_]\w*)"?\s*=\s*(?:\(\s*SELECT\s+)?auth\.uid\(\)/i.exec(definition);
|
|
17178
17285
|
return m?.[1];
|
|
@@ -17188,21 +17295,32 @@ function pickOwner(candidates) {
|
|
|
17188
17295
|
for (const name of OWNER_NAMES) if (candidates.includes(name)) return { column: name, ambiguous: false };
|
|
17189
17296
|
return { column: candidates[0], ambiguous: candidates.length > 1 };
|
|
17190
17297
|
}
|
|
17191
|
-
function detectOwner(table, source, authTable, opts) {
|
|
17298
|
+
function detectOwner(table, source, authTable, opts, userKeyed = /* @__PURE__ */ new Set()) {
|
|
17192
17299
|
const cols = new Set(table.columns.map((c) => c.name));
|
|
17193
17300
|
const override = opts.owners?.[table.name];
|
|
17194
|
-
if (override
|
|
17301
|
+
if (override === OWNER_NONE) return void 0;
|
|
17302
|
+
if (override && cols.has(override)) return { column: override, via: "--owner" };
|
|
17195
17303
|
for (const p of source.rlsPolicies ?? []) {
|
|
17196
17304
|
if (lastSeg(p.table) !== table.name) continue;
|
|
17197
17305
|
const col = ruleOwnerColumn(p.definition);
|
|
17198
17306
|
if (col && cols.has(col)) return { column: col, via: `row-level access rule${p.name ? ` '${p.name}'` : ""}` };
|
|
17199
17307
|
}
|
|
17200
|
-
const
|
|
17308
|
+
const userFkParent = (name) => {
|
|
17309
|
+
for (const fk of source.foreignKeys) {
|
|
17310
|
+
if (fk.childTable !== table.name || fk.childColumns.length !== 1 || fk.childColumns[0] !== name) continue;
|
|
17311
|
+
if (isAuthParent(fk.parentTable, authTable)) return authTable;
|
|
17312
|
+
const parent = lastSeg(fk.parentTable);
|
|
17313
|
+
if (parent !== table.name && userKeyed.has(parent)) return parent;
|
|
17314
|
+
}
|
|
17315
|
+
return void 0;
|
|
17316
|
+
};
|
|
17317
|
+
const fkCols = table.columns.map((c) => c.name).filter((name) => userFkParent(name) !== void 0);
|
|
17201
17318
|
if (fkCols.length > 0) {
|
|
17202
17319
|
const pick = pickOwner(fkCols);
|
|
17320
|
+
const parent = userFkParent(pick.column);
|
|
17203
17321
|
return {
|
|
17204
17322
|
column: pick.column,
|
|
17205
|
-
via: `FK \u2192 ${authTable}`,
|
|
17323
|
+
via: parent === authTable ? `FK \u2192 ${authTable}` : `FK \u2192 ${parent} (its id is the user id)`,
|
|
17206
17324
|
...pick.ambiguous || fkCols.length > 1 ? { ambiguousAmong: fkCols } : {}
|
|
17207
17325
|
};
|
|
17208
17326
|
}
|
|
@@ -17359,9 +17477,24 @@ function inferMapping(source, opts = {}) {
|
|
|
17359
17477
|
const vectorTargets = [];
|
|
17360
17478
|
const skipSet = new Set(opts.skipTables ?? []);
|
|
17361
17479
|
const paymentsSet = new Set(opts.paymentsTables ?? []);
|
|
17362
|
-
|
|
17363
|
-
|
|
17364
|
-
|
|
17480
|
+
const includeSet = new Set(opts.includeTables ?? []);
|
|
17481
|
+
const ownerOverrides = Object.keys(opts.owners ?? {});
|
|
17482
|
+
const findTable = (name) => source.tables.find((t) => t.name === name || `${t.schema ?? "public"}.${t.name}` === name);
|
|
17483
|
+
for (const [name, via] of [
|
|
17484
|
+
...[...skipSet].map((n) => [n, "--skip"]),
|
|
17485
|
+
...[...paymentsSet].map((n) => [n, "migrate/payments.json"]),
|
|
17486
|
+
...[...includeSet].map((n) => [n, "--include"]),
|
|
17487
|
+
...ownerOverrides.map((n) => [n, "--owner"])
|
|
17488
|
+
]) {
|
|
17489
|
+
if (!findTable(name)) warnings.push(`'${name}' (${via}) names no source table \u2014 it selects nothing`);
|
|
17490
|
+
}
|
|
17491
|
+
for (const name of includeSet) {
|
|
17492
|
+
if (skipSet.has(name)) warnings.push(`'${name}' is named by both --skip and --include \u2014 --skip wins`);
|
|
17493
|
+
}
|
|
17494
|
+
for (const [name, col] of Object.entries(opts.owners ?? {})) {
|
|
17495
|
+
const t = findTable(name);
|
|
17496
|
+
if (t && col !== OWNER_NONE && !t.columns.some((c) => c.name === col)) {
|
|
17497
|
+
warnings.push(`--owner ${name}=${col}: ${name} has no column '${col}' \u2014 ignored (columns: ${t.columns.map((c) => c.name).join(", ")})`);
|
|
17365
17498
|
}
|
|
17366
17499
|
}
|
|
17367
17500
|
const fksByChild = /* @__PURE__ */ new Map();
|
|
@@ -17388,10 +17521,17 @@ function inferMapping(source, opts = {}) {
|
|
|
17388
17521
|
for (const t of source.tables) {
|
|
17389
17522
|
const reasons = [];
|
|
17390
17523
|
let target;
|
|
17524
|
+
let confirm;
|
|
17391
17525
|
const qualified = `${t.schema ?? "public"}.${t.name}`;
|
|
17526
|
+
const included = includeSet.has(t.name) || includeSet.has(qualified);
|
|
17527
|
+
const nameSkip = SUBSCRIPTION_TABLE_RE.test(t.name) ? `its name matches the subscription/entitlement pattern (provider-derived state is never seeded)` : WEBHOOK_LOG_RE.test(t.name) ? `its name matches the webhook pattern (a provider webhook/event log \u2014 vxil keeps its own delivery log)` : void 0;
|
|
17392
17528
|
if (skipSet.has(t.name) || skipSet.has(qualified)) {
|
|
17393
17529
|
target = "skip";
|
|
17394
17530
|
reasons.push("skipped by option (--skip)");
|
|
17531
|
+
} else if (included && (t.extensionOwned || t.schema === "auth" || t.schema === "storage" || qualified === authTable || t.name === authTable)) {
|
|
17532
|
+
target = "skip";
|
|
17533
|
+
reasons.push(`--include ignored: ${qualified} is an auth, storage or extension table, never a cms collection`);
|
|
17534
|
+
warnings.push(`--include ${t.name}: an auth, storage or extension table is never loaded as a cms collection \u2014 ignored`);
|
|
17395
17535
|
} else if (t.extensionOwned) {
|
|
17396
17536
|
target = "skip";
|
|
17397
17537
|
reasons.push(`T8: ${qualified} belongs to an installed extension \u2014 the extension recreates it; not app data`);
|
|
@@ -17404,22 +17544,26 @@ function inferMapping(source, opts = {}) {
|
|
|
17404
17544
|
} else if (paymentsSet.has(t.name) || paymentsSet.has(qualified)) {
|
|
17405
17545
|
target = "payments-credits";
|
|
17406
17546
|
reasons.push(`T8: ${t.name} is listed in migrate/payments.json \u2192 payments credits via grantOnce (idempotent grant_key); never cms`);
|
|
17407
|
-
} else if (
|
|
17408
|
-
target = "skip";
|
|
17409
|
-
reasons.push(`subscription/entitlement table ${t.name}: provider-derived \u2014 never seeded`);
|
|
17410
|
-
residuals.push(`subscription/entitlement state (${t.name}): NOT seeded \u2014 rebuild from live provider webhooks after cutover`);
|
|
17411
|
-
} else if (WEBHOOK_LOG_RE.test(t.name)) {
|
|
17547
|
+
} else if (nameSkip !== void 0 && !included) {
|
|
17412
17548
|
target = "skip";
|
|
17413
|
-
|
|
17414
|
-
|
|
17549
|
+
confirm = `${t.name}: ${nameSkip} \u2014 not loaded. If it is app data, re-run plan with \`--include ${t.name}\`; \`--skip ${t.name}\` confirms the skip`;
|
|
17550
|
+
warnings.push(`SKIP (confirm): ${confirm}`);
|
|
17551
|
+
if (SUBSCRIPTION_TABLE_RE.test(t.name)) {
|
|
17552
|
+
reasons.push(`R1: skip (confirm): ${confirm}`);
|
|
17553
|
+
residuals.push(`subscription/entitlement state (${t.name}): NOT seeded \u2014 rebuild from live provider webhooks after cutover`);
|
|
17554
|
+
} else {
|
|
17555
|
+
reasons.push(`T8: skip (confirm): ${confirm} (to seed credits from it instead, list it in migrate/payments.json and re-run plan)`);
|
|
17556
|
+
}
|
|
17557
|
+
} else if (t.rowCountEstimate === 0 && !opts.includeEmpty && !included && policiesOn(t) + fkEdgesOn(t) === 0) {
|
|
17415
17558
|
target = "skip";
|
|
17416
17559
|
reasons.push("R1: 0 rows \u2014 likely dropped/legacy; skipped (confirm; --include-empty keeps it)");
|
|
17417
17560
|
} else {
|
|
17418
17561
|
target = "cms";
|
|
17419
17562
|
reasons.push(`R1: ${qualified} \u2192 cms collection '${toCollectionName(t.name)}'`);
|
|
17563
|
+
if (included && nameSkip !== void 0) reasons.push(`kept by --include (${nameSkip})`);
|
|
17420
17564
|
if (t.rowCountEstimate === 0) {
|
|
17421
|
-
if (opts.includeEmpty) {
|
|
17422
|
-
reasons.push(
|
|
17565
|
+
if (opts.includeEmpty || included) {
|
|
17566
|
+
reasons.push(`R1: 0 rows by the catalog estimate \u2014 kept (${included ? "--include" : "--include-empty"})`);
|
|
17423
17567
|
} else {
|
|
17424
17568
|
reasons.push("T1: 0 rows by the catalog estimate, but it carries row-level rules/FKs \u2014 kept");
|
|
17425
17569
|
warnings.push(
|
|
@@ -17433,20 +17577,32 @@ function inferMapping(source, opts = {}) {
|
|
|
17433
17577
|
);
|
|
17434
17578
|
}
|
|
17435
17579
|
}
|
|
17436
|
-
classified.push({ table: t, target, reasons });
|
|
17580
|
+
classified.push({ table: t, target, reasons, ...confirm !== void 0 ? { confirm } : {} });
|
|
17437
17581
|
}
|
|
17438
17582
|
const cmsByName = /* @__PURE__ */ new Map();
|
|
17439
17583
|
for (const c of classified) {
|
|
17440
17584
|
if (c.target === "cms") cmsByName.set(c.table.name, { table: c.table, collection: toCollectionName(c.table.name) });
|
|
17441
17585
|
}
|
|
17442
17586
|
const owners = /* @__PURE__ */ new Map();
|
|
17443
|
-
|
|
17587
|
+
let profileCandidates = [];
|
|
17588
|
+
for (let pass = 0; pass <= cmsByName.size; pass++) {
|
|
17589
|
+
const userKeyed = new Set(profileCandidates);
|
|
17590
|
+
owners.clear();
|
|
17591
|
+
const next = [];
|
|
17592
|
+
for (const { table } of cmsByName.values()) {
|
|
17593
|
+
const o = detectOwner(table, source, authTable, opts, userKeyed);
|
|
17594
|
+
if (!o) continue;
|
|
17595
|
+
owners.set(table.name, o);
|
|
17596
|
+
const pkCols = table.columns.filter((c) => c.isPk);
|
|
17597
|
+
if (pkCols.length === 1 && pkCols[0].name === o.column) next.push(table.name);
|
|
17598
|
+
}
|
|
17599
|
+
const stable = next.length === profileCandidates.length && next.every((n) => userKeyed.has(n));
|
|
17600
|
+
profileCandidates = next;
|
|
17601
|
+
if (stable) break;
|
|
17602
|
+
}
|
|
17444
17603
|
for (const { table } of cmsByName.values()) {
|
|
17445
|
-
const o =
|
|
17604
|
+
const o = owners.get(table.name);
|
|
17446
17605
|
if (!o) continue;
|
|
17447
|
-
owners.set(table.name, o);
|
|
17448
|
-
const pkCols = table.columns.filter((c) => c.isPk);
|
|
17449
|
-
if (pkCols.length === 1 && pkCols[0].name === o.column) profileCandidates.push(table.name);
|
|
17450
17606
|
if (o.ambiguousAmong) {
|
|
17451
17607
|
const preferred = OWNER_NAMES.includes(o.column);
|
|
17452
17608
|
const line = `T6: ${table.name} has ${o.ambiguousAmong.length} user-ref columns (${o.ambiguousAmong.join(", ")}) \u2014 owner '${o.column}'${preferred ? " (the preferred owner name)" : " is a GUESS"}; confirm ownerField in plan.json`;
|
|
@@ -17475,9 +17631,9 @@ function inferMapping(source, opts = {}) {
|
|
|
17475
17631
|
}
|
|
17476
17632
|
}
|
|
17477
17633
|
}
|
|
17478
|
-
for (const { table: t, target, reasons } of classified) {
|
|
17634
|
+
for (const { table: t, target, reasons, confirm } of classified) {
|
|
17479
17635
|
if (target !== "cms") {
|
|
17480
|
-
const owner2 = target === "payments-credits" ? detectOwner(t, source, authTable, opts) : void 0;
|
|
17636
|
+
const owner2 = target === "payments-credits" ? detectOwner(t, source, authTable, opts, new Set(profileCandidates)) : void 0;
|
|
17481
17637
|
const m = {
|
|
17482
17638
|
table: t.name,
|
|
17483
17639
|
target,
|
|
@@ -17485,7 +17641,8 @@ function inferMapping(source, opts = {}) {
|
|
|
17485
17641
|
// end_user_id / the grantOnce grant_key) — no id capture needed.
|
|
17486
17642
|
idRemap: "value-keyed",
|
|
17487
17643
|
fields: target === "users" ? authUserFields(t.columns.map((c) => c.name)) : [],
|
|
17488
|
-
reasons
|
|
17644
|
+
reasons,
|
|
17645
|
+
...confirm !== void 0 ? { confirm } : {}
|
|
17489
17646
|
};
|
|
17490
17647
|
const ref = target === "users" ? "id" : owner2?.column;
|
|
17491
17648
|
if (ref !== void 0) m.userRefColumn = ref;
|
|
@@ -17639,11 +17796,27 @@ function inferMapping(source, opts = {}) {
|
|
|
17639
17796
|
}
|
|
17640
17797
|
fields.push(field);
|
|
17641
17798
|
}
|
|
17799
|
+
if (!owner) {
|
|
17800
|
+
const candidates = t.columns.filter((c) => ownerLikeType(c) && (c.matchesUserIds === true || !fks.has(c.name) && isOwnerLikeName(c.name)));
|
|
17801
|
+
if (candidates.length > 0) {
|
|
17802
|
+
mapping.ownerCandidates = candidates.map((c) => c.name);
|
|
17803
|
+
const why = candidates.map((c) => `${c.name} (${isOwnerLikeName(c.name) ? "its name" : "its values are user ids"})`).join(", ");
|
|
17804
|
+
if (opts.owners?.[t.name] !== OWNER_NONE) {
|
|
17805
|
+
warnings.push(
|
|
17806
|
+
`OWNER (decide): ${t.name} has no owner rule or FK to the users, but ${why} may hold the row owner's user id \u2014 without an owner every end user can read every row. Re-run plan with \`--owner ${t.name}=${candidates[0].name}\` to scope each row to that user, or \`--owner ${t.name}=none\` to import it unscoped on purpose; \`schema --apply\` and \`data --execute\` refuse until then`
|
|
17807
|
+
);
|
|
17808
|
+
}
|
|
17809
|
+
}
|
|
17810
|
+
if (opts.owners?.[t.name] === OWNER_NONE) {
|
|
17811
|
+
mapping.ownerField = OWNER_NONE;
|
|
17812
|
+
mapping.reasons.push(`R6: no owner scoping \u2014 --owner ${t.name}=none (any caller holding cms scopes reads every row)`);
|
|
17813
|
+
}
|
|
17814
|
+
}
|
|
17642
17815
|
const tokenCols = t.columns.map((c) => c.name).filter((c) => PUSH_TOKEN_RE.test(c));
|
|
17643
17816
|
if (PUSH_TOKEN_RE.test(t.name) || tokenCols.length > 0) {
|
|
17644
17817
|
mapping.reasons.push(`T4: push device tokens${tokenCols.length ? ` (${tokenCols.join(", ")})` : ""} \u2014 copied as app data`);
|
|
17645
17818
|
residuals.push(
|
|
17646
|
-
`push device tokens (${t.name}${tokenCols.length ? `: ${tokenCols.join(", ")}` : ""}): copied as app data into cms '${collection}'${mapping
|
|
17819
|
+
`push device tokens (${t.name}${tokenCols.length ? `: ${tokenCols.join(", ")}` : ""}): copied as app data into cms '${collection}'${hasOwnerField(mapping) ? ` (owner-scoped on ${mapping.ownerField})` : ""} \u2014 vxil has no push provider; send through a function over egress (the push-notifications blueprint), or \`--skip ${t.name}\` and let clients re-enroll after cutover`
|
|
17647
17820
|
);
|
|
17648
17821
|
}
|
|
17649
17822
|
const change = detectChangeColumn(t, source);
|
|
@@ -17718,9 +17891,9 @@ function inferMapping(source, opts = {}) {
|
|
|
17718
17891
|
carried = `table not loaded as cms (${m?.target ?? "not in the plan"}) \u2014 nothing to carry`;
|
|
17719
17892
|
} else if (isOpenRule(p.definition)) {
|
|
17720
17893
|
carried = `open rule \u2014 nothing to carry (any caller holding cms scopes reads '${m.collection}')`;
|
|
17721
|
-
} else if (m
|
|
17894
|
+
} else if (hasOwnerField(m) && m.userRefColumn !== void 0 && ruleOwnerColumn(p.definition) === m.userRefColumn) {
|
|
17722
17895
|
carried = isSoleOwnerRule(p.definition, m.userRefColumn) ? `carried by ownerField '${m.ownerField}' of '${m.collection}'` : `partly carried by ownerField '${m.ownerField}' of '${m.collection}': the rest of the rule (an OR / AND / EXISTS branch or another column) is dropped \u2014 re-express it (a public read model, a gateway function on a server key, or a validate hook)`;
|
|
17723
|
-
} else if (m
|
|
17896
|
+
} else if (hasOwnerField(m)) {
|
|
17724
17897
|
carried = `NOT expressed by ownerField '${m.ownerField}' of '${m.collection}' \u2014 re-express it (a gateway function on a server key, a validate hook, or a public read model)`;
|
|
17725
17898
|
} else {
|
|
17726
17899
|
carried = `'${m.collection}' has no ownerField \u2014 re-express it (a gateway function on a server key; strictEndUserScope makes an unowned collection server-only)`;
|
|
@@ -17792,11 +17965,6 @@ function topoOrder(cmsNames, fks, profileTable, warnings) {
|
|
|
17792
17965
|
import { existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "node:fs";
|
|
17793
17966
|
import { dirname as dirname3 } from "node:path";
|
|
17794
17967
|
|
|
17795
|
-
// src/migrate/types.ts
|
|
17796
|
-
function newMigrateState() {
|
|
17797
|
-
return { version: 1, schemaPushed: false, cursors: {}, idMap: {} };
|
|
17798
|
-
}
|
|
17799
|
-
|
|
17800
17968
|
// src/migrate/loaders/delta.ts
|
|
17801
17969
|
import { createHash as createHash2 } from "node:crypto";
|
|
17802
17970
|
function canonical(v) {
|
|
@@ -18462,7 +18630,7 @@ function mappingToCollection(m) {
|
|
|
18462
18630
|
}
|
|
18463
18631
|
return {
|
|
18464
18632
|
fields,
|
|
18465
|
-
...m
|
|
18633
|
+
...hasOwnerField(m) ? { ownerField: m.ownerField } : {}
|
|
18466
18634
|
};
|
|
18467
18635
|
}
|
|
18468
18636
|
function planToCollections(plan) {
|
|
@@ -19772,12 +19940,21 @@ function emitResidualsMd(plan, sourceLabel) {
|
|
|
19772
19940
|
}
|
|
19773
19941
|
return lines.join("\n") + "\n";
|
|
19774
19942
|
}
|
|
19943
|
+
function undecidedOwners(plan, only) {
|
|
19944
|
+
const steps = only?.length ? new Set(only) : null;
|
|
19945
|
+
return plan.tables.filter((t) => t.target === "cms" && (t.ownerCandidates?.length ?? 0) > 0 && t.ownerField === void 0 && (!steps || steps.has("cms") || steps.has("vectors") || steps.has(t.table)));
|
|
19946
|
+
}
|
|
19947
|
+
function undecidedOwnersText(tables) {
|
|
19948
|
+
const list = tables.map((t) => `${t.table} (${t.ownerCandidates.join(", ")})`).join(", ");
|
|
19949
|
+
const first = tables[0];
|
|
19950
|
+
return `${tables.length} table(s) have an id-like column that may hold the row owner, and no owner decision: ${list}. Re-run \`vxil migrate plan \u2026 --owner ${first.table}=${first.ownerCandidates[0]}\` to scope each row to that user, or \`--owner ${first.table}=none\` to import it unscoped on purpose (or set "ownerField" in ${MIGRATE_DIR}/plan.json)`;
|
|
19951
|
+
}
|
|
19775
19952
|
function formatPlanSummary(plan) {
|
|
19776
19953
|
const rows = plan.tables.map((t) => [
|
|
19777
19954
|
t.table,
|
|
19778
|
-
t.target,
|
|
19955
|
+
t.confirm !== void 0 ? "skip (confirm)" : t.target,
|
|
19779
19956
|
t.target === "cms" ? t.collection ?? "" : "\u2014",
|
|
19780
|
-
t.ownerField ?? t.userRefColumn ?? "\u2014",
|
|
19957
|
+
t.ownerField ?? t.userRefColumn ?? (t.ownerCandidates?.length ? `? ${t.ownerCandidates.join("|")} (decide)` : "\u2014"),
|
|
19781
19958
|
t.target === "cms" || t.target === "users" ? String(t.fields.length) : "\u2014"
|
|
19782
19959
|
]);
|
|
19783
19960
|
const head = ["TABLE", "TARGET", "COLLECTION", "OWNER/USER-REF", "FIELDS"];
|
|
@@ -19870,6 +20047,9 @@ function loaderDeps({ api, cwd, log }, dryRun) {
|
|
|
19870
20047
|
async function runMigrateSchema(io, opts = {}) {
|
|
19871
20048
|
const plan = readPlanFile(io.cwd);
|
|
19872
20049
|
const apply = opts.apply === true;
|
|
20050
|
+
const undecided = undecidedOwners(plan);
|
|
20051
|
+
if (undecided.length && apply) throw new Error(`migrate schema --apply refused: ${undecidedOwnersText(undecided)} (nothing was written)`);
|
|
20052
|
+
if (undecided.length) io.log(` \u26A0 ${undecidedOwnersText(undecided)} \u2014 \`--apply\` refuses until then`);
|
|
19873
20053
|
const missing = apply ? await missingPlanFeatures(io.api, planFeatureNeeds(plan, { step: "schema" })) : [];
|
|
19874
20054
|
if (missing.length) throw new Error(`migrate schema --apply refused: ${missingFeaturesText(missing)} (nothing was written)`);
|
|
19875
20055
|
if (apply) ensureStateIgnored(io.cwd, io.log);
|
|
@@ -19913,6 +20093,9 @@ async function runMigrateData(io, opts = {}) {
|
|
|
19913
20093
|
io.log(` \u26A0 migrate/payments.json names '${table}' but plan.json maps it to ${m.target} \u2014 re-run \`vxil migrate plan\` to plan it as payments credits`);
|
|
19914
20094
|
}
|
|
19915
20095
|
}
|
|
20096
|
+
const undecided = undecidedOwners(plan, opts.only);
|
|
20097
|
+
if (undecided.length && execute) throw new Error(`migrate data --execute refused: ${undecidedOwnersText(undecided)} (nothing was written)`);
|
|
20098
|
+
if (undecided.length) io.log(` \u26A0 ${undecidedOwnersText(undecided)} \u2014 \`--execute\` refuses until then`);
|
|
19916
20099
|
const missing = execute ? await missingPlanFeatures(io.api, planFeatureNeeds(plan, {
|
|
19917
20100
|
step: "data",
|
|
19918
20101
|
...opts.only ? { only: opts.only } : {},
|
|
@@ -21575,6 +21758,19 @@ export default defineConfig({
|
|
|
21575
21758
|
// src/migrate/adapters/csv-json.ts
|
|
21576
21759
|
import { readFileSync as readFileSync7, readdirSync, existsSync as existsSync7, statSync } from "node:fs";
|
|
21577
21760
|
import { basename as basename3, extname, join as join3, resolve as resolve6 } from "node:path";
|
|
21761
|
+
|
|
21762
|
+
// src/migrate/adapters/identity.ts
|
|
21763
|
+
var IDENTITY_COLUMNS = ["id", "email", "display_name", "avatar_url", "raw_user_meta_data"];
|
|
21764
|
+
function splitIdentityColumns(columns) {
|
|
21765
|
+
const keep = columns.filter((c) => IDENTITY_COLUMNS.includes(c));
|
|
21766
|
+
return { keep, dropped: columns.filter((c) => !keep.includes(c)) };
|
|
21767
|
+
}
|
|
21768
|
+
function identityDroppedWarning(adapter, table, dropped) {
|
|
21769
|
+
if (dropped.length === 0) return void 0;
|
|
21770
|
+
return `${adapter}: the identity read of '${table}' carries only ${IDENTITY_COLUMNS.join(", ")} \u2014 not read (not on the identity allowlist): ${dropped.join(", ")}`;
|
|
21771
|
+
}
|
|
21772
|
+
|
|
21773
|
+
// src/migrate/adapters/csv-json.ts
|
|
21578
21774
|
function parseCsv(text) {
|
|
21579
21775
|
const src = text.charCodeAt(0) === 65279 ? text.slice(1) : text;
|
|
21580
21776
|
const rows = [];
|
|
@@ -21764,6 +21960,9 @@ var CsvJsonAdapter = class {
|
|
|
21764
21960
|
columns: cols,
|
|
21765
21961
|
hasPasswordHash: cols.some((c) => PASSWORDISH_RE.test(c))
|
|
21766
21962
|
};
|
|
21963
|
+
const dropped = identityDroppedWarning("csv-json", at.name, splitIdentityColumns(cols).dropped);
|
|
21964
|
+
if (dropped) warnings.push(dropped);
|
|
21965
|
+
this.markUserIdColumns(at, tables);
|
|
21767
21966
|
} else {
|
|
21768
21967
|
warnings.push(`csv-json: _schema.json authTable '${overrides.authTable}' has no matching file`);
|
|
21769
21968
|
}
|
|
@@ -21871,22 +22070,39 @@ var CsvJsonAdapter = class {
|
|
|
21871
22070
|
}) : t.rows;
|
|
21872
22071
|
yield* this.paged(rows, cursor);
|
|
21873
22072
|
}
|
|
21874
|
-
/** identity rows from the _schema.json authTable
|
|
21875
|
-
*
|
|
22073
|
+
/** identity rows from the _schema.json authTable — ONLY the identity
|
|
22074
|
+
* allowlist (IDENTITY_COLUMNS): every other column, a hash in any
|
|
22075
|
+
* language included, never leaves the export dir (the PII rule). */
|
|
21876
22076
|
async *readAuthUsers(cursor) {
|
|
21877
22077
|
const { schema, tables } = this.load();
|
|
21878
22078
|
if (!schema.authUsers?.present || !schema.authUsers.table) {
|
|
21879
22079
|
throw new Error("csv-json: no authTable declared in _schema.json \u2014 identity step unavailable");
|
|
21880
22080
|
}
|
|
21881
22081
|
const t = tables.get(schema.authUsers.table);
|
|
21882
|
-
const
|
|
21883
|
-
const rows =
|
|
21884
|
-
const out = {
|
|
21885
|
-
for (const c of
|
|
22082
|
+
const { keep } = splitIdentityColumns(t.columns.map((c) => c.name));
|
|
22083
|
+
const rows = t.rows.map((r) => {
|
|
22084
|
+
const out = {};
|
|
22085
|
+
for (const c of keep) if (c in r) out[c] = r[c];
|
|
21886
22086
|
return out;
|
|
21887
22087
|
});
|
|
21888
22088
|
yield* this.paged(rows, cursor);
|
|
21889
22089
|
}
|
|
22090
|
+
/** R6 owner candidates: a string column of another table whose sampled
|
|
22091
|
+
* non-null values are ALL ids of the auth table (strings only — small
|
|
22092
|
+
* integer ids would match any counter column). */
|
|
22093
|
+
markUserIdColumns(auth, tables) {
|
|
22094
|
+
if (!auth.columns.some((c) => c.name === "id")) return;
|
|
22095
|
+
const ids = new Set(auth.rows.map((r) => r.id).filter((v) => typeof v === "string" && v !== ""));
|
|
22096
|
+
if (ids.size === 0) return;
|
|
22097
|
+
for (const t of tables.values()) {
|
|
22098
|
+
if (t === auth) continue;
|
|
22099
|
+
for (const col of t.columns) {
|
|
22100
|
+
if (col.inferredType !== "string") continue;
|
|
22101
|
+
const sample = t.rows.slice(0, this.sampleSize).map((r) => r[col.name]).filter((v) => v !== null && v !== void 0 && v !== "");
|
|
22102
|
+
if (sample.length > 0 && sample.every((v) => typeof v === "string" && ids.has(v))) col.matchesUserIds = true;
|
|
22103
|
+
}
|
|
22104
|
+
}
|
|
22105
|
+
}
|
|
21890
22106
|
/** storage objects from the `_objects/<bucket>/**` manifest (R14). Bytes are
|
|
21891
22107
|
* read LAZILY per object via stream() — a dry-run never opens them. */
|
|
21892
22108
|
async *readObjects(bucket) {
|
|
@@ -22389,14 +22605,25 @@ var GenericPgAdapter = class {
|
|
|
22389
22605
|
return;
|
|
22390
22606
|
}
|
|
22391
22607
|
const columns = t.columns.map((c) => c.name);
|
|
22608
|
+
const table = this.displayName(t.schema ?? this.schemas[0], t.name);
|
|
22392
22609
|
const auth = {
|
|
22393
22610
|
present: true,
|
|
22394
|
-
table
|
|
22611
|
+
table,
|
|
22395
22612
|
columns,
|
|
22396
22613
|
hasPasswordHash: columns.some((c) => PASSWORDISH_RE2.test(c))
|
|
22397
22614
|
};
|
|
22398
22615
|
if (t.rowCountEstimate !== void 0) auth.count = t.rowCountEstimate;
|
|
22399
22616
|
schema.authUsers = auth;
|
|
22617
|
+
const dropped = identityDroppedWarning(this.providerName, table, this.identityColumns(t).dropped);
|
|
22618
|
+
if (dropped) schema.warnings.push(dropped);
|
|
22619
|
+
}
|
|
22620
|
+
/** the identity allowlist (identity.ts) plus the single pk the keyset
|
|
22621
|
+
* page needs — never a pattern-filtered "everything else". */
|
|
22622
|
+
identityColumns(t) {
|
|
22623
|
+
const pk = t.columns.filter((c) => c.isPk).map((c) => c.name);
|
|
22624
|
+
const split = splitIdentityColumns(t.columns.map((c) => c.name));
|
|
22625
|
+
const keep = [...split.keep, ...pk.length === 1 && !split.keep.includes(pk[0]) ? pk : []];
|
|
22626
|
+
return { keep, dropped: split.dropped.filter((c) => !keep.includes(c)) };
|
|
22400
22627
|
}
|
|
22401
22628
|
findTableIn(schema, table) {
|
|
22402
22629
|
return schema.tables.find((t) => t.name === table || `${t.schema ?? ""}.${t.name}` === table);
|
|
@@ -22450,17 +22677,17 @@ var GenericPgAdapter = class {
|
|
|
22450
22677
|
else offset += rows.length;
|
|
22451
22678
|
}
|
|
22452
22679
|
}
|
|
22453
|
-
/** generic identity read: the `--auth-table` rows
|
|
22454
|
-
* columns (
|
|
22455
|
-
* supabase subclass overrides with the
|
|
22680
|
+
/** generic identity read: the `--auth-table` rows, identity ALLOWLIST
|
|
22681
|
+
* columns only (identity.ts — a hash column in any language is never read
|
|
22682
|
+
* out, the PII rule; the supabase subclass overrides with the GoTrue
|
|
22683
|
+
* auth.users allowlist). */
|
|
22456
22684
|
async *readAuthUsers(cursor) {
|
|
22457
22685
|
const source = await this.introspect();
|
|
22458
22686
|
if (!source.authUsers?.present || !source.authUsers.table) {
|
|
22459
22687
|
throw new Error("no auth table configured \u2014 pass authTable (--auth-table) for generic-pg sources");
|
|
22460
22688
|
}
|
|
22461
22689
|
const t = await this.resolveTable(source.authUsers.table);
|
|
22462
|
-
|
|
22463
|
-
yield* this.pagedRead(t, safe, cursor);
|
|
22690
|
+
yield* this.pagedRead(t, this.identityColumns(t).keep, cursor);
|
|
22464
22691
|
}
|
|
22465
22692
|
async close() {
|
|
22466
22693
|
await this.sql.end({ timeout: 5 });
|
|
@@ -26771,10 +26998,13 @@ vxil functions dev '${name}' stopped.`);
|
|
|
26771
26998
|
function migrateUsage() {
|
|
26772
26999
|
return `usage: vxil migrate <plan|schema|data|verify> [--dev]
|
|
26773
27000
|
plan --from supabase|generic-pg|csv-json (--url <pg-url> | --dir <folder>)
|
|
26774
|
-
[--skip a,b] [--include-empty] [--
|
|
27001
|
+
[--skip a,b] [--include a,b] [--include-empty] [--owner <table>=<column>|none,\u2026]
|
|
27002
|
+
[--profile-table <table>|none] [--keep-source-id]
|
|
26775
27003
|
introspect the source (read-only; no API key needed) \u2192 migrate/plan.json
|
|
26776
27004
|
+ migrate/vxil.config.suggested.ts + migrate/RESIDUALS.md
|
|
26777
|
-
(credit seeding is opt-in: tables named in migrate/payments.json
|
|
27005
|
+
(credit seeding is opt-in: tables named in migrate/payments.json; a table skipped only
|
|
27006
|
+
for its NAME prints "skip (confirm)" \u2014 --include loads it; a table with an id-like
|
|
27007
|
+
column but no detected owner must be decided with --owner before schema --apply)
|
|
26778
27008
|
schema [--apply] create the plan's cms + vector-search collections (default: dry-run diff; additive only)
|
|
26779
27009
|
data [--execute] [--only users,credentials,cms,vectors,files,payments|<table>,\u2026] [--resume]
|
|
26780
27010
|
the ordered backfill users \u2192 cms (FK order) \u2192 vectors \u2192 files \u2192 payments-credits
|
|
@@ -26868,7 +27098,14 @@ ${migrateUsage()}`);
|
|
|
26868
27098
|
try {
|
|
26869
27099
|
if (sub === "plan") {
|
|
26870
27100
|
const skip = flag("skip")?.split(",").map((s) => s.trim()).filter(Boolean);
|
|
27101
|
+
const include = flag("include")?.split(",").map((s) => s.trim()).filter(Boolean);
|
|
26871
27102
|
const profileTable = flag("profile-table");
|
|
27103
|
+
const owners = {};
|
|
27104
|
+
for (const pair of flag("owner")?.split(",").map((s) => s.trim()).filter(Boolean) ?? []) {
|
|
27105
|
+
const eq = pair.indexOf("=");
|
|
27106
|
+
if (eq <= 0 || eq === pair.length - 1) fail(`--owner '${pair}' must be <table>=<column> or <table>=none`);
|
|
27107
|
+
owners[pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim();
|
|
27108
|
+
}
|
|
26872
27109
|
await runMigratePlan({
|
|
26873
27110
|
adapter,
|
|
26874
27111
|
cwd,
|
|
@@ -26876,6 +27113,8 @@ ${migrateUsage()}`);
|
|
|
26876
27113
|
sourceLabel: label,
|
|
26877
27114
|
infer: {
|
|
26878
27115
|
...skip?.length ? { skipTables: skip } : {},
|
|
27116
|
+
...include?.length ? { includeTables: include } : {},
|
|
27117
|
+
...Object.keys(owners).length ? { owners } : {},
|
|
26879
27118
|
...hasFlag("include-empty") ? { includeEmpty: true } : {},
|
|
26880
27119
|
...profileTable !== void 0 ? { profileTable } : {},
|
|
26881
27120
|
...hasFlag("keep-source-id") ? { keepSourceId: true } : {}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vxil/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "The vxil CLI — init, quickstart, push, gen, secrets, keys, migrate, doctor, diff, functions, payments; installs the `vxil` command (npm i -g @vxil/cli).",
|
|
6
6
|
"license": "MIT",
|