@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.
Files changed (2) hide show
  1. package/dist/vxil-main.js +291 -52
  2. 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.27.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 PAYMENTS_TABLE_RE = /(purchase|payment|invoice|credit)/i;
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 && cols.has(override)) return { column: override, via: "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 fkCols = table.columns.map((c) => c.name).filter((name) => source.foreignKeys.some((fk) => fk.childTable === table.name && fk.childColumns.length === 1 && fk.childColumns[0] === name && isAuthParent(fk.parentTable, authTable)));
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
- for (const name of [...skipSet, ...paymentsSet]) {
17363
- if (!source.tables.some((t) => t.name === name || `${t.schema ?? "public"}.${t.name}` === name)) {
17364
- warnings.push(`'${name}' (${skipSet.has(name) ? "--skip" : "migrate/payments.json"}) names no source table \u2014 it selects nothing`);
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 (SUBSCRIPTION_TABLE_RE.test(t.name)) {
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
- reasons.push(`T8: ${t.name} looks like a provider webhook/event log \u2014 vxil keeps its own delivery log; not loaded (to seed credits from it, list it in migrate/payments.json and re-run plan)`);
17414
- } else if (t.rowCountEstimate === 0 && !opts.includeEmpty && policiesOn(t) + fkEdgesOn(t) === 0) {
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("R1: 0 rows by the catalog estimate \u2014 kept (--include-empty)");
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
- const profileCandidates = [];
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 = detectOwner(table, source, authTable, opts);
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.ownerField ? ` (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`
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.ownerField && m.userRefColumn !== void 0 && ruleOwnerColumn(p.definition) === m.userRefColumn) {
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.ownerField) {
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.ownerField !== void 0 ? { ownerField: m.ownerField } : {}
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, password-ish columns
21875
- * STRIPPED (hash values never leave the export dir — the PII rule). */
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 strip = t.columns.map((c) => c.name).filter((c) => PASSWORDISH_RE.test(c));
21883
- const rows = strip.length === 0 ? t.rows : t.rows.map((r) => {
21884
- const out = { ...r };
21885
- for (const c of strip) delete out[c];
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: this.displayName(t.schema ?? this.schemas[0], t.name),
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 MINUS password/secret-ish
22454
- * columns (hash values are never read out — the PII rule; the
22455
- * supabase subclass overrides with the auth.users allowlist). */
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
- const safe = t.columns.map((c) => c.name).filter((c) => !PASSWORDISH_RE2.test(c));
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] [--profile-table <table>|none] [--keep-source-id]
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.27.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",