@vxil/cli 0.26.3 → 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 CHANGED
@@ -3888,7 +3888,13 @@ var OidcProviderSchema = Type.Object({
3888
3888
  // default true: an identity whose VERIFIED email matches an existing user is
3889
3889
  // linked to it (the shipped social sign-in rule). false: link only by the stable
3890
3890
  // (issuer, sub) anchor; a matching email that is not yet linked → 409.
3891
- autoLink: Type.Optional(Type.Boolean())
3891
+ autoLink: Type.Optional(Type.Boolean()),
3892
+ // AUTH-OIDC-LINK-ONLY (2026-10-13), default false: true ⇒ this issuer only
3893
+ // signs in / links an EXISTING user (matched by the rule above: the stable
3894
+ // (issuer, sub) link, else — autoLink on — a verified email); an identity
3895
+ // matching none is refused 403 oidc_link_only (no user created, no guest
3896
+ // promoted). Staff / SSO-style sign-in without open sign-up.
3897
+ linkOnly: Type.Optional(Type.Boolean())
3892
3898
  });
3893
3899
  var REDIRECT_ORIGIN_PATTERN = "^(https://[^/?#\\s]+|http://(localhost|127\\.0\\.0\\.1)(:[0-9]{1,5})?)$";
3894
3900
  var AuthConfigSchema = Type.Object({
@@ -4053,6 +4059,7 @@ var AuthConfigSchema = Type.Object({
4053
4059
  // • lockout — present ⇒ per-(tenant, identifier) failure lockout on password
4054
4060
  // sign-in + OTP verify (429 account_locked + Retry-After); a bounded
4055
4061
  // stored counter survives restarts.
4062
+ // lockout.countOtp: false takes OTP verify out of it (see below).
4056
4063
  // • breachedPasswords — HIBP k-anonymity range check (first 5 SHA-1 hex
4057
4064
  // chars leave the worker, never the password) at sign-up / reset-confirm;
4058
4065
  // fail-OPEN on network error → 422 password_breached.
@@ -4072,7 +4079,14 @@ var AuthConfigSchema = Type.Object({
4072
4079
  lockout: Type.Optional(Type.Object({
4073
4080
  maxFailures: Type.Integer({ default: 10, minimum: 3, maximum: 100 }),
4074
4081
  windowMinutes: Type.Integer({ default: 15, minimum: 1, maximum: 1440 }),
4075
- lockMinutes: Type.Integer({ default: 15, minimum: 1, maximum: 1440 })
4082
+ lockMinutes: Type.Integer({ default: 15, minimum: 1, maximum: 1440 }),
4083
+ // AUTH-LOCKOUT-OTP-DOS (2026-10-13): false ⇒ wrong OTP verifies neither
4084
+ // charge nor are refused by the identifier lockout (it is keyed on the
4085
+ // address alone, so anyone could lock an OTP user out); OTP-only apps
4086
+ // then rely on otp.maxAttempts + resendCooldownSec. Optional, NO
4087
+ // default (absent = true = the pre-flag behavior) — existing manifests
4088
+ // fold byte-identically.
4089
+ countOtp: Type.Optional(Type.Boolean())
4076
4090
  })),
4077
4091
  breachedPasswords: Type.Boolean({ default: false }),
4078
4092
  captchaSecretRef: Type.Optional(Type.String({ minLength: 1, maxLength: 200 })),
@@ -4085,6 +4099,12 @@ var AuthConfigSchema = Type.Object({
4085
4099
  // Optional, NO default (absent = false) — existing manifests fold unchanged.
4086
4100
  // Requires otp.enabled (cross-field rule in validateFeatureConfig).
4087
4101
  emailChangeStepUp: Type.Optional(Type.Boolean()),
4102
+ // CANARY-SESSION (2026-10-13): the TENANT opt-in for server-key canary
4103
+ // sessions (PUT /v1/auth/users/{id}/canary + POST …/canary-session). Off ⇒
4104
+ // both answer 403 canary_sessions_disabled (clearing a flag still works),
4105
+ // so holding auth:write alone never lets a key sign in as a user. Optional,
4106
+ // NO default (absent = false) — existing manifests fold unchanged.
4107
+ canarySessions: Type.Optional(Type.Boolean()),
4088
4108
  allowedRedirectOrigins: Type.Optional(Type.Array(
4089
4109
  Type.String({ minLength: 8, maxLength: 253, pattern: REDIRECT_ORIGIN_PATTERN }),
4090
4110
  { maxItems: 32 }
@@ -5011,7 +5031,29 @@ var PaymentsConfigSchema = Type.Object({
5011
5031
  // `ledger` bag and make `vxil push --dry-run` / `vxil diff` report drift for
5012
5032
  // every payments tenant. The READER owns the default
5013
5033
  // (`config.ledger?.unmappedProduct ?? 'error'`), which is the one definition.
5014
- unmappedProduct: Type.Optional(Type.Union([Type.Literal("error"), Type.Literal("ignore")]))
5034
+ unmappedProduct: Type.Optional(Type.Union([Type.Literal("error"), Type.Literal("ignore")])),
5035
+ // (2026-10-13 PADDLE-DEFER-DOWNGRADE) Opt-in, PADDLE ONLY. For a tenant
5036
+ // whose app changes plans down only with proration_billing_mode
5037
+ // `full_next_billing_period`: a Paddle subscription event that keeps the
5038
+ // row's current_period_start and names a LOWER-ranked tierMap tier keeps
5039
+ // the paid tier and notes `metadata.pending_product_change`; the next
5040
+ // period's event applies the new tier (the RevenueCat PRODUCT_CHANGE rule).
5041
+ // Never on by default: a `prorated_immediately` downgrade credits the
5042
+ // unused time at once, so deferring it would hand out the higher tier for
5043
+ // free. Not Stripe (period-end changes there are subscription schedules).
5044
+ // No schema default (Value.Default would materialize it into every
5045
+ // manifest); the reader treats absent as false. Paddle is required below.
5046
+ deferDowngrades: Type.Optional(Type.Boolean()),
5047
+ // (2026-10-13 YEARLY-SUBPERIOD-GRANTS) Opt-in. A `monthly`
5048
+ // tierMap grant on an ACTIVE paid subscription whose billing period is
5049
+ // longer than a month (a yearly or quarterly plan) mints once per
5050
+ // anniversary month instead of once per period: any fold mints the month
5051
+ // that is due and the nightly reconcile sweep folds the rows no provider
5052
+ // event touches. Off by default: on, a tenant already granting monthly
5053
+ // credits from a cron function would grant them twice, and a tenant that
5054
+ // sized a yearly plan's `amount` for the year would mint it every month.
5055
+ // No schema default; the reader treats absent as false.
5056
+ subPeriodGrants: Type.Optional(Type.Boolean())
5015
5057
  }))
5016
5058
  // NB: the former `webhooks.forwardToTenantUrl` leaf
5017
5059
  // was DELETED — it had zero readers (never forwarded anything). Outbound
@@ -5542,9 +5584,9 @@ function lowerAllTriggerBindings(def, name) {
5542
5584
  return bindings;
5543
5585
  }
5544
5586
  var CONFIG_FILENAMES = ["vxil.config.ts", "vxil.config.mjs", "vxil.config.js"];
5545
- var VXIL_CONFIG_PKG_VERSION = "0.19.1";
5546
- var VXIL_SDK_PKG_VERSION = "0.26.2";
5547
- var VXIL_CLI_PKG_VERSION = "0.26.3";
5587
+ var VXIL_CONFIG_PKG_VERSION = "0.20.0";
5588
+ var VXIL_SDK_PKG_VERSION = "0.27.0";
5589
+ var VXIL_CLI_PKG_VERSION = "0.28.0";
5548
5590
  function ensureScaffoldPackageJson(cwd, opts = {}) {
5549
5591
  const file = resolve(cwd, "package.json");
5550
5592
  const wanted = {
@@ -9017,6 +9059,9 @@ import { existsSync as existsSync2, readFileSync as readFileSync2, realpathSync
9017
9059
  import { homedir } from "node:os";
9018
9060
  import { dirname as dirname2, resolve as resolve2, relative, isAbsolute, sep } from "node:path";
9019
9061
  import { createHash } from "node:crypto";
9062
+ function describeRuntime(r) {
9063
+ return `${r.compatibilityDate}${r.compatibilityFlags.length ? ` [${r.compatibilityFlags.join(",")}]` : ""}`;
9064
+ }
9020
9065
  function describeBinding(b2) {
9021
9066
  const parts = [b2.kind];
9022
9067
  if (b2.kind === "cron") parts.push(b2.schedule ? `'${b2.schedule}'` : "(no schedule)");
@@ -9344,16 +9389,36 @@ async function planFunctions({ api, functions, cwd, apply, onNote, remoteOnly =
9344
9389
  const sizeWarnings = headroom ? [{ fn: name, message: headroom }] : [];
9345
9390
  const retryN = bindings.find((b2) => b2.retry)?.retry?.maxAttempts;
9346
9391
  const label = `${name} (${Math.round(bytes / 100) / 10} KB) [${bindings.map((b2) => b2.kind).join("+")}${retryN !== void 0 ? " \xB7 retry:" + retryN : ""}${def.scopes?.length ? " \xB7 " + def.scopes.join(",") : ""}${secrets.length ? " \xB7 secrets:" + secrets.map((s) => s.slice("secret:".length)).join(",") : ""}${limits?.cpuMs !== void 0 ? " \xB7 cpu:" + limits.cpuMs + "ms" : ""}${limits?.timeoutMs !== void 0 ? " \xB7 egress:" + limits.timeoutMs + "ms" : ""}]`;
9347
- if (remoteFn !== void 0 && remoteFn.scriptRef.endsWith(`-${sha}`) && sameSecretSet(remoteFn.secrets, secrets) && sameFunctionsRuntime(remoteFn.runtime, read.deployRuntime) && (read.cpuMsCeiling === null || recordedScriptCpuMs(remoteFn.runtime) === planScriptCpuMs(remoteFn.runtime, limits?.cpuMs, read.cpuMsCeiling, read.cpuMsDefault ?? read.cpuMsCeiling)) && sameStringSet2(clampFunctionScopes(def.scopes), remoteFn.scopes) && sameStringSet2(def.egressAllow ?? [], remoteFn.egressAllow) && stableStringify(remoteFn.bindings) === stableStringify(bindings) && stableStringify(remoteFn.signature) === stableStringify(def.signature ?? null) && stableStringify(remoteFn.limits ?? null) === stableStringify(limits ?? null) && stableStringify(remoteFn.vars) === stableStringify(def.vars ?? {}) && !force) {
9392
+ const unchanged = (ignorePlatform) => {
9393
+ if (remoteFn !== void 0 && remoteFn.scriptRef.endsWith(`-${ignorePlatform ? sourceSha12(source, remoteFn.runtime) : sha}`) && sameSecretSet(remoteFn.secrets, secrets) && (ignorePlatform || sameFunctionsRuntime(remoteFn.runtime, read.deployRuntime)) && (ignorePlatform || read.cpuMsCeiling === null || recordedScriptCpuMs(remoteFn.runtime) === planScriptCpuMs(remoteFn.runtime, limits?.cpuMs, read.cpuMsCeiling, read.cpuMsDefault ?? read.cpuMsCeiling)) && sameStringSet2(clampFunctionScopes(def.scopes), remoteFn.scopes) && sameStringSet2(def.egressAllow ?? [], remoteFn.egressAllow) && stableStringify(remoteFn.bindings) === stableStringify(bindings) && stableStringify(remoteFn.signature) === stableStringify(def.signature ?? null) && stableStringify(remoteFn.limits ?? null) === stableStringify(limits ?? null) && stableStringify(remoteFn.vars) === stableStringify(def.vars ?? {}) && !force) {
9394
+ return true;
9395
+ }
9396
+ return false;
9397
+ };
9398
+ if (unchanged(false)) {
9348
9399
  return { change: { name, kind: "unchanged", detail: label }, applied: 0, cmsHooks: null, webhooks: null, warnings: sizeWarnings, hook: null };
9349
9400
  }
9401
+ const reasons = [];
9402
+ if (remoteFn) {
9403
+ if (!sameFunctionsRuntime(remoteFn.runtime, read.deployRuntime)) {
9404
+ reasons.push({ kind: "runtime", from: describeRuntime(remoteFn.runtime), to: describeRuntime(read.deployRuntime) });
9405
+ }
9406
+ const from = recordedScriptCpuMs(remoteFn.runtime);
9407
+ const to = read.cpuMsCeiling === null ? from : planScriptCpuMs(remoteFn.runtime, limits?.cpuMs, read.cpuMsCeiling, read.cpuMsDefault ?? read.cpuMsCeiling);
9408
+ if (from !== to && stableStringify(remoteFn.limits ?? null) === stableStringify(limits ?? null)) {
9409
+ reasons.push({ kind: "cpu-limit", from, to });
9410
+ }
9411
+ }
9412
+ const platformOnly = reasons.length > 0 && unchanged(true);
9350
9413
  const bindingChanges = remoteFn !== void 0 ? diffBindings(remoteFn.bindings, bindings) : null;
9351
9414
  const change = {
9352
9415
  name,
9353
9416
  kind: remoteFn === void 0 ? "deploy-new" : "deploy-update",
9354
9417
  detail: label,
9355
9418
  bindings: bindings.map(describeBinding),
9356
- ...bindingChanges ? { bindingChanges } : {}
9419
+ ...bindingChanges ? { bindingChanges } : {},
9420
+ ...reasons.length ? { reasons } : {},
9421
+ ...platformOnly ? { platformOnly: true } : {}
9357
9422
  };
9358
9423
  if (apply) {
9359
9424
  const body = { source, trigger: triggerKind, secrets };
@@ -9441,6 +9506,12 @@ function formatFnChanges(changes) {
9441
9506
  const out = [];
9442
9507
  for (const c of actionable) {
9443
9508
  out.push(` ${mark(c.kind)} ${c.detail}`);
9509
+ for (const r of c.reasons ?? []) {
9510
+ out.push(r.kind === "runtime" ? ` reason: runtime ${r.from} \u2192 ${r.to}` : ` reason: cpu-limit ${r.from} ms \u2192 ${r.to} ms`);
9511
+ }
9512
+ if (c.platformOnly) {
9513
+ out.push(" (platform update only \u2014 your function is unchanged; `vxil push` redeploys it under the new settings; under `vxil diff --exit-code` / `--against` it is a notice, not drift, unless --strict-runtime)");
9514
+ }
9444
9515
  if (c.bindingChanges) {
9445
9516
  for (const b2 of c.bindingChanges.removed) out.push(` - binding ${b2}`);
9446
9517
  for (const b2 of c.bindingChanges.added) out.push(` + binding ${b2}`);
@@ -13393,7 +13464,7 @@ var TOOLS = [
13393
13464
  {
13394
13465
  name: "jobs_get_run",
13395
13466
  feature: "jobs",
13396
- description: "Read ONE run by run_id: the row jobs_list_runs summarises plus payload_json (your own jobs; a platform-enqueued run reads { redacted: true }), any generation_* fields, result (what the run completed with: its signed callback's result, or a generation's settled value; null when nothing reported one), progress (the latest { progress 0..100, stage, message, at } report, or null), concurrency_key / concurrency_limit (null when unkeyed) and, on a generation run, provider_ref (the provider job id, with completion.ref_path) and mirror_error (the last status_mirror write the target refused \u2014 e.g. a callback key the collection does not declare: the status word was retried alone and fields_dropped lists what was not written; status_written says whether the record holds the status word \u2014 or null; a later clean completed/failed write clears it into last_mirror_error, with mirror_ok_at saying when). Pass wait (1..25 seconds) to WAIT FOR THIS RUN: the read holds until the run is terminal (succeeded | failed | dead | cancelled) or the deadline passes, then returns the current row \u2014 on a timeout (or when the per-tenant wait budget of 60 held reads a minute declined to hold) state is still non-terminal, so check it and call again. Use it right after jobs_enqueue or an async function invoke instead of polling jobs_list_runs.",
13467
+ description: "Read ONE run by run_id: the row jobs_list_runs summarises plus payload_json (your own jobs; a platform-enqueued run reads { redacted: true }), any generation_* fields, result (what the run completed with: its signed callback's result, or a generation's settled value; null when nothing reported one), progress (the latest { progress 0..100, stage, message, at } report, or null), concurrency_key / concurrency_limit (null when unkeyed) and, on a generation run, provider_ref (the provider job id, with completion.ref_path), attempts (each provider START call: attempt, started_at, ms, http_status, error_class \u2014 the newest 10) and mirror_error (the last status_mirror write the target refused \u2014 e.g. a callback key the collection does not declare: the status word was retried alone and fields_dropped lists what was not written; status_written says whether the record holds the status word \u2014 or null; a later clean completed/failed write clears it into last_mirror_error, with mirror_ok_at saying when). Pass wait (1..25 seconds) to WAIT FOR THIS RUN: the read holds until the run is terminal (succeeded | failed | dead | cancelled) or the deadline passes, then returns the current row \u2014 on a timeout (or when the per-tenant wait budget of 60 held reads a minute declined to hold) state is still non-terminal, so check it and call again. Use it right after jobs_enqueue or an async function invoke instead of polling jobs_list_runs.",
13397
13468
  inputSchema: {
13398
13469
  type: "object",
13399
13470
  properties: {
@@ -14742,7 +14813,7 @@ var TOOLS = [
14742
14813
  {
14743
14814
  name: "ai_generation_get",
14744
14815
  feature: "ai",
14745
- description: "Read a generation back: pass generation_id for one (status pending|processing|completed|failed, usage, run_id, and for a job-lane generation the settled answer in result.text, kept 30 days), or correlation_id to find yours by your own handle (newest first, at most 20; each with result:null \u2014 call again with that generation_id for its text) \u2014 the recovery read after a timed-out or lost job answer. result_state says stored | purged | not_recorded (sync and stream generations return their answer directly and record none).",
14816
+ description: "Read a generation back: pass generation_id for one (status pending|processing|completed|failed, usage, run_id, and for a job-lane generation the settled answer in result.text, kept 30 days), or correlation_id to find yours by your own handle (newest first, at most 20; each with result:null \u2014 call again with that generation_id for its text) \u2014 the recovery read after a timed-out or lost job answer. result_state says stored | purged | not_recorded (sync and stream generations return their answer directly and record none). attempts lists a job-lane generation's model calls (attempt, started_at, ms, the upstream http_status, error_class \u2014 the newest 10), so a retried or slow provider call is visible.",
14746
14817
  inputSchema: {
14747
14818
  type: "object",
14748
14819
  properties: {
@@ -15514,6 +15585,21 @@ function unstoredSecretVerdict(opts) {
15514
15585
  if (opts.ignored) return "ignored";
15515
15586
  return opts.strict ? "drift" : "informational";
15516
15587
  }
15588
+ function platformOnlyFnVerdict(opts) {
15589
+ if (opts.ignored) return "ignored";
15590
+ return opts.gate && !opts.strict ? "informational" : "drift";
15591
+ }
15592
+ function partitionFnDrift(changes, opts) {
15593
+ const out = { count: [], informational: [] };
15594
+ for (const c of changes) {
15595
+ if (c.kind === "unchanged") continue;
15596
+ const key = `function:${c.name}`;
15597
+ if (c.platformOnly && platformOnlyFnVerdict({ gate: opts.gate, strict: opts.strict, ignored: isIgnored(key, opts.ignore) }) === "informational") {
15598
+ out.informational.push(key);
15599
+ } else out.count.push(key);
15600
+ }
15601
+ return out;
15602
+ }
15517
15603
  function driftExitCode(r) {
15518
15604
  if (r.errors > 0) return 2;
15519
15605
  return r.drift > 0 ? 1 : 0;
@@ -16649,7 +16735,7 @@ var VERB_USAGE = {
16649
16735
  listen: "usage: vxil listen --forward-to <url> [--dev] [--source <id>] [--events <prefix,\u2026>] [--replay-last <n>] [--interval <s>] [--show-runs] [--raw] \u2014 forward inbound webhook events to a local server",
16650
16736
  mcp: "usage: vxil mcp install [--client cursor|claude|vscode] [--project] [--print] [--key <api_key>] [--scopes a,b] [--name <server>]",
16651
16737
  plan: "usage: vxil plan [--dev|--target <name>] [--explain] [--skip-functions] \u2014 what `vxil push` would change: cms schema, feature config, declared API state, functions (a deployed function the config no longer declares shows as `-`); writes nothing",
16652
- diff: "usage: vxil diff [--against <target>|--exit-code] [--explain] [--ignore <k,\u2026>] [--strict-secrets] [--strict-api-state] [--api-state] [--json] \u2014 the CI gate: exit 0 no drift \xB7 1 drift \xB7 2 error",
16738
+ diff: "usage: vxil diff [--against <target>|--exit-code] [--explain] [--ignore <k,\u2026>] [--strict-secrets] [--strict-runtime] [--strict-api-state] [--api-state] [--json] \u2014 the CI gate: exit 0 no drift \xB7 1 drift \xB7 2 error",
16653
16739
  push: "usage: vxil push [--dev|--target <name>|--prod] [--allow-destructive] [--strict] [--server [--resume <apply_id>]] [--no-gen] [--skip-functions] [--yes] \u2014 apply the config; --allow-destructive also deletes deployed functions and stored AI templates the config no longer declares; --strict exits 1 when anything declared was refused, deferred or left unwired; --server prints the trigger wiring the server reconciled (--json: one result document on stdout)",
16654
16740
  gen: "usage: vxil gen [--check] [--offline] [--out <f>] [--no-mcp] [--mcp-out <f>] \u2014 generate vxil.types.ts (+ vxil.mcp.json); --offline keeps an existing API_VERSIONS block; --check compares instead of writing",
16655
16741
  types: "usage: vxil types \u2014 alias of `vxil gen` (run `vxil gen --help`)",
@@ -16719,6 +16805,8 @@ var EXTRA_KNOWN_FLAGS = [
16719
16805
  "dir",
16720
16806
  "file-links",
16721
16807
  "from-provider",
16808
+ "include",
16809
+ "owner",
16722
16810
  "include-empty",
16723
16811
  "include-password-hashes",
16724
16812
  "with-password-hashes",
@@ -16768,6 +16856,7 @@ var BOOLEAN_FLAGS = /* @__PURE__ */ new Set([
16768
16856
  "raw",
16769
16857
  "strict",
16770
16858
  "strict-api-state",
16859
+ "strict-runtime",
16771
16860
  "strict-secrets",
16772
16861
  "sweep",
16773
16862
  "watch",
@@ -16967,8 +17056,8 @@ function ledgerGrantPeriodWarnings(cfg) {
16967
17056
  });
16968
17057
  }
16969
17058
  for (const [priceRef, tier] of Object.entries(ledger.priceMap ?? {})) {
16970
- if (YEARLY_ID.test(priceRef) && periodsOf(tier).includes("monthly")) {
16971
- out.push(`/ledger/priceMap/${priceRef}: looks like a yearly price, but tier '${tier}' has a 'monthly' grant \u2014 tier grants mint once per billing period (keyed by its start), so it mints once a year. For monthly credits on a yearly plan, run a cron function that calls grantOnce keyed by month`);
17059
+ if (YEARLY_ID.test(priceRef) && periodsOf(tier).includes("monthly") && ledger.subPeriodGrants !== true) {
17060
+ out.push(`/ledger/priceMap/${priceRef}: looks like a yearly price, but tier '${tier}' has a 'monthly' grant \u2014 tier grants mint once per billing period (keyed by its start), so it mints once a year. For monthly credits on a yearly plan, set ledger.subPeriodGrants: true`);
16972
17061
  }
16973
17062
  }
16974
17063
  for (const [productId, rule] of Object.entries(ledger.productMap ?? {})) {
@@ -16990,6 +17079,15 @@ import { dirname as dirname4, resolve as resolve5 } from "node:path";
16990
17079
  import { appendFileSync, existsSync as existsSync5, mkdirSync as mkdirSync3, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "node:fs";
16991
17080
  import { basename as basename2, resolve as resolve4 } from "node:path";
16992
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
+
16993
17091
  // src/migrate/inference.ts
16994
17092
  var VECTOR_DIMS = [256, 384, 512, 768, 1024, 1536, 3072];
16995
17093
  var SOURCE_ID_FIELD = "source_id";
@@ -17073,7 +17171,92 @@ function parseNumeric(pgType) {
17073
17171
  const m = /^numeric\s*\(\s*(\d+)\s*,\s*(\d+)\s*\)$/i.exec(pgType.trim());
17074
17172
  return m ? { precision: Number(m[1]), scale: Number(m[2]) } : void 0;
17075
17173
  }
17076
- 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");
17077
17260
  var WEBHOOK_LOG_RE = /webhook/i;
17078
17261
  var SUBSCRIPTION_TABLE_RE = /(subscription|entitlement)/i;
17079
17262
  var PUSH_TOKEN_RE = /(push_token|device_token|fcm_token|apns|expo_token)/i;
@@ -17086,6 +17269,17 @@ function isAuthParent(parentTable, authTable) {
17086
17269
  return !parentTable.includes(".") || !authTable.includes(".");
17087
17270
  }
17088
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
+ }
17089
17283
  function ruleOwnerColumn(definition) {
17090
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);
17091
17285
  return m?.[1];
@@ -17101,21 +17295,32 @@ function pickOwner(candidates) {
17101
17295
  for (const name of OWNER_NAMES) if (candidates.includes(name)) return { column: name, ambiguous: false };
17102
17296
  return { column: candidates[0], ambiguous: candidates.length > 1 };
17103
17297
  }
17104
- function detectOwner(table, source, authTable, opts) {
17298
+ function detectOwner(table, source, authTable, opts, userKeyed = /* @__PURE__ */ new Set()) {
17105
17299
  const cols = new Set(table.columns.map((c) => c.name));
17106
17300
  const override = opts.owners?.[table.name];
17107
- 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" };
17108
17303
  for (const p of source.rlsPolicies ?? []) {
17109
17304
  if (lastSeg(p.table) !== table.name) continue;
17110
17305
  const col = ruleOwnerColumn(p.definition);
17111
17306
  if (col && cols.has(col)) return { column: col, via: `row-level access rule${p.name ? ` '${p.name}'` : ""}` };
17112
17307
  }
17113
- 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);
17114
17318
  if (fkCols.length > 0) {
17115
17319
  const pick = pickOwner(fkCols);
17320
+ const parent = userFkParent(pick.column);
17116
17321
  return {
17117
17322
  column: pick.column,
17118
- via: `FK \u2192 ${authTable}`,
17323
+ via: parent === authTable ? `FK \u2192 ${authTable}` : `FK \u2192 ${parent} (its id is the user id)`,
17119
17324
  ...pick.ambiguous || fkCols.length > 1 ? { ambiguousAmong: fkCols } : {}
17120
17325
  };
17121
17326
  }
@@ -17272,9 +17477,24 @@ function inferMapping(source, opts = {}) {
17272
17477
  const vectorTargets = [];
17273
17478
  const skipSet = new Set(opts.skipTables ?? []);
17274
17479
  const paymentsSet = new Set(opts.paymentsTables ?? []);
17275
- for (const name of [...skipSet, ...paymentsSet]) {
17276
- if (!source.tables.some((t) => t.name === name || `${t.schema ?? "public"}.${t.name}` === name)) {
17277
- 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(", ")})`);
17278
17498
  }
17279
17499
  }
17280
17500
  const fksByChild = /* @__PURE__ */ new Map();
@@ -17301,10 +17521,17 @@ function inferMapping(source, opts = {}) {
17301
17521
  for (const t of source.tables) {
17302
17522
  const reasons = [];
17303
17523
  let target;
17524
+ let confirm;
17304
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;
17305
17528
  if (skipSet.has(t.name) || skipSet.has(qualified)) {
17306
17529
  target = "skip";
17307
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`);
17308
17535
  } else if (t.extensionOwned) {
17309
17536
  target = "skip";
17310
17537
  reasons.push(`T8: ${qualified} belongs to an installed extension \u2014 the extension recreates it; not app data`);
@@ -17317,22 +17544,26 @@ function inferMapping(source, opts = {}) {
17317
17544
  } else if (paymentsSet.has(t.name) || paymentsSet.has(qualified)) {
17318
17545
  target = "payments-credits";
17319
17546
  reasons.push(`T8: ${t.name} is listed in migrate/payments.json \u2192 payments credits via grantOnce (idempotent grant_key); never cms`);
17320
- } else if (SUBSCRIPTION_TABLE_RE.test(t.name)) {
17321
- target = "skip";
17322
- reasons.push(`subscription/entitlement table ${t.name}: provider-derived \u2014 never seeded`);
17323
- residuals.push(`subscription/entitlement state (${t.name}): NOT seeded \u2014 rebuild from live provider webhooks after cutover`);
17324
- } else if (WEBHOOK_LOG_RE.test(t.name)) {
17547
+ } else if (nameSkip !== void 0 && !included) {
17325
17548
  target = "skip";
17326
- 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)`);
17327
- } 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) {
17328
17558
  target = "skip";
17329
17559
  reasons.push("R1: 0 rows \u2014 likely dropped/legacy; skipped (confirm; --include-empty keeps it)");
17330
17560
  } else {
17331
17561
  target = "cms";
17332
17562
  reasons.push(`R1: ${qualified} \u2192 cms collection '${toCollectionName(t.name)}'`);
17563
+ if (included && nameSkip !== void 0) reasons.push(`kept by --include (${nameSkip})`);
17333
17564
  if (t.rowCountEstimate === 0) {
17334
- if (opts.includeEmpty) {
17335
- 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"})`);
17336
17567
  } else {
17337
17568
  reasons.push("T1: 0 rows by the catalog estimate, but it carries row-level rules/FKs \u2014 kept");
17338
17569
  warnings.push(
@@ -17346,20 +17577,32 @@ function inferMapping(source, opts = {}) {
17346
17577
  );
17347
17578
  }
17348
17579
  }
17349
- classified.push({ table: t, target, reasons });
17580
+ classified.push({ table: t, target, reasons, ...confirm !== void 0 ? { confirm } : {} });
17350
17581
  }
17351
17582
  const cmsByName = /* @__PURE__ */ new Map();
17352
17583
  for (const c of classified) {
17353
17584
  if (c.target === "cms") cmsByName.set(c.table.name, { table: c.table, collection: toCollectionName(c.table.name) });
17354
17585
  }
17355
17586
  const owners = /* @__PURE__ */ new Map();
17356
- 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
+ }
17357
17603
  for (const { table } of cmsByName.values()) {
17358
- const o = detectOwner(table, source, authTable, opts);
17604
+ const o = owners.get(table.name);
17359
17605
  if (!o) continue;
17360
- owners.set(table.name, o);
17361
- const pkCols = table.columns.filter((c) => c.isPk);
17362
- if (pkCols.length === 1 && pkCols[0].name === o.column) profileCandidates.push(table.name);
17363
17606
  if (o.ambiguousAmong) {
17364
17607
  const preferred = OWNER_NAMES.includes(o.column);
17365
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`;
@@ -17388,9 +17631,9 @@ function inferMapping(source, opts = {}) {
17388
17631
  }
17389
17632
  }
17390
17633
  }
17391
- for (const { table: t, target, reasons } of classified) {
17634
+ for (const { table: t, target, reasons, confirm } of classified) {
17392
17635
  if (target !== "cms") {
17393
- 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;
17394
17637
  const m = {
17395
17638
  table: t.name,
17396
17639
  target,
@@ -17398,7 +17641,8 @@ function inferMapping(source, opts = {}) {
17398
17641
  // end_user_id / the grantOnce grant_key) — no id capture needed.
17399
17642
  idRemap: "value-keyed",
17400
17643
  fields: target === "users" ? authUserFields(t.columns.map((c) => c.name)) : [],
17401
- reasons
17644
+ reasons,
17645
+ ...confirm !== void 0 ? { confirm } : {}
17402
17646
  };
17403
17647
  const ref = target === "users" ? "id" : owner2?.column;
17404
17648
  if (ref !== void 0) m.userRefColumn = ref;
@@ -17552,11 +17796,27 @@ function inferMapping(source, opts = {}) {
17552
17796
  }
17553
17797
  fields.push(field);
17554
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
+ }
17555
17815
  const tokenCols = t.columns.map((c) => c.name).filter((c) => PUSH_TOKEN_RE.test(c));
17556
17816
  if (PUSH_TOKEN_RE.test(t.name) || tokenCols.length > 0) {
17557
17817
  mapping.reasons.push(`T4: push device tokens${tokenCols.length ? ` (${tokenCols.join(", ")})` : ""} \u2014 copied as app data`);
17558
17818
  residuals.push(
17559
- `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`
17560
17820
  );
17561
17821
  }
17562
17822
  const change = detectChangeColumn(t, source);
@@ -17631,9 +17891,9 @@ function inferMapping(source, opts = {}) {
17631
17891
  carried = `table not loaded as cms (${m?.target ?? "not in the plan"}) \u2014 nothing to carry`;
17632
17892
  } else if (isOpenRule(p.definition)) {
17633
17893
  carried = `open rule \u2014 nothing to carry (any caller holding cms scopes reads '${m.collection}')`;
17634
- } 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) {
17635
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)`;
17636
- } else if (m.ownerField) {
17896
+ } else if (hasOwnerField(m)) {
17637
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)`;
17638
17898
  } else {
17639
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)`;
@@ -17705,11 +17965,6 @@ function topoOrder(cmsNames, fks, profileTable, warnings) {
17705
17965
  import { existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "node:fs";
17706
17966
  import { dirname as dirname3 } from "node:path";
17707
17967
 
17708
- // src/migrate/types.ts
17709
- function newMigrateState() {
17710
- return { version: 1, schemaPushed: false, cursors: {}, idMap: {} };
17711
- }
17712
-
17713
17968
  // src/migrate/loaders/delta.ts
17714
17969
  import { createHash as createHash2 } from "node:crypto";
17715
17970
  function canonical(v) {
@@ -18264,6 +18519,7 @@ async function loadCredentials2({ deps, adapter, state, save, opts, delta = fals
18264
18519
  return result;
18265
18520
  }
18266
18521
  const reasons = /* @__PURE__ */ new Map();
18522
+ let would = deps.dryRun ? {} : null;
18267
18523
  let withPassword = 0;
18268
18524
  let withLinks = 0;
18269
18525
  let linked = 0;
@@ -18284,13 +18540,19 @@ async function loadCredentials2({ deps, adapter, state, save, opts, delta = fals
18284
18540
  entries.push(entry);
18285
18541
  digests.push(h);
18286
18542
  }
18287
- if (deps.dryRun) continue;
18543
+ if (deps.dryRun && would === null) continue;
18288
18544
  for (let i = 0; i < entries.length; i += CREDENTIALS_BATCH_MAX) {
18289
18545
  const chunk = entries.slice(i, i + CREDENTIALS_BATCH_MAX);
18290
18546
  const res = await deps.api("POST", "/v1/auth/users/import", {
18291
18547
  users: chunk,
18292
- ...opts.oidcIssuer ? { oidc_issuer: opts.oidcIssuer } : {}
18548
+ ...opts.oidcIssuer ? { oidc_issuer: opts.oidcIssuer } : {},
18549
+ ...deps.dryRun ? { dry_run: true } : {}
18293
18550
  });
18551
+ if (deps.dryRun && (res.status !== 200 || res.body.error)) {
18552
+ deps.log(` \u26A0 ${apiErrorLine("credentials: server preview unavailable (POST /v1/auth/users/import dry_run)", res)} \u2014 the local counts below still stand; --execute needs auth enabled and a SERVER key holding auth:write`);
18553
+ would = null;
18554
+ break;
18555
+ }
18294
18556
  if (res.status !== 200 || res.body.error) {
18295
18557
  const hint = res.body.error?.code === "capability_not_enabled" ? " \u2014 enable auth (with emailPassword) on this project first" : res.body.error?.code === "server_only" || res.body.error?.code === "insufficient_scope" ? " \u2014 run the migration with a SERVER key holding auth:write" : "";
18296
18558
  result.errors.push(apiErrorLine(`credentials: POST /v1/auth/users/import (${chunk.length} users) failed`, res) + hint + " \u2014 the cursor was NOT advanced; fix and re-run (the import is idempotent)");
@@ -18301,10 +18563,17 @@ async function loadCredentials2({ deps, adapter, state, save, opts, delta = fals
18301
18563
  linked += typeof data2.identities_linked === "number" ? data2.identities_linked : 0;
18302
18564
  const taken = (data2.results ?? []).filter((r) => r.status === "skipped" && r.reason === "id_taken").length;
18303
18565
  if (taken > 0) {
18304
- result.errors.push(`credentials: ${taken} user id(s) already belong to another vxil project in this environment (user ids are unique across projects) \u2014 most often a rehearsal dev backend that imported the same users. Delete it (\`vxil dev down\`) or the project that holds them, then re-run: the cursor was NOT advanced and the import is idempotent. Left as is, those users would get a new id at sign-up and lose their data.`);
18566
+ result.errors.push(`credentials: ${deps.dryRun ? "--execute would fail \u2014 " : ""}${taken} user id(s) already belong to another vxil project in this environment (user ids are unique across projects) \u2014 most often a rehearsal dev backend that imported the same users. Delete it (\`vxil dev down\`) or the project that holds them, then re-run: the cursor was NOT advanced and the import is idempotent. Left as is, those users would get a new id at sign-up and lose their data.`);
18305
18567
  result.cursor = cursors[KEY] ?? null;
18306
18568
  return result;
18307
18569
  }
18570
+ if (would !== null) {
18571
+ for (const r of data2.results ?? []) {
18572
+ would[r.status] = (would[r.status] ?? 0) + 1;
18573
+ if (r.status === "skipped") reasons.set(r.reason ?? "unknown", (reasons.get(r.reason ?? "unknown") ?? 0) + 1);
18574
+ }
18575
+ continue;
18576
+ }
18308
18577
  for (const r of data2.results ?? []) {
18309
18578
  if (r.status === "skipped") {
18310
18579
  result.skipped++;
@@ -18315,12 +18584,21 @@ async function loadCredentials2({ deps, adapter, state, save, opts, delta = fals
18315
18584
  }
18316
18585
  for (let j = 0; j < chunk.length; j++) sent[chunk[j].id] = digests[i + j];
18317
18586
  }
18587
+ if (deps.dryRun) continue;
18318
18588
  cursors[KEY] = batch.nextCursor;
18319
18589
  save();
18320
18590
  if (batch.nextCursor === null) break;
18321
18591
  }
18322
18592
  if (deps.dryRun) {
18323
18593
  deps.log(`credentials: DRY-RUN \u2014 would import ${result.read - result.skipped} identit(ies)` + (opts.withHashes ? `, ${withPassword} with a password hash` : " (no hashes \u2014 --with-password-hashes not set)") + (opts.oidcIssuer ? ", each linked to the source issuer for the session hand-over" : "") + (opts.withIdentities ? `, ${withLinks} with social sign-in links` : "") + "; nothing written");
18594
+ if (would !== null) {
18595
+ const skippedN = would["skipped"] ?? 0;
18596
+ deps.log(`credentials: server preview: created=${would["created"] ?? 0} updated=${would["updated"] ?? 0} kept=${would["kept"] ?? 0} skipped=${skippedN}` + (reasons.size > 0 ? ` (${[...reasons].map(([r, n]) => `${r}=${n}`).join(" ")})` : "") + (opts.withIdentities ? `, ${linked} social link(s) would be created` : "") + " \u2014 the server ran each batch of up to 500 and rolled it back: exact within a batch, not across batches (an address or social subject repeated in a LATER batch can still be email_taken / not linked on --execute)");
18597
+ if (reasons.has("not_registered")) {
18598
+ deps.log(" `not_registered` in a preview: the users step has not registered them yet (it writes nothing in a dry run either) \u2014 run `vxil migrate data --execute --only users`, then preview again for their real outcome");
18599
+ }
18600
+ }
18601
+ reasons.clear();
18324
18602
  } else {
18325
18603
  cursors[KEY] = null;
18326
18604
  save();
@@ -18352,7 +18630,7 @@ function mappingToCollection(m) {
18352
18630
  }
18353
18631
  return {
18354
18632
  fields,
18355
- ...m.ownerField !== void 0 ? { ownerField: m.ownerField } : {}
18633
+ ...hasOwnerField(m) ? { ownerField: m.ownerField } : {}
18356
18634
  };
18357
18635
  }
18358
18636
  function planToCollections(plan) {
@@ -19343,10 +19621,12 @@ function saveMigrateState(stateFile, state) {
19343
19621
  mkdirSync2(dirname3(stateFile), { recursive: true });
19344
19622
  writeFileSync3(stateFile, JSON.stringify(state, null, 2) + "\n");
19345
19623
  }
19624
+ var SERVER_PREVIEW_POSTS = /* @__PURE__ */ new Set(["/v1/auth/users/import"]);
19346
19625
  function guardReadOnly(api) {
19347
19626
  return async (method, path, body, headers) => {
19348
19627
  const m = method.toUpperCase();
19349
- if (m !== "GET" && m !== "HEAD") {
19628
+ const preview = m === "POST" && SERVER_PREVIEW_POSTS.has(path) && typeof body === "object" && body !== null && body.dry_run === true;
19629
+ if (m !== "GET" && m !== "HEAD" && !preview) {
19350
19630
  throw new Error(
19351
19631
  `dry-run: blocked ${m} ${path} \u2014 a loader attempted a write without --execute (loader bug; nothing was sent). Re-run with --execute to write.`
19352
19632
  );
@@ -19660,12 +19940,21 @@ function emitResidualsMd(plan, sourceLabel) {
19660
19940
  }
19661
19941
  return lines.join("\n") + "\n";
19662
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
+ }
19663
19952
  function formatPlanSummary(plan) {
19664
19953
  const rows = plan.tables.map((t) => [
19665
19954
  t.table,
19666
- t.target,
19955
+ t.confirm !== void 0 ? "skip (confirm)" : t.target,
19667
19956
  t.target === "cms" ? t.collection ?? "" : "\u2014",
19668
- t.ownerField ?? t.userRefColumn ?? "\u2014",
19957
+ t.ownerField ?? t.userRefColumn ?? (t.ownerCandidates?.length ? `? ${t.ownerCandidates.join("|")} (decide)` : "\u2014"),
19669
19958
  t.target === "cms" || t.target === "users" ? String(t.fields.length) : "\u2014"
19670
19959
  ]);
19671
19960
  const head = ["TABLE", "TARGET", "COLLECTION", "OWNER/USER-REF", "FIELDS"];
@@ -19758,6 +20047,9 @@ function loaderDeps({ api, cwd, log }, dryRun) {
19758
20047
  async function runMigrateSchema(io, opts = {}) {
19759
20048
  const plan = readPlanFile(io.cwd);
19760
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`);
19761
20053
  const missing = apply ? await missingPlanFeatures(io.api, planFeatureNeeds(plan, { step: "schema" })) : [];
19762
20054
  if (missing.length) throw new Error(`migrate schema --apply refused: ${missingFeaturesText(missing)} (nothing was written)`);
19763
20055
  if (apply) ensureStateIgnored(io.cwd, io.log);
@@ -19801,6 +20093,9 @@ async function runMigrateData(io, opts = {}) {
19801
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`);
19802
20094
  }
19803
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`);
19804
20099
  const missing = execute ? await missingPlanFeatures(io.api, planFeatureNeeds(plan, {
19805
20100
  step: "data",
19806
20101
  ...opts.only ? { only: opts.only } : {},
@@ -21064,11 +21359,11 @@ export default defineConfig({
21064
21359
  "hasFunctions": true,
21065
21360
  "byoKeys": [],
21066
21361
  "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Push notifications\" \u2014 DEVICE TOKENS DONE PROPERLY.\n//\n// Sending a push is the easy part: one HTTPS POST to your push service. What\n// actually breaks in production is the REGISTRY \u2014 the mapping from a user to\n// the devices they are currently holding. Tokens rotate, phones get sold, users\n// sign out and a colleague signs in on the same handset, and a token that once\n// belonged to Alice starts delivering Alice's notifications to Bob. That is a\n// privacy incident, not a bug.\n//\n// So this blueprint is a registry with three invariants and a small amount of\n// sending bolted on \u2014 and it is THE supported Expo path on vxil (guide 17):\n// 1. ONE TOKEN, ONE USER \u2014 `token` is `unique`, enforced by the platform.\n// 2. RE-OWN ON CHANGE \u2014 a token that reappears under a different user is\n// REASSIGNED, never duplicated. From your backend (server mode) that is\n// immediate; from the app itself (end-user mode, which cannot see another\n// user's row) it is ASYNC: a claim row + a server-mode cmsHook.\n// 3. PRUNE STALE \u2014 the push service tells you when a token is dead; a cron\n// collects those receipts and deletes the rows, so your registry shrinks.\n// It walks the WHOLE registry over successive ticks (a persisted cursor).\n// Plus the two things every consumer app ends up writing:\n// \u2022 ERASURE \u2014 a user erased under GDPR (auth.user.erased / user.erased) or\n// deleted (user.deleted) loses every device row, claim and preferences\n// row \u2014 never on session.revoked, which fires on every refresh.\n// \u2022 A REMINDER LADDER \u2014 an hourly cron nudging inactive users (1 d, 3 d, 7 d),\n// honouring each user's opt-out and quiet hours in THEIR time zone.\n//\n// \u2022 cms \u2192 `push_tokens` (owner-scoped, `token` unique), `push_token_claims`\n// (an app's async re-own request), `push_prefs` (reminders,\n// time zone, quiet hours, last activity), `push_state` (cursors)\n// \u2022 functions \u2192 `register-token` (the registry: http + the claim cmsHook + the\n// erasure webhooks), `send-push` (http send + the hourly ladder\n// cron), `prune-receipts` (the receipts cron)\n//\n// Three functions and two crons (the fastest hourly) on purpose: that is inside\n// the Free plan's function limits for staging and development projects (guide\n// 12 has the per-plan table), so the whole kit runs on a free project while\n// you build the app. Every tick is bounded and resumable, so a tick the plan\n// sheds is harmless: the next one picks up where the last stopped.\n//\n// Written against Expo's push service because it fronts BOTH APNs and FCM with\n// one token format. Swapping in raw FCM or APNs is a change to two constants\n// and the message body \u2014 see README.md.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n cms: {\n // Fail-safe: a verified end-user may only touch collections that declare\n // an ownerField. `push_tokens` does, so a signed-in device can read and\n // refresh its OWN rows and structurally cannot enumerate anyone else's.\n strictEndUserScope: true,\n hooks: {\n // A row with no token is a row that can never receive anything.\n token_present: {\n collection: 'push_tokens',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'len(item.token) > 0',\n message: 'a push token is required',\n },\n // The ladder walk filters on `ladder_step < 3` (a slot compare, which\n // never matches an absent value), so every prefs row carries one: a\n // write that leaves it out gets 0.\n ladder_step_default: {\n collection: 'push_prefs',\n event: 'beforeWrite',\n kind: 'derive',\n field: 'ladder_step',\n expr: 'coalesce(item.ladder_step, 0)',\n },\n },\n },\n functions: { enabled: true },\n },\n\n cms: {\n collections: {\n push_tokens: {\n singular: 'push_token',\n // Owner-scoped: an end-user session sees only its own devices.\n ownerField: 'end_user',\n fields: {\n // \u2605 INVARIANT 1. `unique` is the TOP-LEVEL field attribute (there is no\n // `validation.unique`). vxil enforces it with its uniqueness check, so a\n // second registration of the same token is a 409 `unique_violation` \u2014\n // atomically, under concurrency, without a read-then-write race.\n // NOTE: a unique value is capped at 256 characters. Expo tokens\n // (`ExponentPushToken[\u2026]`) and APNs device tokens are far inside that;\n // if you switch to raw FCM registration tokens, store a hash here and\n // keep the full token in an unindexed `text` field.\n token: { type: 'string', required: true, indexSlot: 's1', unique: true },\n end_user: { type: 'string', required: true, indexSlot: 's2' }, // the owner\n platform: { type: 'string', indexSlot: 's3', validation: { enum: ['ios', 'android', 'web'] } },\n // The device's language, captured at registration \u2014 this is what makes\n // a localized push possible without a second lookup per device.\n locale: { type: 'string', indexSlot: 's4' }, // 'en', 'ar', 'fr', \u2026\n // The receipt id returned by the LAST send. `prune-receipts` trades it\n // for a verdict and deletes the row when the service says the device\n // is gone.\n receipt_id: { type: 'string' },\n // When that receipt id was parked. The push service keeps a receipt\n // for a limited time; past RECEIPT_MAX_AGE the id is cleared as\n // unknowable instead of being asked about forever.\n receipt_at: { type: 'datetime' },\n // Consecutive delivery failures \u2014 a soft signal for your own dashboards\n // (the authoritative kill signal is the receipt, not this counter).\n failures: { type: 'int', indexSlot: 'n1', validation: { min: 0 } },\n last_seen: { type: 'datetime', indexSlot: 't1' }, // last registration/refresh\n checked_at: { type: 'datetime', indexSlot: 't2' }, // last receipt check\n last_error: { type: 'text' },\n },\n },\n\n // \u2605 INVARIANT 2, the app's half. An end-user session cannot see \u2014 let\n // alone re-own \u2014 a row that belongs to someone else, so when the app\n // registers a token another user holds, `register-token` files a CLAIM\n // here (owned by the caller) and answers 202. The `register-token`\n // cmsHook binding then runs in SERVER mode, moves the token, and deletes\n // the claim. Usually done within seconds.\n push_token_claims: {\n singular: 'push_token_claim',\n ownerField: 'end_user',\n fields: {\n token: { type: 'string', required: true, indexSlot: 's1' },\n end_user: { type: 'string', required: true, indexSlot: 's2' },\n platform: { type: 'string', indexSlot: 's3', validation: { enum: ['ios', 'android', 'web'] } },\n locale: { type: 'string', indexSlot: 's4' },\n },\n },\n\n // The reminder ladder's per-user state. Owner-scoped: the app reads and\n // writes its OWN row (opt out, set quiet hours, stamp `last_active` and\n // reset `ladder_step` to 0 whenever the app comes to the foreground).\n push_prefs: {\n singular: 'push_pref',\n ownerField: 'end_user',\n fields: {\n end_user: { type: 'string', required: true, indexSlot: 's1', unique: true },\n // IANA zone ('Asia/Riyadh'); quiet hours are evaluated in it. An\n // unknown zone is treated as UTC.\n timezone: { type: 'string', indexSlot: 's2' },\n // false = no reminders at all (absent = reminders on).\n reminders: { type: 'bool' },\n // Local hours [quiet_start, quiet_end) during which nothing is sent;\n // the window may wrap midnight (22 \u2192 8). Equal values = no quiet hours.\n quiet_start: { type: 'int', validation: { min: 0, max: 23 } },\n quiet_end: { type: 'int', validation: { min: 0, max: 23 } },\n last_active: { type: 'datetime', indexSlot: 't1' },\n // The next rung (0..3) since `last_active` (the app resets it to 0; the\n // `ladder_step_default` derive writes 0 when a write leaves it out).\n ladder_step: { type: 'int', indexSlot: 'n1', validation: { min: 0 } },\n last_reminded_at: { type: 'datetime', indexSlot: 't2' },\n },\n },\n\n // Where each cron left off (`key` = the job). No ownerField, so with\n // `strictEndUserScope` an end-user session cannot touch it at all.\n push_state: {\n singular: 'push_state',\n fields: {\n key: { type: 'string', required: true, indexSlot: 's1', unique: true },\n // the last item id walked; '' = start again from the newest row\n cursor: { type: 'string' },\n updated_at: { type: 'datetime', indexSlot: 't1' },\n },\n },\n },\n },\n\n functions: {\n // THE REGISTRY \u2014 one bundle, five front doors (a function is code + a SET\n // of trigger bindings):\n // \u2022 http POST /v1/fn/register-token { token, user_id?, platform?, locale? }\n // server mode (your backend) re-owns at once; end-user mode\n // (the app) files a claim for a token someone else holds \u2192 202.\n // \u2022 cmsHook a new `push_token_claims` row \u2192 re-own it in SERVER mode,\n // delete the claim. `retry` re-delivers a failed attempt.\n // \u2022 webhook `auth.user.erased` (the auth erase), `user.erased` (the\n // admin erase) and `user.deleted` (the admin delete) \u2192 delete\n // every device row, claim and preferences row of that user.\n // NOT `auth.session.revoked`: that fires on every refresh-token\n // rotation, and would sign devices out of push at random.\n 'register-token': {\n entry: './functions/register-token.ts',\n trigger: { kind: 'http' },\n triggers: [\n { kind: 'cmsHook', collection: 'push_token_claims', event: 'beforeCreate', retry: { maxAttempts: 3 } },\n { kind: 'webhook', source: 'auth.user.erased', retry: { maxAttempts: 3 } },\n { kind: 'webhook', source: 'user.erased', retry: { maxAttempts: 3 } },\n { kind: 'webhook', source: 'user.deleted', retry: { maxAttempts: 3 } },\n ],\n scopes: ['cms:read', 'cms:write'],\n egressAllow: [], // pure vxil-internal\n },\n\n // THE SENDER \u2014 two front doors:\n // \u2022 http POST /v1/fn/send-push { user_ids, messages, data? } \u2014 resolves\n // each user's live devices, picks the message for each device's\n // locale, and posts to the push service in batches.\n // \u2022 cron hourly: THE REMINDER LADDER. Users inactive 1 d / 3 d / 7 d get\n // at most one nudge per step (a user found already a week idle gets\n // only the 7-day one), never during their quiet hours, never after\n // opting out. Each step is claimed (If-Match) BEFORE the send, so\n // an overlapping tick can never send it twice.\n 'send-push': {\n entry: './functions/send-push.ts',\n trigger: { kind: 'http' },\n triggers: [{ kind: 'cron', schedule: '0 * * * *' }],\n scopes: ['cms:read', 'cms:write'],\n // Deny-by-default egress. `exp.host` is Expo's push host; `api.expo.dev`\n // is its newer alias. For raw FCM use 'fcm.googleapis.com'; for APNs,\n // 'api.push.apple.com' (and see README.md on the token-auth swap).\n egressAllow: ['exp.host', 'api.expo.dev'],\n // NO `secrets` here on purpose. Expo's push API needs no credential in its\n // default configuration, and secret resolution is FAIL-CLOSED: a declared\n // `secret:<name>` ref with no stored value makes every invocation\n // `409 secret_missing` before your code runs. Declare a secret only once\n // you will actually set it \u2014 see README.md \xA7\"If your push service needs a key\".\n },\n\n // \u2605 INVARIANT 3. THE GARBAGE COLLECTOR. A push service accepts a send and\n // only later tells you the device was gone \u2014 that verdict arrives as a\n // RECEIPT, not as the send response. Every 6 hours this cron trades parked\n // receipt ids for verdicts and deletes the rows the service declared dead.\n // It resumes where the last tick stopped (`push_state`), so a registry of\n // any size is walked end to end.\n 'prune-receipts': {\n entry: './functions/prune-receipts.ts',\n trigger: { kind: 'cron', schedule: '30 */6 * * *' },\n scopes: ['cms:read', 'cms:write'],\n egressAllow: ['exp.host', 'api.expo.dev'],\n },\n },\n});\n",
21067
- "readme": "# Push notifications (mobile)\n\nSending a push is the easy part: one HTTPS POST to your push service. What breaks in production is the\n**registry** \u2014 the mapping from a user to the devices they are currently holding.\n\nTokens rotate. Phones get sold. A user signs out and a colleague signs in on the same handset. Unless\nsomething reassigns that token, the previous owner's notifications keep arriving on someone else's lock\nscreen \u2014 a privacy incident, not a bug. And nobody collects delivery **receipts**, so the registry only\never grows: dead tokens are re-sent to forever and delivery rates look worse than they are.\n\nThis blueprint is that registry, with three invariants, plus the sending, the erasure clean-up and the\nre-engagement ladder every consumer app ends up writing. It is **the supported Expo path on vxil** \u2014\nvxil holds no push credential and runs no push service; your functions call Expo (which fronts APNs and\nFCM) through the egress allowlist. The whole mobile picture is in\nguide 17 \u2014 vxil for Expo apps (vxil.com/docs/guide/17-mobile-apps-with-expo).\n\n```bash\nmkdir my-push && cd my-push\nvxil init --template push-notifications\nvxil quickstart --env staging --no-push --invite <code> # --invite only for a new e-mail\n # (or `vxil link <slug> --env staging` for an existing backend)\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\nvxil push # four collections + three functions (two of them crons); writes vxil.types.ts\n```\n\nThree functions and two crons (the fastest hourly) on purpose: that is inside the Free plan's function\nlimits for staging and development projects (the per-plan table is in guide 12), so a free project runs\nall of it while you build the app. Every cron tick is bounded and resumable, so a tick the plan sheds is\nharmless \u2014 the next one picks up where the last stopped.\n\n> **Plan note.** The three functions deploy on the Free plan when the project's workload is `staging` or\n> `development` (the `vxil projects workload <slug> staging` step above; create the project with quickstart,\n> not `vxil projects create`, whose empty project cannot take this config's first push today). On a Free `production` project, `vxil push`\n> stops before it writes anything, naming the plan and the ways out: change the workload or upgrade\n> to Developer, or run `vxil push --skip-functions` to apply the collections and config without the\n> functions.\n\n## The three invariants\n\n| # | Invariant | Where it is enforced |\n|---|---|---|\n| **1** | **One token, one user** | `token: { unique: true }` \u2014 the *top-level* field attribute, enforced by vxil's uniqueness check. A second registration of the same token is a `409 unique_violation`, atomically, with no read-then-write race. |\n| **2** | **Re-own on change** | `functions/register-token.ts` \u2014 on that 409 it looks the row up and **PATCHes the owner** with `If-Match`. Reassign, never duplicate. From the app itself (end-user mode) the move is **async**: a claim row + a server-mode `cmsHook` (below). |\n| **3** | **Prune stale** | `functions/prune-receipts.ts` \u2014 a cron every 6 hours trades parked receipt ids for verdicts and **deletes** every row the service reports as `DeviceNotRegistered`. It resumes where the last tick stopped, so the **whole** registry is walked, whatever its size. |\n\nThere is no `validation: { unique: true }` \u2014 that key does not exist and would be silently ignored.\nUniqueness is the top-level `unique` attribute, on scalar field types only.\n\n## The registry\n\n```ts\npush_tokens: {\n ownerField: 'end_user', // a signed-in device sees only its own rows\n fields: {\n token: { type: 'string', required: true, indexSlot: 's1', unique: true },\n end_user: { type: 'string', required: true, indexSlot: 's2' },\n platform: { type: 'string', indexSlot: 's3' }, // ios | android | web\n locale: { type: 'string', indexSlot: 's4' }, // 'en', 'ar', 'pt-BR', \u2026\n receipt_id: { type: 'string' }, // the last send's pending verdict\n receipt_at: { type: 'datetime' }, // when it was parked (48 h \u2192 cleared)\n failures: { type: 'int', indexSlot: 'n1' },\n last_seen: { type: 'datetime', indexSlot: 't1' },\n checked_at: { type: 'datetime', indexSlot: 't2' },\n last_error: { type: 'text' },\n },\n}\n```\n\nBeside it: `push_token_claims` (the app's async re-own requests, owner-scoped), `push_prefs` (one row per\nuser for the reminder ladder, owner-scoped, `end_user` unique) and `push_state` (each cron's cursor \u2014 no\n`ownerField`, so no end-user session can touch it).\n\n`strictEndUserScope: true` is on, so a verified end-user session may only touch collections that declare\nan `ownerField`. `push_tokens` does \u2014 a device can refresh **its own** rows and structurally cannot\nenumerate anyone else's.\n\n> **The 256-character cap.** A unique value is capped at 256 characters. Expo tokens\n> (`ExponentPushToken[\u2026]`) and APNs device tokens are far inside it. If you switch to raw **FCM\n> registration tokens**, store a hash in `token` and keep the full value in an unindexed `text` field \u2014\n> otherwise long tokens are rejected at write time.\n\n## Registering \u2014 from your backend or from the app\n\n`POST /v1/fn/register-token { token, user_id?, platform?, locale? }`\n\nInvariant 2 needs to reassign a token **away from another user**, which needs a tenant-wide read. A\nfunction invoked with a verified end-user session gets **owner-scoped** callback tokens \u2014 the platform\nstamps the principal onto every scoped token the function mints downstream \u2014 so in end-user mode the\nother user's row is simply invisible. That is the fail-safe working, not a gap. So there are two paths:\n\n| Called from | `user_id` | A token another user holds | Response |\n|---|---|---|---|\n| **your backend** (server key) | from the body | re-owned at once (`If-Match`) | `200 {action:'reowned', previous_owner}` |\n| **the app** (end-user session) | the **verified** session id \u2014 a body `user_id` is ignored | a **claim** row in `push_token_claims`, owned by the caller | `202 {action:'reown_queued', claim_id}` |\n\nEither way a new token is `201 {action:'created'}` and an unchanged owner is `200 {action:'refreshed'}`.\nCalled from the app, the app's thin-client key needs `functions:invoke` (it is allowed on a public key).\n\n**The async re-own.** `register-token` declares a second binding,\n`{ kind: 'cmsHook', collection: 'push_token_claims', event: 'beforeCreate', retry: { maxAttempts: 3 } }`.\nA platform-delivered hook carries **no** end-user, so it runs in server mode: it re-reads the claim,\nre-owns the token row for the claimant (or creates it, if the old holder deleted it meanwhile), and\ndeletes the claim. It usually lands within seconds; a failed attempt answers non-2xx and the `retry`\nre-delivers it, and every step is idempotent (a claim that is already gone is done).\n\n**What a claim can and cannot do.** A claim can only name its caller as the new owner (the platform\nstamps the verified owner on the row), and a push token is a bearer credential only the device itself is\nhanded by the OS. Claiming a token you do not hold moves **your** notifications to that device \u2014 no data\nreaches you. The trade-off to know about is availability: a signed-in user who somehow learned another\nuser's Expo token could move that device away from its owner, who then stops receiving pushes (security\nalerts included) until their app starts again and re-registers it. Tokens are not shown to other users\nby anything in this kit, so that needs the token to have leaked. If your app treats pushes as critical,\nadd a `validate` hook on `push_token_claims` that caps open claims, or have the app re-register on every\nforeground (it is idempotent \u2014 `200 {action:'refreshed'}` costs one write).\n\n**Sign-out.** A device that signs out should stop receiving the old user's pushes before anyone else\nsigns in: delete the device's own row from the app (`DELETE /v1/cms/items/push_tokens/{item_id}` with\nthe session \u2014 an owner may delete its own rows), or just let the next sign-in re-own it.\n\n## Erasure and deletion \u2014 `auth.user.erased`, not `session.revoked`\n\n`register-token` also binds `{ kind: 'webhook', source: 'auth.user.erased' }`,\n`{ kind: 'webhook', source: 'user.erased' }` (the admin `DELETE /v1/users/{id}?erase=true`) and\n`{ kind: 'webhook', source: 'user.deleted' }` (the admin delete without erase). That user loses every\n`push_tokens` row, pending claim and their `push_prefs` row (time zone, quiet hours, activity) \u2014 device\ntokens and preferences are personal data, vxil's erasure scrubs its own tables, not your collections,\nand a deleted account must stop receiving reminder nudges.\n\nA **merge or rekey** (`auth.user.merged` / `auth.user.rekeyed` \u2014 a guest becoming a registered user)\nneeds no binding: the app's next start registers its token under the new id, which re-owns the device.\nThe old id's prefs row goes quiet on its own (it has no devices left to nudge).\n\nDo **not** clean up on `auth.session.revoked`: it fires on every refresh-token rotation as well as on\nsign-out, so a cleanup bound to it would unregister devices at random.\n\n## Sending \u2014 localized per device, not per user\n\n`POST /v1/fn/send-push`\n\n```json\n{\n \"user_ids\": [\"usr_a\", \"usr_b\"],\n \"messages\": {\n \"default\": { \"title\": \"You're in\", \"body\": \"Pro is active on this device.\" },\n \"ar\": { \"title\": \"\u062A\u0645 \u0627\u0644\u062A\u0641\u0639\u064A\u0644\", \"body\": \"\u0623\u0635\u0628\u062D \u0627\u0634\u062A\u0631\u0627\u0643 Pro \u0645\u0641\u0639\u0651\u0644\u0627\u064B.\" },\n \"fr\": { \"title\": \"C'est actif\", \"body\": \"Pro est activ\xE9 sur cet appareil.\" }\n },\n \"data\": { \"screen\": \"billing\" }\n}\n```\n\nOne person can carry an English phone and an Arabic tablet. Because `locale` lives on the **token row**,\neach device gets the right copy with no extra lookup \u2014 exact match (`pt-BR`), then the base language\n(`pt`), then `default`.\n\nThe response distinguishes the two failure kinds, because they call for opposite actions:\n\n- an **immediate** `DeviceNotRegistered` \u2192 the token is dead now; the row is deleted;\n- anything else (rate limits, a message too big, a transient fault) \u2192 counted in `failures`, device kept.\n Deleting a device because one message was malformed is how a registry loses real users.\n- an **accepted** message returns a *receipt id*, not a delivery. That id is parked on the row, and the\n real verdict is collected by the cron below.\n\n## The receipts cron \u2014 the loop almost nobody closes\n\n`prune-receipts` runs at `30 */6 * * *`. It resumes the walk of `push_tokens` where the previous tick\nstopped (its cursor lives in `push_state`), collects the rows carrying a `receipt_id`, asks the push\nservice for those receipts in batches, and acts \u2014 `ok` clears the id, `DeviceNotRegistered` deletes the\nrow, anything else counts a failure and keeps the device. A receipt that is not ready yet is simply\nabsent from the response, which is not an error; it is asked again on the next walk. A receipt id parked\nfor more than 48 hours with no answer is cleared \u2014 the service no longer keeps it, and asking forever\nwould pin the row.\n\nEverything is bounded: one tick reads at most 2,000 rows and acts on at most 300, so its outbound calls\nstay in the low hundreds. Because the cursor persists, a registry of **any** size is walked end to end\nover successive ticks; when the walk reaches the oldest row the cursor resets and the next tick starts\nagain from the newest (rows registered during a walk are covered by the next one).\n\n> **A paging detail worth stealing.** The cron pages with `?cursor=` and **no** `?sort=`. In the\n> default order (newest first) the cursor is a plain item id, so it stays valid across ticks \u2014 persist it\n> and the walk resumes exactly where it stopped.\n\n**Never clears a newer receipt.** Each clear is a `PATCH` with `If-Match` on the row version the walk\nread. If `send-push` parked a newer receipt id on the same device in between, the clear is refused and\ncounted as `raced`; the newer id is checked on the next walk.\n\n## The reminder ladder \u2014 re-engagement that respects the user\n\n`send-push` has a second binding, `{ kind: 'cron', schedule: '0 * * * *' }`. Every hour it nudges users\nwho stopped opening the app: once after **1 day**, once after **3**, once after **7** \u2014 then silence until\nthey come back. The copy lives in the `LADDER` constant at the top of `functions/send-push.ts` (per\nlocale, picked per device like any send).\n\nIt reads `push_prefs`, a row per user **the app writes itself** (owner-scoped):\n\n| Field | Meaning |\n|---|---|\n| `reminders` | `false` = never. Absent = on. |\n| `timezone` | IANA zone (`Asia/Riyadh`); quiet hours are evaluated in it. Unknown = UTC. |\n| `quiet_start`, `quiet_end` | local hours `[start, end)` with nothing sent; may wrap midnight (`22` \u2192 `8`). Equal = none. |\n| `last_active` | stamp it whenever the app comes to the foreground\u2026 |\n| `ladder_step` | \u2026and reset this to `0` at the same time. The cron advances it. Left out on a write, a `derive` hook stores `0`. |\n\n```ts\n// in the app, on AppState 'active' (end-user session)\nawait vx.from('push_prefs').patch(prefsId, { last_active: new Date().toISOString(), ladder_step: 0 });\n```\n\nThree guarantees:\n\n- **Only idle users are read.** The walk filters on `last_active` and `ladder_step < 3` (both indexed),\n so active users and users who finished the ladder cost nothing; it resumes where the last tick stopped\n (`push_state`), reading at most 2,000 rows and nudging at most 100 users per tick. Users who opted out\n are still read (an absent `reminders` means on, which a filter cannot express) and skipped.\n- **Never twice, never a burst.** Each step is claimed on the prefs row with `If-Match` **before** the\n send \u2014 two overlapping ticks cannot both send it. A send that fails after the claim is a missed nudge,\n never a duplicate one. When several rungs are due at once (the kit added to a live app, reminders\n turned back on, a cron that was held), only the **highest** is sent and the ones below are skipped; and\n a rung waits its own gap after the previous nudge (`last_reminded_at`), so the 3-day and 7-day nudges\n stay two and four days after the one before.\n- **Never at night.** A user inside their quiet hours is skipped and picked up by a later tick once the\n window has passed (the next one, unless the walk spans more than one tick).\n\n## Example: post-purchase push on `payments.entitlement.changed`\n\nThe payments feature writes `payments.entitlement.changed` to your audit stream on every entitlement\ntransition, carrying `end_user_id`, the previous and new tier, `status`, `until`, `reason` and\n`environment`. Turning that into \"your Pro plan is live, in the language of the device\" is a wiring\nexercise, not new code \u2014 there are two shapes, both using the same `send-push` call:\n\n**(a) Subscribe and call in.** Declare the outbound subscription under `features` in `vxil.config.ts`, and `vxil push`\nconverges it by `target_url` (declared API state \u2014 the funnel-saas blueprint has the whole table):\n\n```ts\nwebhooks: {\n enabled: true,\n subscriptions: [\n { target_url: 'https://your-app.example.com/hooks/vxil', event_prefixes: ['payments.entitlement.'] },\n ],\n},\n```\n\nor create it at runtime as a row you manage yourself.\n\nEither way the key needs `webhooks:read` and `webhooks:write`. The project key quickstart minted for\nthis blueprint (cms, functions) has neither, so `vxil push` reports `webhooks: 403 insufficient_scope`\nand the curl is refused. Start from `vxil quickstart --features cms,functions,webhooks`, or (after\n`vxil login`) mint one with `vxil keys mint --name hooks --scopes webhooks:read,webhooks:write --json`\nand use it as `$HOOKS_KEY`:\n\n```bash\ncurl -X POST https://api.vxil.com/v1/webhooks/subscriptions \\\n -H \"authorization: Bearer $HOOKS_KEY\" -H 'content-type: application/json' \\\n -d '{\"target_url\":\"https://your-app.example.com/hooks/vxil\",\"event_prefixes\":[\"payments.entitlement.\"]}'\n```\n\nYour endpoint verifies the signed delivery, then calls `send-push` with a key holding\n`functions:invoke`:\n\n```ts\n// inside your webhook receiver \u2014 event.payload.end_user_id / .tier are the fields above\nawait fetch('https://api.vxil.com/v1/fn/send-push', {\n method: 'POST',\n headers: { authorization: `Bearer ${process.env.VXIL_API_KEY}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n user_ids: [event.payload.end_user_id],\n messages: {\n default: { title: 'You\\'re in', body: `${event.payload.tier} is active on this device.` },\n ar: { title: '\u062A\u0645 \u0627\u0644\u062A\u0641\u0639\u064A\u0644', body: `\u062A\u0645 \u062A\u0641\u0639\u064A\u0644 ${event.payload.tier} \u0639\u0644\u0649 \u0647\u0630\u0627 \u0627\u0644\u062C\u0647\u0627\u0632.` },\n },\n data: { screen: 'billing', tier: event.payload.tier },\n }),\n});\n```\n\n**(b) Drain the audit stream on a cron.** If you would rather keep everything inside your backend, the\nwatermark-plus-allow-list pattern in the **alerts-to-slack** blueprint reads the same\nevents with `GET /v1/audit/export?after_id=\u2026` and needs no public endpoint at all. Same events, no\ninbound surface.\n\nWhichever you pick, send on the **`from_tier \u2192 tier` crossing**, not on every event: entitlement snapshots\nare refolded on redelivery, and \"Pro is active\" three times is how an app gets its notifications muted.\n\n## Swapping the push service\n\nThe blueprint targets Expo because it fronts **both** APNs and FCM with one token format. To swap:\n\n| Service | `PUSH_URL` | `egressAllow` | Also change |\n|---|---|---|---|\n| Expo (default) | `https://exp.host/--/api/v2/push/send` | `exp.host`, `api.expo.dev` | \u2014 |\n| FCM (HTTP v1) | the HTTP v1 send method under `https://fcm.googleapis.com/v1/projects/<id>/` \u2014 the exact URL is in `functions/send-push.ts` | `fcm.googleapis.com` | one message per request; an OAuth bearer; the `message.notification` body shape; hash long tokens (see the 256-char cap above) |\n| APNs | `https://api.push.apple.com/3/device/<token>` | `api.push.apple.com` | one request per device; a JWT `authorization`; an `apns-topic` header; the `aps` payload shape |\n\n### If your push service needs a key\n\nAdd the ref to the function and set the value:\n\n```ts\n'send-push': { /* \u2026 */ secrets: ['secret:push_api_key'] }\n```\n```bash\nprintf '%s' \"$KEY\" | vxil secrets set functions/push_api_key\n```\n\nIt resolves per invocation as `envelope.secrets.push_api_key`, so **rotating it needs no redeploy**. One\nwarning: resolution is **fail-closed**. A declared `secret:<name>` ref with no stored value makes every\ninvocation `409 secret_missing` before your code runs \u2014 which is why this blueprint declares no secrets\nat all by default. Declare one only when you will actually set it.\n\n## What to learn from this\n\n- **The hard part of push is ownership, not delivery.** A `unique` field plus one re-own path is the\n whole difference between a registry that stays correct and one that leaks notifications between users.\n- **A verified principal beats a body field, always.** The platform hands the function `end_user.id`; the\n request body is a suggestion.\n- **Delete on the verdict, not on the error.** `DeviceNotRegistered` is the only signal that justifies\n removing a device. Everything else is about the message.\n- **What an end-user cannot do, a server-mode hook can \u2014 on the user's request.** The claim row is the\n request; the `cmsHook` is the authority. No backend of your own is needed for it.\n- **A cron that cannot finish in one tick must remember where it stopped.** Restarting from the top of\n a newest-first list means the oldest rows are never reached.\n- **One function, several front doors.** `triggers: [...]` puts an http door, a hook and a cron on the\n same bundle \u2014 the code that owns a job keeps all of its entry points.\n\n**Pairs with:** the **alerts-to-slack** blueprint for the audit-stream drain used in\nexample (b).\n",
21362
+ "readme": "# Push notifications (mobile)\n\nSending a push is the easy part: one HTTPS POST to your push service. What breaks in production is the\n**registry** \u2014 the mapping from a user to the devices they are currently holding.\n\nTokens rotate. Phones get sold. A user signs out and a colleague signs in on the same handset. Unless\nsomething reassigns that token, the previous owner's notifications keep arriving on someone else's lock\nscreen \u2014 a privacy incident, not a bug. And nobody collects delivery **receipts**, so the registry only\never grows: dead tokens are re-sent to forever and delivery rates look worse than they are.\n\nThis blueprint is that registry, with three invariants, plus the sending, the erasure clean-up and the\nre-engagement ladder every consumer app ends up writing. It is **the supported Expo path on vxil** \u2014\nvxil holds no push credential and runs no push service; your functions call Expo (which fronts APNs and\nFCM) through the egress allowlist. The whole mobile picture is in\nguide 17 \u2014 vxil for Expo apps (vxil.com/docs/guide/17-mobile-apps-with-expo).\n\n```bash\nmkdir my-push && cd my-push\nvxil init --template push-notifications\nvxil quickstart --env staging --no-push --invite <code> # --invite only for a new e-mail\n # (or `vxil link <slug> --env staging` for an existing backend)\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\nvxil push # four collections + three functions (two of them crons); writes vxil.types.ts\n```\n\nThree functions and two crons (the fastest hourly) on purpose: that is inside the Free plan's function\nlimits for staging and development projects (the per-plan table is in guide 12), so a free project runs\nall of it while you build the app. Every cron tick is bounded and resumable, so a tick the plan sheds is\nharmless \u2014 the next one picks up where the last stopped.\n\n> **Plan note.** The three functions deploy on the Free plan when the project's workload is `staging` or\n> `development` (the `vxil projects workload <slug> staging` step above; create the project with quickstart,\n> not `vxil projects create`, whose empty project cannot take this config's first push today). On a Free `production` project, `vxil push`\n> stops before it writes anything, naming the plan and the ways out: change the workload or upgrade\n> to Developer, or run `vxil push --skip-functions` to apply the collections and config without the\n> functions.\n\n## The three invariants\n\n| # | Invariant | Where it is enforced |\n|---|---|---|\n| **1** | **One token, one user** | `token: { unique: true }` \u2014 the *top-level* field attribute, enforced by vxil's uniqueness check. A second registration of the same token is a `409 unique_violation`, atomically, with no read-then-write race. |\n| **2** | **Re-own on change** | `functions/register-token.ts` \u2014 on that 409 it looks the row up and **PATCHes the owner** with `If-Match`. Reassign, never duplicate. From the app itself (end-user mode) the move is **async**: a claim row + a server-mode `cmsHook` (below). |\n| **3** | **Prune stale** | `functions/prune-receipts.ts` \u2014 a cron every 6 hours trades parked receipt ids for verdicts and **deletes** every row the service reports as `DeviceNotRegistered`. It resumes where the last tick stopped, so the **whole** registry is walked, whatever its size. |\n\nThere is no `validation: { unique: true }` \u2014 that key does not exist and would be silently ignored.\nUniqueness is the top-level `unique` attribute, on scalar field types only.\n\n## The registry\n\n```ts\npush_tokens: {\n ownerField: 'end_user', // a signed-in device sees only its own rows\n fields: {\n token: { type: 'string', required: true, indexSlot: 's1', unique: true },\n end_user: { type: 'string', required: true, indexSlot: 's2' },\n platform: { type: 'string', indexSlot: 's3' }, // ios | android | web\n locale: { type: 'string', indexSlot: 's4' }, // 'en', 'ar', 'pt-BR', \u2026\n receipt_id: { type: 'string' }, // the last send's pending verdict\n receipt_at: { type: 'datetime' }, // when it was parked (48 h \u2192 cleared)\n failures: { type: 'int', indexSlot: 'n1' },\n last_seen: { type: 'datetime', indexSlot: 't1' },\n checked_at: { type: 'datetime', indexSlot: 't2' },\n last_error: { type: 'text' },\n },\n}\n```\n\nBeside it: `push_token_claims` (the app's async re-own requests, owner-scoped), `push_prefs` (one row per\nuser for the reminder ladder, owner-scoped, `end_user` unique) and `push_state` (each cron's cursor \u2014 no\n`ownerField`, so no end-user session can touch it).\n\n`strictEndUserScope: true` is on, so a verified end-user session may only touch collections that declare\nan `ownerField`. `push_tokens` does \u2014 a device can refresh **its own** rows and structurally cannot\nenumerate anyone else's.\n\n> **The 256-character cap.** A unique value is capped at 256 characters. Expo tokens\n> (`ExponentPushToken[\u2026]`) and APNs device tokens are far inside it. If you switch to raw **FCM\n> registration tokens**, store a hash in `token` and keep the full value in an unindexed `text` field \u2014\n> otherwise long tokens are rejected at write time.\n\n## Registering \u2014 from your backend or from the app\n\n`POST /v1/fn/register-token { token, user_id?, platform?, locale? }`\n\nInvariant 2 needs to reassign a token **away from another user**, which needs a tenant-wide read. A\nfunction invoked with a verified end-user session gets **owner-scoped** callback tokens \u2014 the platform\nstamps the principal onto every scoped token the function mints downstream \u2014 so in end-user mode the\nother user's row is simply invisible. That is the fail-safe working, not a gap. So there are two paths:\n\n| Called from | `user_id` | A token another user holds | Response |\n|---|---|---|---|\n| **your backend** (server key) | from the body | re-owned at once (`If-Match`) | `200 {action:'reowned', previous_owner}` |\n| **the app** (end-user session) | the **verified** session id \u2014 a body `user_id` is ignored | a **claim** row in `push_token_claims`, owned by the caller | `202 {action:'reown_queued', claim_id}` |\n\nEither way a new token is `201 {action:'created'}` and an unchanged owner is `200 {action:'refreshed'}`.\nCalled from the app, the app's thin-client key needs `functions:invoke` (it is allowed on a public key).\n\n**The async re-own.** `register-token` declares a second binding,\n`{ kind: 'cmsHook', collection: 'push_token_claims', event: 'beforeCreate', retry: { maxAttempts: 3 } }`.\nA platform-delivered hook carries **no** end-user, so it runs in server mode: it re-reads the claim,\nre-owns the token row for the claimant (or creates it, if the old holder deleted it meanwhile), and\ndeletes the claim. It usually lands within seconds; a failed attempt answers non-2xx and the `retry`\nre-delivers it, and every step is idempotent (a claim that is already gone is done).\n\n**What a claim can and cannot do.** A claim can only name its caller as the new owner (the platform\nstamps the verified owner on the row), and a push token is a bearer credential only the device itself is\nhanded by the OS. Claiming a token you do not hold moves **your** notifications to that device \u2014 no data\nreaches you. The trade-off to know about is availability: a signed-in user who somehow learned another\nuser's Expo token could move that device away from its owner, who then stops receiving pushes (security\nalerts included) until their app starts again and re-registers it. Tokens are not shown to other users\nby anything in this kit, so that needs the token to have leaked. If your app treats pushes as critical,\nadd a `validate` hook on `push_token_claims` that caps open claims, or have the app re-register on every\nforeground (it is idempotent \u2014 `200 {action:'refreshed'}` costs one write).\n\n**Sign-out.** A device that signs out should stop receiving the old user's pushes before anyone else\nsigns in: delete the device's own row from the app (`DELETE /v1/cms/items/push_tokens/{item_id}` with\nthe session \u2014 an owner may delete its own rows), or just let the next sign-in re-own it.\n\n## Erasure and deletion \u2014 `auth.user.erased`, not `session.revoked`\n\n`register-token` also binds `{ kind: 'webhook', source: 'auth.user.erased' }`,\n`{ kind: 'webhook', source: 'user.erased' }` (the admin `DELETE /v1/users/{id}?erase=true`) and\n`{ kind: 'webhook', source: 'user.deleted' }` (the admin delete without erase). That user loses every\n`push_tokens` row, pending claim and their `push_prefs` row (time zone, quiet hours, activity) \u2014 device\ntokens and preferences are personal data, vxil's erasure scrubs its own tables, not your collections,\nand a deleted account must stop receiving reminder nudges.\n\nA **merge or rekey** (`auth.user.merged` / `auth.user.rekeyed` \u2014 a guest becoming a registered user)\nneeds no binding: the app's next start registers its token under the new id, which re-owns the device.\nThe old id's prefs row goes quiet on its own (it has no devices left to nudge).\n\nDo **not** clean up on `auth.session.revoked`: it fires on every refresh-token rotation as well as on\nsign-out, so a cleanup bound to it would unregister devices at random.\n\n## Sending \u2014 localized per device, not per user\n\n`POST /v1/fn/send-push` \u2014 **server keys and the cron only**: a call made with an end user's session answers `403`. Deploying the kit without `send-push` (sending from your own server instead) is supported; the re-own lives in `register-token`.\n\n```json\n{\n \"user_ids\": [\"usr_a\", \"usr_b\"],\n \"messages\": {\n \"default\": { \"title\": \"You're in\", \"body\": \"Pro is active on this device.\" },\n \"ar\": { \"title\": \"\u062A\u0645 \u0627\u0644\u062A\u0641\u0639\u064A\u0644\", \"body\": \"\u0623\u0635\u0628\u062D \u0627\u0634\u062A\u0631\u0627\u0643 Pro \u0645\u0641\u0639\u0651\u0644\u0627\u064B.\" },\n \"fr\": { \"title\": \"C'est actif\", \"body\": \"Pro est activ\xE9 sur cet appareil.\" }\n },\n \"data\": { \"screen\": \"billing\" }\n}\n```\n\nOne person can carry an English phone and an Arabic tablet. Because `locale` lives on the **token row**,\neach device gets the right copy with no extra lookup \u2014 exact match (`pt-BR`), then the base language\n(`pt`), then `default`.\n\nThe response distinguishes the two failure kinds, because they call for opposite actions:\n\n- an **immediate** `DeviceNotRegistered` \u2192 the token is dead now; the row is deleted;\n- anything else (rate limits, a message too big, a transient fault) \u2192 counted in `failures`, device kept.\n Deleting a device because one message was malformed is how a registry loses real users.\n- an **accepted** message returns a *receipt id*, not a delivery. That id is parked on the row, and the\n real verdict is collected by the cron below.\n\n## The receipts cron \u2014 the loop almost nobody closes\n\n`prune-receipts` runs at `30 */6 * * *`. It resumes the walk of `push_tokens` where the previous tick\nstopped (its cursor lives in `push_state`), collects the rows carrying a `receipt_id`, asks the push\nservice for those receipts in batches, and acts \u2014 `ok` clears the id, `DeviceNotRegistered` deletes the\nrow, anything else counts a failure and keeps the device. A receipt that is not ready yet is simply\nabsent from the response, which is not an error; it is asked again on the next walk. A receipt id parked\nfor more than 48 hours with no answer is cleared \u2014 the service no longer keeps it, and asking forever\nwould pin the row.\n\nEverything is bounded: one tick reads at most 2,000 rows and acts on at most 300, so its outbound calls\nstay in the low hundreds. Because the cursor persists, a registry of **any** size is walked end to end\nover successive ticks; when the walk reaches the oldest row the cursor resets and the next tick starts\nagain from the newest (rows registered during a walk are covered by the next one).\n\n> **A paging detail worth stealing.** The cron pages with `?cursor=` and **no** `?sort=`. In the\n> default order (newest first) the cursor is a plain item id, so it stays valid across ticks \u2014 persist it\n> and the walk resumes exactly where it stopped.\n\n**Never clears a newer receipt.** Each clear is a `PATCH` with `If-Match` on the row version the walk\nread. If `send-push` parked a newer receipt id on the same device in between, the clear is refused and\ncounted as `raced`; the newer id is checked on the next walk.\n\n## The reminder ladder \u2014 re-engagement that respects the user\n\n`send-push` has a second binding, `{ kind: 'cron', schedule: '0 * * * *' }`. Every hour it nudges users\nwho stopped opening the app: once after **1 day**, once after **3**, once after **7** \u2014 then silence until\nthey come back. The copy lives in the `LADDER` constant at the top of `functions/send-push.ts` (per\nlocale, picked per device like any send).\n\nIt reads `push_prefs`, a row per user **the app writes itself** (owner-scoped):\n\n| Field | Meaning |\n|---|---|\n| `reminders` | `false` = never. Absent = on. |\n| `timezone` | IANA zone (`Asia/Riyadh`); quiet hours are evaluated in it. Unknown = UTC. |\n| `quiet_start`, `quiet_end` | local hours `[start, end)` with nothing sent; may wrap midnight (`22` \u2192 `8`). Equal = none. |\n| `last_active` | stamp it whenever the app comes to the foreground\u2026 |\n| `ladder_step` | \u2026and reset this to `0` at the same time. The cron advances it. Left out on a write, a `derive` hook stores `0`. |\n\n```ts\n// in the app, on AppState 'active' (end-user session)\nawait vx.from('push_prefs').patch(prefsId, { last_active: new Date().toISOString(), ladder_step: 0 });\n```\n\nThree guarantees:\n\n- **Only idle users are read.** The walk filters on `last_active` and `ladder_step < 3` (both indexed),\n so active users and users who finished the ladder cost nothing; it resumes where the last tick stopped\n (`push_state`), reading at most 2,000 rows and nudging at most 100 users per tick. Users who opted out\n are still read (an absent `reminders` means on, which a filter cannot express) and skipped.\n- **Never twice, never a burst.** Each step is claimed on the prefs row with `If-Match` **before** the\n send \u2014 two overlapping ticks cannot both send it. A send that fails after the claim is a missed nudge,\n never a duplicate one. When several rungs are due at once (the kit added to a live app, reminders\n turned back on, a cron that was held), only the **highest** is sent and the ones below are skipped; and\n a rung waits its own gap after the previous nudge (`last_reminded_at`), so the 3-day and 7-day nudges\n stay two and four days after the one before.\n- **Never at night.** A user inside their quiet hours is skipped and picked up by a later tick once the\n window has passed (the next one, unless the walk spans more than one tick).\n\n## Example: post-purchase push on `payments.entitlement.changed`\n\nThe payments feature writes `payments.entitlement.changed` to your audit stream on every entitlement\ntransition, carrying `end_user_id`, the previous and new tier, `status`, `until`, `reason` and\n`environment`. Turning that into \"your Pro plan is live, in the language of the device\" is a wiring\nexercise, not new code \u2014 there are two shapes, both using the same `send-push` call:\n\n**(a) Subscribe and call in.** Declare the outbound subscription under `features` in `vxil.config.ts`, and `vxil push`\nconverges it by `target_url` (declared API state \u2014 the funnel-saas blueprint has the whole table):\n\n```ts\nwebhooks: {\n enabled: true,\n subscriptions: [\n { target_url: 'https://your-app.example.com/hooks/vxil', event_prefixes: ['payments.entitlement.'] },\n ],\n},\n```\n\nor create it at runtime as a row you manage yourself.\n\nEither way the key needs `webhooks:read` and `webhooks:write`. The project key quickstart minted for\nthis blueprint (cms, functions) has neither, so `vxil push` reports `webhooks: 403 insufficient_scope`\nand the curl is refused. Start from `vxil quickstart --features cms,functions,webhooks`, or (after\n`vxil login`) mint one with `vxil keys mint --name hooks --scopes webhooks:read,webhooks:write --json`\nand use it as `$HOOKS_KEY`:\n\n```bash\ncurl -X POST https://api.vxil.com/v1/webhooks/subscriptions \\\n -H \"authorization: Bearer $HOOKS_KEY\" -H 'content-type: application/json' \\\n -d '{\"target_url\":\"https://your-app.example.com/hooks/vxil\",\"event_prefixes\":[\"payments.entitlement.\"]}'\n```\n\nYour endpoint verifies the signed delivery, then calls `send-push` with a key holding\n`functions:invoke`:\n\n```ts\n// inside your webhook receiver \u2014 event.payload.end_user_id / .tier are the fields above\nawait fetch('https://api.vxil.com/v1/fn/send-push', {\n method: 'POST',\n headers: { authorization: `Bearer ${process.env.VXIL_API_KEY}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n user_ids: [event.payload.end_user_id],\n messages: {\n default: { title: 'You\\'re in', body: `${event.payload.tier} is active on this device.` },\n ar: { title: '\u062A\u0645 \u0627\u0644\u062A\u0641\u0639\u064A\u0644', body: `\u062A\u0645 \u062A\u0641\u0639\u064A\u0644 ${event.payload.tier} \u0639\u0644\u0649 \u0647\u0630\u0627 \u0627\u0644\u062C\u0647\u0627\u0632.` },\n },\n data: { screen: 'billing', tier: event.payload.tier },\n }),\n});\n```\n\n**(b) Drain the audit stream on a cron.** If you would rather keep everything inside your backend, the\nwatermark-plus-allow-list pattern in the **alerts-to-slack** blueprint reads the same\nevents with `GET /v1/audit/export?after_id=\u2026` and needs no public endpoint at all. Same events, no\ninbound surface.\n\nWhichever you pick, send on the **`from_tier \u2192 tier` crossing**, not on every event: entitlement snapshots\nare refolded on redelivery, and \"Pro is active\" three times is how an app gets its notifications muted.\n\n## Swapping the push service\n\nThe blueprint targets Expo because it fronts **both** APNs and FCM with one token format. To swap:\n\n| Service | `PUSH_URL` | `egressAllow` | Also change |\n|---|---|---|---|\n| Expo (default) | `https://exp.host/--/api/v2/push/send` | `exp.host`, `api.expo.dev` | \u2014 |\n| FCM (HTTP v1) | the HTTP v1 send method under `https://fcm.googleapis.com/v1/projects/<id>/` \u2014 the exact URL is in `functions/send-push.ts` | `fcm.googleapis.com` | one message per request; an OAuth bearer; the `message.notification` body shape; hash long tokens (see the 256-char cap above) |\n| APNs | `https://api.push.apple.com/3/device/<token>` | `api.push.apple.com` | one request per device; a JWT `authorization`; an `apns-topic` header; the `aps` payload shape |\n\n### If your push service needs a key\n\nAdd the ref to the function and set the value:\n\n```ts\n'send-push': { /* \u2026 */ secrets: ['secret:push_api_key'] }\n```\n```bash\nprintf '%s' \"$KEY\" | vxil secrets set functions/push_api_key\n```\n\nIt resolves per invocation as `envelope.secrets.push_api_key`, so **rotating it needs no redeploy**. One\nwarning: resolution is **fail-closed**. A declared `secret:<name>` ref with no stored value makes every\ninvocation `409 secret_missing` before your code runs \u2014 which is why this blueprint declares no secrets\nat all by default. Declare one only when you will actually set it.\n\n## What to learn from this\n\n- **The hard part of push is ownership, not delivery.** A `unique` field plus one re-own path is the\n whole difference between a registry that stays correct and one that leaks notifications between users.\n- **A verified principal beats a body field, always.** The platform hands the function `end_user.id`; the\n request body is a suggestion.\n- **Delete on the verdict, not on the error.** `DeviceNotRegistered` is the only signal that justifies\n removing a device. Everything else is about the message.\n- **What an end-user cannot do, a server-mode hook can \u2014 on the user's request.** The claim row is the\n request; the `cmsHook` is the authority. No backend of your own is needed for it.\n- **A cron that cannot finish in one tick must remember where it stopped.** Restarting from the top of\n a newest-first list means the oldest rows are never reached.\n- **One function, several front doors.** `triggers: [...]` puts an http door, a hook and a cron on the\n same bundle \u2014 the code that owns a job keeps all of its entry points.\n\n**Pairs with:** the **alerts-to-slack** blueprint for the audit-stream drain used in\nexample (b).\n",
21068
21363
  "functions": {
21069
21364
  "prune-receipts.ts": "// prune-receipts.ts \u2014 THE GARBAGE COLLECTOR (a vxil function, cron trigger).\n//\n// Trigger: cron `30 */6 * * *` (every 6 hours).\n//\n// A push service accepts a message and answers \"ok\" long before it knows whether\n// the device still exists. The real verdict arrives later, as a RECEIPT keyed by\n// the id the send returned. Almost nobody collects them, which is why push\n// registries only ever grow: dead tokens are re-sent to forever, quota is spent\n// on handsets that were wiped a year ago, and delivery rates look worse than\n// they are.\n//\n// This cron closes that loop:\n// 1. resume the walk of `push_tokens` where the previous tick stopped (the\n// cursor lives in `push_state`), collecting rows that carry a `receipt_id`,\n// 2. ask the push service for those receipts, in batches,\n// 3. `DeviceNotRegistered` \u2192 DELETE the row (\u2605 invariant 3),\n// any other error \u2192 count it and keep the device,\n// ok \u2192 clear the receipt id; there is nothing left to check,\n// no receipt and older than RECEIPT_MAX_AGE_H \u2192 clear it (the service no\n// longer has it; asking forever would pin the row),\n// Every clear is a PATCH with If-Match on the row version that was read:\n// if `send-push` parked a NEWER receipt id on the device meanwhile, the\n// clear is refused (409) and the newer id stays for the next walk,\n// 4. save the cursor \u2014 or '' when the walk reached the oldest row, so the\n// next tick starts again from the newest.\n//\n// Everything is bounded: one tick reads at most MAX_ROWS rows and acts on at\n// most MAX_ACTIONS of them, so its outbound calls stay in the low hundreds.\n// Because the cursor persists, a registry of ANY size is walked end to end over\n// successive ticks \u2014 MAX_ROWS \xD7 4 ticks a day. (The walk follows the default\n// newest-first order; rows registered while a walk is in progress are covered\n// by the next walk.)\n\n// cron-walk: persisted-cursor \u2014 the walk's cursor lives in `push_state` and is saved with If-Match.\n\nimport type { CronFunctionEnvelope } from '@vxil/sdk';\n\nconst RECEIPTS_URL = 'https://exp.host/--/api/v2/push/getReceipts';\n/** Receipt ids per request. */\nconst RECEIPT_BATCH = 300;\n/** Token rows examined per cron tick. */\nconst MAX_ROWS = 2000;\n/** Rows with a pending receipt acted on per tick (each costs one PATCH or DELETE). */\nconst MAX_ACTIONS = 300;\n/** A receipt id parked longer than this is cleared as unknowable. */\nconst RECEIPT_MAX_AGE_H = 48;\n/** cms page size \u2014 100 is the default `maxPageSize` for the cms feature. */\nconst PAGE = 100;\n/** This cron's row in `push_state`. */\nconst STATE_KEY = 'prune-receipts';\n\ntype Env = CronFunctionEnvelope;\ninterface TokenRow {\n item_id: string;\n version?: number;\n data: { token?: string; receipt_id?: string; receipt_at?: string; failures?: number };\n}\ninterface Receipt { status?: string; message?: string; details?: { error?: string } }\ninterface StateRow { item_id: string; version?: number; data: { cursor?: string } }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n if (!cms) return Response.json({ error: 'missing cms scope' }, { status: 403 });\n const H = { authorization: `Bearer ${cms}`, 'content-type': 'application/json' };\n\n // \u2500\u2500 1. Resume the walk; collect pending receipts. \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n const state = await readState(base, H, STATE_KEY);\n if (state === 'error') return Response.json({ error: 'state_read_failed' }, { status: 502 });\n const startedAt = state?.data.cursor || null;\n const pending = new Map<string, TokenRow>(); // receipt_id \u2192 row\n let cursor: string | null = startedAt;\n let scanned = 0;\n let wrapped = false;\n while (scanned < MAX_ROWS && pending.size < MAX_ACTIONS) {\n // NOTE: no `sort=` here on purpose. In the DEFAULT order (newest first)\n // a cursor is a plain item id, which is what we persist between ticks\n // and resume `item_id <` from. Paging beats ordering here.\n const url = `${base}/v1/cms/items/push_tokens?limit=${PAGE}`\n + (cursor ? `&cursor=${encodeURIComponent(cursor)}` : '');\n const res = await fetch(url, { headers: H });\n if (!res.ok) return Response.json({ error: 'list_failed', status: res.status }, { status: 502 });\n const body = (await res.json()) as { data?: { items?: TokenRow[]; next_cursor?: string | null } };\n const items = body.data?.items ?? [];\n let stoppedEarly = false;\n for (const it of items) {\n scanned++;\n if (it.data.receipt_id) pending.set(it.data.receipt_id, it);\n cursor = it.item_id; // resume AFTER the last row actually looked at\n if (pending.size >= MAX_ACTIONS || scanned >= MAX_ROWS) { stoppedEarly = true; break; }\n }\n if (stoppedEarly) break;\n if (!body.data?.next_cursor || items.length === 0) { wrapped = true; break; }\n cursor = body.data.next_cursor;\n }\n // Reached the oldest row \u2192 the next tick starts again from the newest.\n const nextCursor = wrapped ? '' : (cursor ?? '');\n\n // \u2500\u2500 2 + 3. Ask for verdicts and act on them. \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n const ids = [...pending.keys()];\n let checked = 0;\n let pruned = 0;\n let cleared = 0;\n let expired = 0;\n let raced = 0;\n const errors: string[] = [];\n const now = Date.now();\n\n for (let i = 0; i < ids.length; i += RECEIPT_BATCH) {\n const slice = ids.slice(i, i + RECEIPT_BATCH);\n const res = await fetch(RECEIPTS_URL, {\n method: 'POST',\n headers: { 'content-type': 'application/json', accept: 'application/json' },\n body: JSON.stringify({ ids: slice }),\n }).catch(() => null);\n if (!res || !res.ok) {\n errors.push(`receipts request returned ${res?.status ?? 'network error'}`);\n continue;\n }\n // Receipts come back keyed by id \u2014 a receipt that is not ready yet is\n // simply ABSENT, which is not an error: it is checked again next walk.\n const map = (((await res.json().catch(() => ({}))) as { data?: Record<string, Receipt> }).data) ?? {};\n for (const id of slice) {\n const receipt = map[id];\n const row = pending.get(id)!;\n if (!receipt) {\n // Not ready \u2014 or no longer kept by the service. Past the age bound\n // the id can never be answered: clear it so the row is not pinned.\n const at = row.data.receipt_at ? Date.parse(row.data.receipt_at) : NaN;\n if (Number.isFinite(at) && now - at > RECEIPT_MAX_AGE_H * 3_600_000) {\n if (await patch(base, H, row, { receipt_id: '', checked_at: iso() })) expired++; else raced++;\n }\n continue;\n }\n checked++;\n if (receipt.status === 'ok') {\n // Delivered. Nothing more to check for this device.\n if (await patch(base, H, row, { receipt_id: '', failures: 0, checked_at: iso() })) cleared++; else raced++;\n continue;\n }\n const reason = receipt.details?.error ?? receipt.message ?? 'unknown';\n if (reason === 'DeviceNotRegistered') {\n // \u2605 INVARIANT 3. The device is gone \u2014 the row goes with it. This is\n // also the ONLY safe automatic delete: every other error is transient\n // or about the message, not about the device's existence.\n await fetch(`${base}/v1/cms/items/push_tokens/${row.item_id}`, { method: 'DELETE', headers: H })\n .catch(() => null);\n pruned++;\n continue;\n }\n if (!await patch(base, H, row, {\n receipt_id: '',\n failures: (row.data.failures ?? 0) + 1,\n last_error: String(reason).slice(0, 300),\n checked_at: iso(),\n })) raced++;\n errors.push(reason);\n }\n }\n\n // \u2500\u2500 4. Save where the walk stopped (If-Match: a concurrent tick wins). \u2500\u2500\u2500\u2500\n const saved = await writeState(base, H, STATE_KEY, state, nextCursor);\n\n return Response.json({\n scanned, pending: pending.size, checked, pruned, cleared, expired, raced,\n resumed_from: startedAt, cursor: nextCursor, wrapped, saved, errors: errors.slice(0, 10),\n });\n },\n};\n\n/** Clear/record against the version that was READ. false = the row changed\n * meanwhile (409: a newer receipt is parked \u2014 leave it) or the write failed. */\nasync function patch(base: string, H: Record<string, string>, row: TokenRow, data: Record<string, unknown>): Promise<boolean> {\n const res = await fetch(`${base}/v1/cms/items/push_tokens/${row.item_id}`, {\n method: 'PATCH',\n headers: { ...H, ...(row.version !== undefined ? { 'if-match': String(row.version) } : {}) },\n body: JSON.stringify({ data }),\n }).catch(() => null);\n return !!res && res.ok;\n}\n\n/** This cron's `push_state` row, null when it does not exist yet. */\nasync function readState(base: string, H: Record<string, string>, key: string): Promise<StateRow | null | 'error'> {\n const filter = encodeURIComponent(JSON.stringify({ key }));\n const res = await fetch(`${base}/v1/cms/items/push_state?filter=${filter}&limit=1`, { headers: H });\n if (!res.ok) return 'error';\n return ((await res.json()) as { data?: { items?: StateRow[] } }).data?.items?.[0] ?? null;\n}\n\n/** Persist the cursor. First tick creates the row (a racing tick's 409 on the\n * unique `key` is fine \u2014 that tick saved its own); later ticks PATCH with\n * If-Match so two overlapping ticks cannot both move it. */\nasync function writeState(\n base: string, H: Record<string, string>, key: string, state: StateRow | null, cursor: string,\n): Promise<boolean> {\n const data = { key, cursor, updated_at: iso() };\n const res = state\n ? await fetch(`${base}/v1/cms/items/push_state/${state.item_id}`, {\n method: 'PATCH',\n headers: { ...H, ...(state.version !== undefined ? { 'if-match': String(state.version) } : {}) },\n body: JSON.stringify({ data }),\n }).catch(() => null)\n : await fetch(`${base}/v1/cms/items/push_state`, {\n method: 'POST', headers: H, body: JSON.stringify({ status: 'published', data }),\n }).catch(() => null);\n return !!res && res.ok;\n}\n\nconst iso = () => new Date().toISOString();\n",
21070
21365
  "register-token.ts": "// register-token.ts \u2014 THE DEVICE REGISTRY (a vxil function with five bindings).\n//\n// http POST /v1/fn/register-token { token, user_id?, platform?, locale? }\n// cmsHook a new `push_token_claims` row (the app's async re-own request)\n// webhook `auth.user.erased` / `user.erased` / `user.deleted` (forget the user)\n//\n// One bundle, because all three are the same job: keeping \"which user holds\n// which device\" true. The envelope's `trigger` says which door was used.\n//\n// \u2500\u2500 http: register \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Call it every time your app starts and every time the OS hands you a new push\n// token. It is idempotent, and it enforces the two registry invariants:\n//\n// 1. ONE TOKEN, ONE USER. `token` is declared `unique`, so the platform \u2014\n// not this code \u2014 decides who wins a race. Two devices registering the\n// same token at the same instant produce exactly one row; the loser sees\n// 409 `unique_violation`.\n// 2. RE-OWN ON CHANGE. A token that comes back under a DIFFERENT user is\n// REASSIGNED, not duplicated. This is the case that matters: a resold\n// phone, a shared tablet, a sign-out/sign-in on the same handset. Without\n// it, the previous owner's notifications keep arriving on someone else's\n// lock screen.\n//\n// IDENTITY. If the call carries a verified end-user session, `envelope.end_user`\n// is populated by the platform and THAT id wins \u2014 a body field can never\n// impersonate a signed-in user. In server mode (your backend, a server key)\n// there is no verified principal and `user_id` from the body is used.\n//\n// SERVER MODE re-owns immediately (a tenant-wide read finds the row whoever\n// owns it). END-USER MODE cannot see another user's row \u2014 the owner scope is\n// the fail-safe working \u2014 so the app's call files a CLAIM in\n// `push_token_claims` (owned by the caller) and answers 202\n// `{ action: 'reown_queued' }`; the cmsHook below finishes the move.\n//\n// \u2500\u2500 cmsHook: finish an async re-own \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Runs in SERVER mode (a platform-delivered hook carries no end-user), so it can\n// read and move any row: re-read the claim, re-own (or create) the token row for\n// the claimant, delete the claim. At-least-once and re-delivered on failure\n// (`retry`), so every step is idempotent: a claim that is already gone is done.\n//\n// \u2500\u2500 webhook: erasure and deletion \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// `auth.user.erased { user_id }`, `user.erased { id }` and `user.deleted { id }`\n// (the admin delete without erase) delete every device row, pending claim and\n// the preferences row of that user \u2014 device tokens, time zone and activity are\n// personal data, and a deleted account must stop getting reminder nudges.\n// Deliberately NOT `auth.session.revoked`: that event fires on every\n// refresh-token rotation, not only on sign-out. A MERGE or REKEY\n// (`auth.user.merged` / `.rekeyed`, guest \u2192 registered) needs nothing here: the\n// app's next start registers its token under the new id, which re-owns it.\n\nimport type { CmsHookFunctionEnvelope, HttpFunctionEnvelope, WebhookFunctionEnvelope } from '@vxil/sdk';\n\ntype RegisterBody = { token?: string; user_id?: string; platform?: string; locale?: string };\ntype Env =\n | HttpFunctionEnvelope<RegisterBody>\n | CmsHookFunctionEnvelope\n | WebhookFunctionEnvelope<{ user_id?: string; id?: string }>;\ninterface TokenRow { item_id: string; version?: number; data: { token?: string; end_user?: string } }\ninterface ClaimRow { item_id: string; data: { token?: string; end_user?: string; platform?: string; locale?: string } }\n\nconst PLATFORMS = new Set(['ios', 'android', 'web']);\n/** Rows deleted per erasure attempt \u2014 a person has a handful of devices; a\n * bigger set is finished by the next attempt (each deletes the next pages). */\nconst ERASE_MAX = 200;\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n if (!cms) return json({ error: 'missing cms scope' }, 403);\n const H = { authorization: `Bearer ${cms}`, 'content-type': 'application/json' };\n\n if (env.trigger === 'cms-hook') return finishClaim(base, H, env);\n if (env.trigger === 'webhook') return forgetUser(base, H, env);\n return register(base, H, env as HttpFunctionEnvelope<RegisterBody>);\n },\n};\n\n// \u2500\u2500 http \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nasync function register(base: string, H: Record<string, string>, env: HttpFunctionEnvelope<RegisterBody>): Promise<Response> {\n const p = env.payload ?? {};\n const token = typeof p.token === 'string' ? p.token.trim() : '';\n // A VERIFIED principal always beats the body. Never trust a client-supplied\n // user id when the platform has already told you who is calling.\n const endUser = env.end_user?.id;\n const owner = endUser ?? (typeof p.user_id === 'string' ? p.user_id.trim() : '');\n if (!token) return json({ error: 'token is required' }, 400);\n if (!owner) return json({ error: 'user_id is required (or invoke with an end-user session)' }, 400);\n // The unique claim is capped at 256 chars; refuse early with a clear message\n // rather than letting the write fail deep in the stack.\n if (token.length > 256) return json({ error: 'token longer than 256 characters \u2014 store a hash instead' }, 400);\n\n const data = tokenData(token, owner, p.platform, p.locale);\n\n // \u2500\u2500 1. Try to create. The common case is one round-trip. \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n const created = await fetch(`${base}/v1/cms/items/push_tokens`, {\n method: 'POST', headers: H, body: JSON.stringify({ status: 'published', data }),\n });\n if (created.ok) {\n const body = (await created.json()) as { data?: { item_id?: string } };\n return json({ registered: true, action: 'created', item_id: body.data?.item_id }, 201);\n }\n if (created.status !== 409) return json({ error: 'register_failed', status: created.status }, 502);\n\n // \u2500\u2500 2. 409 \u21D2 the token already exists. Find it and decide. \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n // In SERVER mode this read is tenant-wide and finds the row whoever owns it.\n // In end-user mode it is owner-scoped, so a row belonging to someone else is\n // invisible \u2014 that is the fail-safe working as designed.\n const row = await findToken(base, H, token);\n if (row === 'error') return json({ error: 'lookup_failed' }, 502);\n if (!row) {\n if (!endUser) {\n // Server mode sees every row, so \"exists but not found\" is a race with a\n // concurrent delete. Retrying the call settles it.\n return json({ registered: false, error: 'conflict', hint: 'retry the call' }, 409);\n }\n // \u2605 The async re-own. File a claim (owned by the caller \u2014 the platform\n // stamps the verified owner); the server-mode cmsHook moves the token.\n const claim = await fetch(`${base}/v1/cms/items/push_token_claims`, {\n method: 'POST', headers: H,\n body: JSON.stringify({ status: 'published', data: claimData(token, owner, p.platform, p.locale) }),\n });\n if (!claim.ok) return json({ error: 'claim_failed', status: claim.status }, 502);\n const c = (await claim.json()) as { data?: { item_id?: string } };\n return json({ registered: false, action: 'reown_queued', claim_id: c.data?.item_id }, 202);\n }\n\n const moved = await reown(base, H, row, data);\n if (!moved.ok) {\n // 409 version_conflict = a concurrent registration already moved it. The\n // registry is still correct; the caller can simply retry.\n return json({ registered: false, error: 'conflict', status: moved.status }, 409);\n }\n const previous = row.data.end_user;\n return json({\n registered: true,\n action: previous && previous !== owner ? 'reowned' : 'refreshed',\n item_id: row.item_id,\n ...(previous && previous !== owner ? { previous_owner: previous } : {}),\n }, 200);\n}\n\n// \u2500\u2500 cmsHook \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nasync function finishClaim(base: string, H: Record<string, string>, env: CmsHookFunctionEnvelope): Promise<Response> {\n const { collection, item_id: claimId } = env.payload ?? ({} as CmsHookFunctionEnvelope['payload']);\n // The platform filters on the binding's collection; this guard is belt and braces.\n if (collection !== 'push_token_claims' || !claimId) return json({ skipped: true }, 200);\n\n const got = await fetch(`${base}/v1/cms/items/push_token_claims/${claimId}`, { headers: H });\n if (got.status === 404) return json({ done: true, reason: 'claim already handled' }, 200);\n if (!got.ok) return json({ error: 'claim_read_failed', status: got.status }, 502); // non-2xx \u2192 another attempt (maxAttempts: 3)\n const claim = ((await got.json()) as { data?: ClaimRow }).data;\n const token = claim?.data.token;\n const owner = claim?.data.end_user;\n if (!token || !owner) {\n await deleteItem(base, H, 'push_token_claims', claimId);\n return json({ done: true, reason: 'empty claim dropped' }, 200);\n }\n\n const data = tokenData(token, owner, claim.data.platform, claim.data.locale);\n const row = await findToken(base, H, token);\n if (row === 'error') return json({ error: 'lookup_failed' }, 502); // non-2xx \u2192 another attempt (maxAttempts: 3)\n let action: string;\n if (!row) {\n // The holder deleted it in the meantime: the claimant simply registers it.\n const created = await fetch(`${base}/v1/cms/items/push_tokens`, {\n method: 'POST', headers: H, body: JSON.stringify({ status: 'published', data }),\n });\n // 409 = it was registered again concurrently; the next delivery re-reads.\n if (!created.ok) return json({ error: 'create_failed', status: created.status }, 502);\n action = 'created';\n } else {\n const moved = await reown(base, H, row, data);\n if (!moved.ok) return json({ error: 'reown_failed', status: moved.status }, 502); // another attempt re-reads (maxAttempts: 3)\n action = row.data.end_user === owner ? 'refreshed' : 'reowned';\n }\n await deleteItem(base, H, 'push_token_claims', claimId);\n return json({ done: true, action, claim_id: claimId }, 200);\n}\n\n// \u2500\u2500 webhook \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nasync function forgetUser(\n base: string, H: Record<string, string>, env: WebhookFunctionEnvelope<{ user_id?: string; id?: string }>,\n): Promise<Response> {\n const event = env.payload?.event;\n const d = env.payload?.data;\n const fields = d && !('truncated' in d) ? d : null;\n // auth.user.erased carries `user_id`; the admin erase and delete carry `id`.\n const userId = event === 'auth.user.erased' ? fields?.user_id\n : event === 'user.erased' || event === 'user.deleted' ? fields?.id : undefined;\n if (typeof userId !== 'string' || !userId) return json({ skipped: true, event }, 200);\n\n let deleted = 0;\n let failed = 0;\n for (const coll of ['push_tokens', 'push_token_claims', 'push_prefs']) {\n const filter = encodeURIComponent(JSON.stringify({ end_user: userId }));\n while (deleted + failed < ERASE_MAX) {\n // Always the FIRST page: what was deleted is gone, so the next read\n // returns the next rows. A row that refuses to delete ends the loop.\n const res = await fetch(`${base}/v1/cms/items/${coll}?filter=${filter}&limit=50`, { headers: H });\n if (!res.ok) return json({ error: 'list_failed', status: res.status, deleted }, 502); // non-2xx \u2192 another attempt (maxAttempts: 3)\n const items = ((await res.json()) as { data?: { items?: Array<{ item_id: string }> } }).data?.items ?? [];\n if (items.length === 0) break;\n let progress = 0;\n for (const it of items) {\n if (await deleteItem(base, H, coll, it.item_id)) { deleted++; progress++; } else failed++;\n }\n if (progress === 0) break;\n }\n }\n // A failed delete \u2014 or a set larger than one attempt's ERASE_MAX \u2014 answers\n // non-2xx: the binding declares maxAttempts: 3, so the jobs ladder hands it\n // back for another attempt, which deletes the rest.\n const unfinished = failed > 0 || deleted >= ERASE_MAX;\n return json({ user_id: userId, deleted, failed, done: !unfinished }, unfinished ? 502 : 200);\n}\n\n// \u2500\u2500 helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nfunction tokenData(token: string, owner: string, platform: unknown, locale: unknown): Record<string, unknown> {\n return {\n token, end_user: owner, last_seen: new Date().toISOString(), failures: 0, last_error: '',\n ...(typeof platform === 'string' && PLATFORMS.has(platform) ? { platform } : {}),\n ...(typeof locale === 'string' && locale ? { locale: locale.slice(0, 16) } : {}),\n };\n}\n\nfunction claimData(token: string, owner: string, platform: unknown, locale: unknown): Record<string, unknown> {\n return {\n token, end_user: owner,\n ...(typeof platform === 'string' && PLATFORMS.has(platform) ? { platform } : {}),\n ...(typeof locale === 'string' && locale ? { locale: locale.slice(0, 16) } : {}),\n };\n}\n\nasync function findToken(base: string, H: Record<string, string>, token: string): Promise<TokenRow | null | 'error'> {\n const filter = encodeURIComponent(JSON.stringify({ token }));\n const found = await fetch(`${base}/v1/cms/items/push_tokens?filter=${filter}&limit=1`, { headers: H });\n if (!found.ok) return 'error';\n return ((await found.json()) as { data?: { items?: TokenRow[] } }).data?.items?.[0] ?? null;\n}\n\n/** \u2605 INVARIANT 2: re-own (or just refresh, when the owner is unchanged).\n * If-Match makes the reassignment atomic against a concurrent registration. */\nfunction reown(base: string, H: Record<string, string>, row: TokenRow, data: Record<string, unknown>): Promise<Response> {\n return fetch(`${base}/v1/cms/items/push_tokens/${row.item_id}`, {\n method: 'PATCH',\n headers: { ...H, ...(row.version !== undefined ? { 'if-match': String(row.version) } : {}) },\n body: JSON.stringify({ data }),\n });\n}\n\n/** true when the row is gone afterwards (a 404 counts: someone else deleted it). */\nasync function deleteItem(base: string, H: Record<string, string>, coll: string, itemId: string): Promise<boolean> {\n const res = await fetch(`${base}/v1/cms/items/${coll}/${itemId}`, { method: 'DELETE', headers: H }).catch(() => null);\n return !!res && (res.ok || res.status === 404);\n}\n\nconst json = (o: unknown, status: number) => Response.json(o, { status });\n",
21071
- "send-push.ts": "// send-push.ts \u2014 THE SENDER (a vxil function, two bindings).\n//\n// http POST /v1/fn/send-push \u2014 send now (below).\n// cron `0 * * * *` \u2014 THE REMINDER LADDER (further below).\n//\n// POST /v1/fn/send-push\n// {\n// \"user_ids\": [\"usr_a\", \"usr_b\"],\n// \"messages\": { // keyed by locale; \"default\" is required\n// \"default\": { \"title\": \"You're in\", \"body\": \"Pro is active on this device.\" },\n// \"ar\": { \"title\": \"\u062A\u0645 \u0627\u0644\u062A\u0641\u0639\u064A\u0644\", \"body\": \"\u0623\u0635\u0628\u062D \u0627\u0634\u062A\u0631\u0627\u0643 Pro \u0645\u0641\u0639\u0651\u0644\u0627\u064B.\" },\n// \"fr\": { \"title\": \"C'est actif\", \"body\": \"Pro est activ\xE9 sur cet appareil.\" }\n// },\n// \"data\": { \"screen\": \"billing\" } // optional payload handed to your app\n// }\n//\n// It resolves each user's live devices, picks the copy for THAT DEVICE'S locale\n// (captured at registration), and posts to the push service in batches.\n//\n// LOCALIZATION IS PER DEVICE, NOT PER USER. One person can carry an English\n// phone and an Arabic tablet. Because `locale` lives on the token row, the right\n// copy goes to each device with no extra lookup.\n//\n// Two failure kinds, handled differently:\n// \u2022 an IMMEDIATE per-message error (`DeviceNotRegistered`) \u2014 the token is dead\n// right now; delete the row.\n// \u2022 an accepted message \u2014 the service returns a RECEIPT id, and the real\n// verdict arrives later. Store the id (and when); `prune-receipts` collects it.\n//\n// \u2500\u2500 The reminder ladder (cron, hourly) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Re-engagement for users who stopped opening the app: one nudge after 1 day\n// of inactivity, one after 3, one after 7 \u2014 then silence until they come back.\n// Per user, from `push_prefs` (the app writes its own row):\n// \u2022 `reminders: false` \u2192 never;\n// \u2022 quiet hours [quiet_start, quiet_end) in the user's `timezone` \u2192 not now\n// (the next hourly tick after the window sends it);\n// \u2022 `last_active` + `ladder_step` decide the step; the APP resets\n// `ladder_step` to 0 when it comes to the foreground.\n// ONE nudge per tick at most, and never a catch-up burst: when several rungs\n// are due at once (a user already idle for a week when the ladder first sees\n// them \u2014 the kit added to a live app, reminders turned back on, a held cron),\n// only the HIGHEST due rung is sent and the steps below it are skipped. And a\n// rung waits its own gap after the previous nudge (`last_reminded_at`), so\n// 1 d \u2192 3 d \u2192 7 d stay 2 and 4 days apart even when a step went out late.\n// Each step is CLAIMED with If-Match on the prefs row BEFORE the send, so two\n// overlapping ticks can never send it twice (a send that then fails is a\n// missed nudge, never a duplicate one). The walk only reads users inactive for\n// at least the first step and not yet through the ladder (`ladder_step` below\n// its length \u2014 a `derive` hook writes 0 when the app leaves it out), and\n// resumes where the last tick stopped (the cursor lives in `push_state`), so\n// it covers any number of users over successive ticks: MAX_LADDER_ROWS read and\n// MAX_REMINDERS sent per tick. A skipped tick is harmless \u2014 the next resumes.\n\n// Expo's push endpoint. For raw FCM: 'https://fcm.googleapis.com/v1/projects/<id>/messages:send'\n// (and an OAuth bearer). For APNs: 'https://api.push.apple.com/3/device/<token>'\n// (one request per device, a JWT `authorization`, and an `apns-topic` header).\n// Whichever you pick, its HOST must be in the function's `egressAllow`.\n\n// cron-walk: persisted-cursor \u2014 the ladder walk's cursor lives in `push_state` and is saved with If-Match.\n\nimport type { CronFunctionEnvelope, HttpFunctionEnvelope } from '@vxil/sdk';\n\nconst PUSH_URL = 'https://exp.host/--/api/v2/push/send';\n/** Expo accepts up to 100 messages in one request. */\nconst BATCH = 100;\n/** cms page size \u2014 100 is the default `maxPageSize` for the cms feature. */\nconst PAGE = 100;\n/** Devices resolved per invocation \u2014 a guardrail, not a limit of the platform. */\nconst MAX_DEVICES = 1000;\n\n/** The ladder: hours of inactivity \u2192 the nudge, per locale ('default' required).\n * Edit the copy; keep the hours ascending. */\nconst LADDER: ReadonlyArray<{ afterHours: number; messages: Messages }> = [\n { afterHours: 24, messages: {\n default: { title: 'Pick up where you left off', body: 'Your streak is waiting for you.' },\n ar: { title: '\u0623\u0643\u0645\u0644 \u0645\u0646 \u062D\u064A\u062B \u062A\u0648\u0642\u0641\u062A', body: '\u0633\u0644\u0633\u0644\u062A\u0643 \u0628\u0627\u0646\u062A\u0638\u0627\u0631\u0643.' },\n } },\n { afterHours: 72, messages: {\n default: { title: 'We saved your progress', body: 'It only takes a minute to get back on track.' },\n ar: { title: '\u062D\u0641\u0638\u0646\u0627 \u062A\u0642\u062F\u0645\u0643', body: '\u062F\u0642\u064A\u0642\u0629 \u0648\u0627\u062D\u062F\u0629 \u062A\u0643\u0641\u064A \u0644\u0644\u0639\u0648\u062F\u0629.' },\n } },\n { afterHours: 168, messages: {\n default: { title: 'Still there?', body: 'Here is what you missed this week.' },\n ar: { title: '\u0647\u0644 \u0645\u0627 \u0632\u0644\u062A \u0647\u0646\u0627\u061F', body: '\u0625\u0644\u064A\u0643 \u0645\u0627 \u0641\u0627\u062A\u0643 \u0647\u0630\u0627 \u0627\u0644\u0623\u0633\u0628\u0648\u0639.' },\n } },\n];\n/** `push_prefs` rows read per tick. */\nconst MAX_LADDER_ROWS = 2000;\n/** Users nudged per tick (each costs a claim PATCH, a device read and receipt PATCHes). */\nconst MAX_REMINDERS = 100;\n/** This cron's row in `push_state`. */\nconst LADDER_STATE_KEY = 'reminder-ladder';\n\ntype Copy = { title?: string; body?: string };\ntype Messages = Record<string, Copy>;\ntype Env = CronFunctionEnvelope | HttpFunctionEnvelope<{\n user_ids?: string[];\n messages?: Messages;\n data?: Record<string, unknown>;\n}>;\ninterface TokenRow {\n item_id: string;\n version?: number;\n data: { token?: string; end_user?: string; locale?: string; failures?: number };\n}\n/** One entry of Expo's response array, positionally matched to the request. */\ninterface Ticket { status?: string; id?: string; message?: string; details?: { error?: string } }\ninterface PrefRow {\n item_id: string;\n version?: number;\n data: {\n end_user?: string; timezone?: string; reminders?: boolean; quiet_start?: number; quiet_end?: number;\n last_active?: string; ladder_step?: number; last_reminded_at?: string;\n };\n}\ninterface StateRow { item_id: string; version?: number; data: { cursor?: string } }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n if (!cms) return json({ error: 'missing cms scope' }, 403);\n const H = { authorization: `Bearer ${cms}`, 'content-type': 'application/json' };\n if (env.trigger === 'cron') return ladder(base, H);\n\n const p = (env as Exclude<Env, CronFunctionEnvelope>).payload ?? {};\n const userIds = Array.isArray(p.user_ids) ? p.user_ids.filter((u) => typeof u === 'string' && u) : [];\n const messages = p.messages ?? {};\n if (userIds.length === 0) return json({ error: 'user_ids is required' }, 400);\n if (!messages.default?.title && !messages.default?.body) {\n return json({ error: 'messages.default { title, body } is required' }, 400);\n }\n const devices = await resolveDevices(base, H, userIds);\n if (devices.length === 0) return json({ sent: 0, devices: 0, note: 'no registered devices' }, 200);\n const out = await sendTo(base, H, devices.map((d) => ({ device: d, messages })), p.data);\n return json({ devices: devices.length, ...out }, 200);\n },\n};\n\n/** \u2500\u2500 1. Resolve devices. One filtered read per user (owner-scoped by value,\n * not by principal \u2014 this runs in server mode). */\nasync function resolveDevices(base: string, H: Record<string, string>, userIds: string[]): Promise<TokenRow[]> {\n const devices: TokenRow[] = [];\n for (const uid of userIds) {\n if (devices.length >= MAX_DEVICES) break;\n const filter = encodeURIComponent(JSON.stringify({ end_user: uid }));\n const res = await fetch(`${base}/v1/cms/items/push_tokens?filter=${filter}&limit=50`, { headers: H });\n if (!res.ok) continue;\n const items = ((await res.json()) as { data?: { items?: TokenRow[] } }).data?.items ?? [];\n for (const it of items) if (it.data.token) devices.push(it);\n }\n return devices;\n}\n\n/** \u2500\u2500 2 + 3. One message per device in that device's language; send in\n * batches; reconcile each ticket back to its device by index. */\nasync function sendTo(\n base: string, H: Record<string, string>,\n targets: Array<{ device: TokenRow; messages: Messages }>, data?: Record<string, unknown>,\n): Promise<{ accepted: number; dropped: number; errors: string[] }> {\n const outgoing = targets.map(({ device, messages }) => {\n const copy = pick(messages, device.data.locale);\n return {\n to: device.data.token!,\n ...(copy.title ? { title: copy.title } : {}),\n ...(copy.body ? { body: copy.body } : {}),\n ...(data ? { data } : {}),\n sound: 'default',\n };\n });\n let accepted = 0;\n let dropped = 0;\n const errors: string[] = [];\n for (let i = 0; i < outgoing.length; i += BATCH) {\n const slice = outgoing.slice(i, i + BATCH);\n const res = await fetch(PUSH_URL, {\n method: 'POST',\n headers: { 'content-type': 'application/json', accept: 'application/json' },\n body: JSON.stringify(slice),\n }).catch(() => null);\n if (!res || !res.ok) {\n errors.push(`push service returned ${res?.status ?? 'network error'} for batch ${i / BATCH}`);\n continue;\n }\n const tickets = (((await res.json().catch(() => ({}))) as { data?: Ticket[] }).data) ?? [];\n for (let k = 0; k < slice.length; k++) {\n const device = targets[i + k]!.device;\n const ticket = tickets[k];\n if (ticket?.status === 'ok' && ticket.id) {\n // Accepted, verdict pending. Park the receipt id (and when) for prune-receipts.\n accepted++;\n await patch(base, H, device, { receipt_id: ticket.id, receipt_at: new Date().toISOString(), failures: 0, last_error: '' });\n continue;\n }\n const reason = ticket?.details?.error ?? ticket?.message ?? 'unknown';\n if (reason === 'DeviceNotRegistered') {\n // Definitive: this token will never deliver again. \u2605 INVARIANT 3.\n await del(base, H, device);\n dropped++;\n continue;\n }\n // Anything else (MessageTooBig, MessageRateExceeded, a transient fault)\n // is NOT a reason to delete a device. Count it and move on.\n await patch(base, H, device, {\n failures: (device.data.failures ?? 0) + 1,\n last_error: String(reason).slice(0, 300),\n });\n errors.push(reason);\n }\n }\n return { accepted, dropped, errors: errors.slice(0, 10) };\n}\n\n// \u2500\u2500 The reminder ladder \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nasync function ladder(base: string, H: Record<string, string>): Promise<Response> {\n const now = new Date();\n const state = await readState(base, H, LADDER_STATE_KEY);\n if (state === 'error') return json({ error: 'state_read_failed' }, 502);\n const startedAt = state?.data.cursor || null;\n // Only users idle for at least the first step, with a rung still ahead of\n // them, can be due: both terms ride slot indexes (`last_active` t1,\n // `ladder_step` n1), so active users and finished ladders are never read.\n const idleSince = new Date(now.getTime() - LADDER[0]!.afterHours * 3_600_000).toISOString();\n const filter = encodeURIComponent(JSON.stringify({\n last_active: { $lt: idleSince }, ladder_step: { $lt: LADDER.length },\n }));\n\n const due: Array<{ row: PrefRow; step: number }> = [];\n let cursor: string | null = startedAt;\n let scanned = 0;\n let wrapped = false;\n let quiet = 0;\n scan: while (scanned < MAX_LADDER_ROWS) {\n const res = await fetch(`${base}/v1/cms/items/push_prefs?filter=${filter}&limit=${PAGE}`\n + (cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''), { headers: H });\n if (!res.ok) return json({ error: 'list_failed', status: res.status }, 502);\n const body = (await res.json()) as { data?: { items?: PrefRow[]; next_cursor?: string | null } };\n const items = body.data?.items ?? [];\n for (const it of items) {\n scanned++;\n cursor = it.item_id;\n const step = dueStep(it.data, now);\n if (step !== null) {\n if (inQuietHours(it.data, now)) quiet++; // left for a later tick: the cursor wraps back to it\n else due.push({ row: it, step });\n }\n if (due.length >= MAX_REMINDERS || scanned >= MAX_LADDER_ROWS) break scan;\n }\n if (!body.data?.next_cursor || items.length === 0) { wrapped = true; break; }\n cursor = body.data.next_cursor;\n }\n const nextCursor = wrapped ? '' : (cursor ?? '');\n\n // Claim each step FIRST (If-Match): a concurrent tick that claimed it wins,\n // and this one skips the user \u2014 never a double nudge.\n const targets: Array<{ device: TokenRow; messages: Messages }> = [];\n let claimed = 0;\n for (const { row, step } of due) {\n const res = await fetch(`${base}/v1/cms/items/push_prefs/${row.item_id}`, {\n method: 'PATCH',\n headers: { ...H, ...(row.version !== undefined ? { 'if-match': String(row.version) } : {}) },\n body: JSON.stringify({ data: { ladder_step: step + 1, last_reminded_at: now.toISOString() } }),\n }).catch(() => null);\n if (!res || !res.ok) continue;\n claimed++;\n for (const device of await resolveDevices(base, H, [row.data.end_user!])) {\n targets.push({ device, messages: LADDER[step]!.messages });\n }\n }\n const sent = targets.length > 0\n ? await sendTo(base, H, targets, { reason: 'reminder' })\n : { accepted: 0, dropped: 0, errors: [] as string[] };\n const saved = await writeState(base, H, LADDER_STATE_KEY, state, nextCursor);\n return json({\n ladder: true, scanned, due: due.length, quiet, claimed, devices: targets.length, ...sent,\n resumed_from: startedAt, cursor: nextCursor, wrapped, saved,\n }, 200);\n}\n\n/** The ladder step due for this user now, or null. Exported for tests.\n * The HIGHEST rung whose idle threshold is met (the claim then moves\n * `ladder_step` past it, so the rungs below are skipped, never sent late), and\n * only once that rung's gap since the previous nudge has passed. */\nexport function dueStep(p: PrefRow['data'], now: Date): number | null {\n if (p.reminders === false || !p.end_user || !p.last_active) return null;\n const idleH = (now.getTime() - Date.parse(p.last_active)) / 3_600_000;\n if (!Number.isFinite(idleH)) return null;\n const from = Math.max(0, Math.trunc(p.ladder_step ?? 0));\n let step: number | null = null;\n for (let r = from; r < LADDER.length; r++) if (idleH >= LADDER[r]!.afterHours) step = r;\n if (step === null) return null;\n if (step > 0 && p.last_reminded_at) {\n const sinceH = (now.getTime() - Date.parse(p.last_reminded_at)) / 3_600_000;\n const gapH = LADDER[step]!.afterHours - LADDER[step - 1]!.afterHours;\n if (Number.isFinite(sinceH) && sinceH < gapH) return null;\n }\n return step;\n}\n\n/** Is it inside the user's quiet hours [quiet_start, quiet_end) in THEIR zone?\n * The window may wrap midnight; equal or missing bounds mean none. An unknown\n * zone is evaluated in UTC. Exported for tests. */\nexport function inQuietHours(p: PrefRow['data'], now: Date): boolean {\n const start = p.quiet_start;\n const end = p.quiet_end;\n if (typeof start !== 'number' || typeof end !== 'number' || start === end) return false;\n const h = localHour(now, p.timezone);\n return start < end ? h >= start && h < end : h >= start || h < end;\n}\n\n/** One formatter per zone, kept for the isolate's life: building an Intl formatter is the\n * expensive part, and a tick can test hundreds of users in the same few zones\n * (CPU per invocation is capped \u2014 2,000 ms on a Free staging/development project; on a dev slot 50 ms unless the function declares limits.cpuMs, up to 2,000; both within a 500,000 CPU-ms daily budget). */\nconst ZONE_FMT = new Map<string, Intl.DateTimeFormat | null>();\nfunction zoneFormatter(tz: string): Intl.DateTimeFormat | null {\n if (!ZONE_FMT.has(tz)) {\n let f: Intl.DateTimeFormat | null = null;\n try { f = new Intl.DateTimeFormat('en-US', { timeZone: tz, hour: 'numeric', hourCycle: 'h23' }); } catch { /* unknown zone */ }\n if (ZONE_FMT.size < 500) ZONE_FMT.set(tz, f);\n return f;\n }\n return ZONE_FMT.get(tz)!;\n}\n\nfunction localHour(now: Date, tz: string | undefined): number {\n try {\n const part = zoneFormatter(tz || 'UTC')?.formatToParts(now).find((x) => x.type === 'hour');\n const h = Number(part?.value);\n if (Number.isInteger(h)) return h % 24;\n } catch { /* unknown zone \u2192 UTC below */ }\n return now.getUTCHours();\n}\n\nasync function readState(base: string, H: Record<string, string>, key: string): Promise<StateRow | null | 'error'> {\n const filter = encodeURIComponent(JSON.stringify({ key }));\n const res = await fetch(`${base}/v1/cms/items/push_state?filter=${filter}&limit=1`, { headers: H });\n if (!res.ok) return 'error';\n return ((await res.json()) as { data?: { items?: StateRow[] } }).data?.items?.[0] ?? null;\n}\n\nasync function writeState(\n base: string, H: Record<string, string>, key: string, state: StateRow | null, cursor: string,\n): Promise<boolean> {\n const data = { key, cursor, updated_at: new Date().toISOString() };\n const res = state\n ? await fetch(`${base}/v1/cms/items/push_state/${state.item_id}`, {\n method: 'PATCH',\n headers: { ...H, ...(state.version !== undefined ? { 'if-match': String(state.version) } : {}) },\n body: JSON.stringify({ data }),\n }).catch(() => null)\n : await fetch(`${base}/v1/cms/items/push_state`, {\n method: 'POST', headers: H, body: JSON.stringify({ status: 'published', data }),\n }).catch(() => null);\n return !!res && res.ok;\n}\n\n/** Locale match: exact ('pt-BR'), then the base language ('pt'), then default. */\nfunction pick(messages: Messages, locale: string | undefined): Copy {\n if (locale) {\n if (messages[locale]) return messages[locale]!;\n const bare = locale.split('-')[0]!;\n if (messages[bare]) return messages[bare]!;\n }\n return messages.default ?? {};\n}\n\nasync function patch(base: string, H: Record<string, string>, d: TokenRow, data: Record<string, unknown>) {\n await fetch(`${base}/v1/cms/items/push_tokens/${d.item_id}`, {\n method: 'PATCH', headers: H, body: JSON.stringify({ data }),\n }).catch(() => null);\n}\nasync function del(base: string, H: Record<string, string>, d: TokenRow) {\n await fetch(`${base}/v1/cms/items/push_tokens/${d.item_id}`, { method: 'DELETE', headers: H }).catch(() => null);\n}\nconst json = (o: unknown, status: number) => Response.json(o, { status });\n"
21366
+ "send-push.ts": "// send-push.ts \u2014 THE SENDER (a vxil function, two bindings).\n//\n// http POST /v1/fn/send-push \u2014 send now (below).\n// cron `0 * * * *` \u2014 THE REMINDER LADDER (further below).\n//\n// POST /v1/fn/send-push\n// {\n// \"user_ids\": [\"usr_a\", \"usr_b\"],\n// \"messages\": { // keyed by locale; \"default\" is required\n// \"default\": { \"title\": \"You're in\", \"body\": \"Pro is active on this device.\" },\n// \"ar\": { \"title\": \"\u062A\u0645 \u0627\u0644\u062A\u0641\u0639\u064A\u0644\", \"body\": \"\u0623\u0635\u0628\u062D \u0627\u0634\u062A\u0631\u0627\u0643 Pro \u0645\u0641\u0639\u0651\u0644\u0627\u064B.\" },\n// \"fr\": { \"title\": \"C'est actif\", \"body\": \"Pro est activ\xE9 sur cet appareil.\" }\n// },\n// \"data\": { \"screen\": \"billing\" } // optional payload handed to your app\n// }\n//\n// It resolves each user's live devices, picks the copy for THAT DEVICE'S locale\n// (captured at registration), and posts to the push service in batches.\n//\n// LOCALIZATION IS PER DEVICE, NOT PER USER. One person can carry an English\n// phone and an Arabic tablet. Because `locale` lives on the token row, the right\n// copy goes to each device with no extra lookup.\n//\n// Two failure kinds, handled differently:\n// \u2022 an IMMEDIATE per-message error (`DeviceNotRegistered`) \u2014 the token is dead\n// right now; delete the row.\n// \u2022 an accepted message \u2014 the service returns a RECEIPT id, and the real\n// verdict arrives later. Store the id (and when); `prune-receipts` collects it.\n//\n// \u2500\u2500 The reminder ladder (cron, hourly) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Re-engagement for users who stopped opening the app: one nudge after 1 day\n// of inactivity, one after 3, one after 7 \u2014 then silence until they come back.\n// Per user, from `push_prefs` (the app writes its own row):\n// \u2022 `reminders: false` \u2192 never;\n// \u2022 quiet hours [quiet_start, quiet_end) in the user's `timezone` \u2192 not now\n// (the next hourly tick after the window sends it);\n// \u2022 `last_active` + `ladder_step` decide the step; the APP resets\n// `ladder_step` to 0 when it comes to the foreground.\n// ONE nudge per tick at most, and never a catch-up burst: when several rungs\n// are due at once (a user already idle for a week when the ladder first sees\n// them \u2014 the kit added to a live app, reminders turned back on, a held cron),\n// only the HIGHEST due rung is sent and the steps below it are skipped. And a\n// rung waits its own gap after the previous nudge (`last_reminded_at`), so\n// 1 d \u2192 3 d \u2192 7 d stay 2 and 4 days apart even when a step went out late.\n// Each step is CLAIMED with If-Match on the prefs row BEFORE the send, so two\n// overlapping ticks can never send it twice (a send that then fails is a\n// missed nudge, never a duplicate one). The walk only reads users inactive for\n// at least the first step and not yet through the ladder (`ladder_step` below\n// its length \u2014 a `derive` hook writes 0 when the app leaves it out), and\n// resumes where the last tick stopped (the cursor lives in `push_state`), so\n// it covers any number of users over successive ticks: MAX_LADDER_ROWS read and\n// MAX_REMINDERS sent per tick. A skipped tick is harmless \u2014 the next resumes.\n\n// Expo's push endpoint. For raw FCM: 'https://fcm.googleapis.com/v1/projects/<id>/messages:send'\n// (and an OAuth bearer). For APNs: 'https://api.push.apple.com/3/device/<token>'\n// (one request per device, a JWT `authorization`, and an `apns-topic` header).\n// Whichever you pick, its HOST must be in the function's `egressAllow`.\n\n// cron-walk: persisted-cursor \u2014 the ladder walk's cursor lives in `push_state` and is saved with If-Match.\n\nimport type { CronFunctionEnvelope, HttpFunctionEnvelope } from '@vxil/sdk';\n\nconst PUSH_URL = 'https://exp.host/--/api/v2/push/send';\n/** Expo accepts up to 100 messages in one request. */\nconst BATCH = 100;\n/** cms page size \u2014 100 is the default `maxPageSize` for the cms feature. */\nconst PAGE = 100;\n/** Devices resolved per invocation \u2014 a guardrail, not a limit of the platform. */\nconst MAX_DEVICES = 1000;\n\n/** The ladder: hours of inactivity \u2192 the nudge, per locale ('default' required).\n * Edit the copy; keep the hours ascending. */\nconst LADDER: ReadonlyArray<{ afterHours: number; messages: Messages }> = [\n { afterHours: 24, messages: {\n default: { title: 'Pick up where you left off', body: 'Your streak is waiting for you.' },\n ar: { title: '\u0623\u0643\u0645\u0644 \u0645\u0646 \u062D\u064A\u062B \u062A\u0648\u0642\u0641\u062A', body: '\u0633\u0644\u0633\u0644\u062A\u0643 \u0628\u0627\u0646\u062A\u0638\u0627\u0631\u0643.' },\n } },\n { afterHours: 72, messages: {\n default: { title: 'We saved your progress', body: 'It only takes a minute to get back on track.' },\n ar: { title: '\u062D\u0641\u0638\u0646\u0627 \u062A\u0642\u062F\u0645\u0643', body: '\u062F\u0642\u064A\u0642\u0629 \u0648\u0627\u062D\u062F\u0629 \u062A\u0643\u0641\u064A \u0644\u0644\u0639\u0648\u062F\u0629.' },\n } },\n { afterHours: 168, messages: {\n default: { title: 'Still there?', body: 'Here is what you missed this week.' },\n ar: { title: '\u0647\u0644 \u0645\u0627 \u0632\u0644\u062A \u0647\u0646\u0627\u061F', body: '\u0625\u0644\u064A\u0643 \u0645\u0627 \u0641\u0627\u062A\u0643 \u0647\u0630\u0627 \u0627\u0644\u0623\u0633\u0628\u0648\u0639.' },\n } },\n];\n/** `push_prefs` rows read per tick. */\nconst MAX_LADDER_ROWS = 2000;\n/** Users nudged per tick (each costs a claim PATCH, a device read and receipt PATCHes). */\nconst MAX_REMINDERS = 100;\n/** This cron's row in `push_state`. */\nconst LADDER_STATE_KEY = 'reminder-ladder';\n\ntype Copy = { title?: string; body?: string };\ntype Messages = Record<string, Copy>;\ntype Env = CronFunctionEnvelope | HttpFunctionEnvelope<{\n user_ids?: string[];\n messages?: Messages;\n data?: Record<string, unknown>;\n}>;\ninterface TokenRow {\n item_id: string;\n version?: number;\n data: { token?: string; end_user?: string; locale?: string; failures?: number };\n}\n/** One entry of Expo's response array, positionally matched to the request. */\ninterface Ticket { status?: string; id?: string; message?: string; details?: { error?: string } }\ninterface PrefRow {\n item_id: string;\n version?: number;\n data: {\n end_user?: string; timezone?: string; reminders?: boolean; quiet_start?: number; quiet_end?: number;\n last_active?: string; ladder_step?: number; last_reminded_at?: string;\n };\n}\ninterface StateRow { item_id: string; version?: number; data: { cursor?: string } }\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const cms = env.scoped_jwts?.cms;\n if (!cms) return json({ error: 'missing cms scope' }, 403);\n const H = { authorization: `Bearer ${cms}`, 'content-type': 'application/json' };\n if (env.trigger === 'cron') return ladder(base, H);\n\n // Server keys and the cron only: an end user's session must never fan push\n // out to arbitrary user_ids (a self-spam / harassment path).\n if ((env as Exclude<Env, CronFunctionEnvelope>).end_user) return json({ error: 'send-push is server-only' }, 403);\n const p = (env as Exclude<Env, CronFunctionEnvelope>).payload ?? {};\n const userIds = Array.isArray(p.user_ids) ? p.user_ids.filter((u) => typeof u === 'string' && u) : [];\n const messages = p.messages ?? {};\n if (userIds.length === 0) return json({ error: 'user_ids is required' }, 400);\n if (!messages.default?.title && !messages.default?.body) {\n return json({ error: 'messages.default { title, body } is required' }, 400);\n }\n const devices = await resolveDevices(base, H, userIds);\n if (devices.length === 0) return json({ sent: 0, devices: 0, note: 'no registered devices' }, 200);\n const out = await sendTo(base, H, devices.map((d) => ({ device: d, messages })), p.data);\n return json({ devices: devices.length, ...out }, 200);\n },\n};\n\n/** \u2500\u2500 1. Resolve devices. One filtered read per user (owner-scoped by value,\n * not by principal \u2014 this runs in server mode). */\nasync function resolveDevices(base: string, H: Record<string, string>, userIds: string[]): Promise<TokenRow[]> {\n const devices: TokenRow[] = [];\n for (const uid of userIds) {\n if (devices.length >= MAX_DEVICES) break;\n const filter = encodeURIComponent(JSON.stringify({ end_user: uid }));\n const res = await fetch(`${base}/v1/cms/items/push_tokens?filter=${filter}&limit=50`, { headers: H });\n if (!res.ok) continue;\n const items = ((await res.json()) as { data?: { items?: TokenRow[] } }).data?.items ?? [];\n for (const it of items) if (it.data.token) devices.push(it);\n }\n return devices;\n}\n\n/** \u2500\u2500 2 + 3. One message per device in that device's language; send in\n * batches; reconcile each ticket back to its device by index. */\nasync function sendTo(\n base: string, H: Record<string, string>,\n targets: Array<{ device: TokenRow; messages: Messages }>, data?: Record<string, unknown>,\n): Promise<{ accepted: number; dropped: number; errors: string[] }> {\n const outgoing = targets.map(({ device, messages }) => {\n const copy = pick(messages, device.data.locale);\n return {\n to: device.data.token!,\n ...(copy.title ? { title: copy.title } : {}),\n ...(copy.body ? { body: copy.body } : {}),\n ...(data ? { data } : {}),\n sound: 'default',\n };\n });\n let accepted = 0;\n let dropped = 0;\n const errors: string[] = [];\n for (let i = 0; i < outgoing.length; i += BATCH) {\n const slice = outgoing.slice(i, i + BATCH);\n const res = await fetch(PUSH_URL, {\n method: 'POST',\n headers: { 'content-type': 'application/json', accept: 'application/json' },\n body: JSON.stringify(slice),\n }).catch(() => null);\n if (!res || !res.ok) {\n errors.push(`push service returned ${res?.status ?? 'network error'} for batch ${i / BATCH}`);\n continue;\n }\n const tickets = (((await res.json().catch(() => ({}))) as { data?: Ticket[] }).data) ?? [];\n for (let k = 0; k < slice.length; k++) {\n const device = targets[i + k]!.device;\n const ticket = tickets[k];\n if (ticket?.status === 'ok' && ticket.id) {\n // Accepted, verdict pending. Park the receipt id (and when) for prune-receipts.\n accepted++;\n await patch(base, H, device, { receipt_id: ticket.id, receipt_at: new Date().toISOString(), failures: 0, last_error: '' });\n continue;\n }\n const reason = ticket?.details?.error ?? ticket?.message ?? 'unknown';\n if (reason === 'DeviceNotRegistered') {\n // Definitive: this token will never deliver again. \u2605 INVARIANT 3.\n await del(base, H, device);\n dropped++;\n continue;\n }\n // Anything else (MessageTooBig, MessageRateExceeded, a transient fault)\n // is NOT a reason to delete a device. Count it and move on.\n await patch(base, H, device, {\n failures: (device.data.failures ?? 0) + 1,\n last_error: String(reason).slice(0, 300),\n });\n errors.push(reason);\n }\n }\n return { accepted, dropped, errors: errors.slice(0, 10) };\n}\n\n// \u2500\u2500 The reminder ladder \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nasync function ladder(base: string, H: Record<string, string>): Promise<Response> {\n const now = new Date();\n const state = await readState(base, H, LADDER_STATE_KEY);\n if (state === 'error') return json({ error: 'state_read_failed' }, 502);\n const startedAt = state?.data.cursor || null;\n // Only users idle for at least the first step, with a rung still ahead of\n // them, can be due: both terms ride slot indexes (`last_active` t1,\n // `ladder_step` n1), so active users and finished ladders are never read.\n const idleSince = new Date(now.getTime() - LADDER[0]!.afterHours * 3_600_000).toISOString();\n const filter = encodeURIComponent(JSON.stringify({\n last_active: { $lt: idleSince }, ladder_step: { $lt: LADDER.length },\n }));\n\n const due: Array<{ row: PrefRow; step: number }> = [];\n let cursor: string | null = startedAt;\n let scanned = 0;\n let wrapped = false;\n let quiet = 0;\n scan: while (scanned < MAX_LADDER_ROWS) {\n const res = await fetch(`${base}/v1/cms/items/push_prefs?filter=${filter}&limit=${PAGE}`\n + (cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''), { headers: H });\n if (!res.ok) return json({ error: 'list_failed', status: res.status }, 502);\n const body = (await res.json()) as { data?: { items?: PrefRow[]; next_cursor?: string | null } };\n const items = body.data?.items ?? [];\n for (const it of items) {\n scanned++;\n cursor = it.item_id;\n const step = dueStep(it.data, now);\n if (step !== null) {\n if (inQuietHours(it.data, now)) quiet++; // left for a later tick: the cursor wraps back to it\n else due.push({ row: it, step });\n }\n if (due.length >= MAX_REMINDERS || scanned >= MAX_LADDER_ROWS) break scan;\n }\n if (!body.data?.next_cursor || items.length === 0) { wrapped = true; break; }\n cursor = body.data.next_cursor;\n }\n const nextCursor = wrapped ? '' : (cursor ?? '');\n\n // Claim each step FIRST (If-Match): a concurrent tick that claimed it wins,\n // and this one skips the user \u2014 never a double nudge.\n const targets: Array<{ device: TokenRow; messages: Messages }> = [];\n let claimed = 0;\n for (const { row, step } of due) {\n const res = await fetch(`${base}/v1/cms/items/push_prefs/${row.item_id}`, {\n method: 'PATCH',\n headers: { ...H, ...(row.version !== undefined ? { 'if-match': String(row.version) } : {}) },\n body: JSON.stringify({ data: { ladder_step: step + 1, last_reminded_at: now.toISOString() } }),\n }).catch(() => null);\n if (!res || !res.ok) continue;\n claimed++;\n for (const device of await resolveDevices(base, H, [row.data.end_user!])) {\n targets.push({ device, messages: LADDER[step]!.messages });\n }\n }\n const sent = targets.length > 0\n ? await sendTo(base, H, targets, { reason: 'reminder' })\n : { accepted: 0, dropped: 0, errors: [] as string[] };\n const saved = await writeState(base, H, LADDER_STATE_KEY, state, nextCursor);\n return json({\n ladder: true, scanned, due: due.length, quiet, claimed, devices: targets.length, ...sent,\n resumed_from: startedAt, cursor: nextCursor, wrapped, saved,\n }, 200);\n}\n\n/** The ladder step due for this user now, or null. Exported for tests.\n * The HIGHEST rung whose idle threshold is met (the claim then moves\n * `ladder_step` past it, so the rungs below are skipped, never sent late), and\n * only once that rung's gap since the previous nudge has passed. */\nexport function dueStep(p: PrefRow['data'], now: Date): number | null {\n if (p.reminders === false || !p.end_user || !p.last_active) return null;\n const idleH = (now.getTime() - Date.parse(p.last_active)) / 3_600_000;\n if (!Number.isFinite(idleH)) return null;\n const from = Math.max(0, Math.trunc(p.ladder_step ?? 0));\n let step: number | null = null;\n for (let r = from; r < LADDER.length; r++) if (idleH >= LADDER[r]!.afterHours) step = r;\n if (step === null) return null;\n if (step > 0 && p.last_reminded_at) {\n const sinceH = (now.getTime() - Date.parse(p.last_reminded_at)) / 3_600_000;\n const gapH = LADDER[step]!.afterHours - LADDER[step - 1]!.afterHours;\n if (Number.isFinite(sinceH) && sinceH < gapH) return null;\n }\n return step;\n}\n\n/** Is it inside the user's quiet hours [quiet_start, quiet_end) in THEIR zone?\n * The window may wrap midnight; equal or missing bounds mean none. An unknown\n * zone is evaluated in UTC. Exported for tests. */\nexport function inQuietHours(p: PrefRow['data'], now: Date): boolean {\n const start = p.quiet_start;\n const end = p.quiet_end;\n if (typeof start !== 'number' || typeof end !== 'number' || start === end) return false;\n const h = localHour(now, p.timezone);\n return start < end ? h >= start && h < end : h >= start || h < end;\n}\n\n/** One formatter per zone, kept for the isolate's life: building an Intl formatter is the\n * expensive part, and a tick can test hundreds of users in the same few zones\n * (CPU per invocation is capped \u2014 2,000 ms on a Free staging/development project; on a dev slot 50 ms unless the function declares limits.cpuMs, up to 2,000; both within a 500,000 CPU-ms daily budget). */\nconst ZONE_FMT = new Map<string, Intl.DateTimeFormat | null>();\nfunction zoneFormatter(tz: string): Intl.DateTimeFormat | null {\n if (!ZONE_FMT.has(tz)) {\n let f: Intl.DateTimeFormat | null = null;\n try { f = new Intl.DateTimeFormat('en-US', { timeZone: tz, hour: 'numeric', hourCycle: 'h23' }); } catch { /* unknown zone */ }\n if (ZONE_FMT.size < 500) ZONE_FMT.set(tz, f);\n return f;\n }\n return ZONE_FMT.get(tz)!;\n}\n\nfunction localHour(now: Date, tz: string | undefined): number {\n try {\n const part = zoneFormatter(tz || 'UTC')?.formatToParts(now).find((x) => x.type === 'hour');\n const h = Number(part?.value);\n if (Number.isInteger(h)) return h % 24;\n } catch { /* unknown zone \u2192 UTC below */ }\n return now.getUTCHours();\n}\n\nasync function readState(base: string, H: Record<string, string>, key: string): Promise<StateRow | null | 'error'> {\n const filter = encodeURIComponent(JSON.stringify({ key }));\n const res = await fetch(`${base}/v1/cms/items/push_state?filter=${filter}&limit=1`, { headers: H });\n if (!res.ok) return 'error';\n return ((await res.json()) as { data?: { items?: StateRow[] } }).data?.items?.[0] ?? null;\n}\n\nasync function writeState(\n base: string, H: Record<string, string>, key: string, state: StateRow | null, cursor: string,\n): Promise<boolean> {\n const data = { key, cursor, updated_at: new Date().toISOString() };\n const res = state\n ? await fetch(`${base}/v1/cms/items/push_state/${state.item_id}`, {\n method: 'PATCH',\n headers: { ...H, ...(state.version !== undefined ? { 'if-match': String(state.version) } : {}) },\n body: JSON.stringify({ data }),\n }).catch(() => null)\n : await fetch(`${base}/v1/cms/items/push_state`, {\n method: 'POST', headers: H, body: JSON.stringify({ status: 'published', data }),\n }).catch(() => null);\n return !!res && res.ok;\n}\n\n/** Locale match: exact ('pt-BR'), then the base language ('pt'), then default. */\nfunction pick(messages: Messages, locale: string | undefined): Copy {\n if (locale) {\n if (messages[locale]) return messages[locale]!;\n const bare = locale.split('-')[0]!;\n if (messages[bare]) return messages[bare]!;\n }\n return messages.default ?? {};\n}\n\nasync function patch(base: string, H: Record<string, string>, d: TokenRow, data: Record<string, unknown>) {\n await fetch(`${base}/v1/cms/items/push_tokens/${d.item_id}`, {\n method: 'PATCH', headers: H, body: JSON.stringify({ data }),\n }).catch(() => null);\n}\nasync function del(base: string, H: Record<string, string>, d: TokenRow) {\n await fetch(`${base}/v1/cms/items/push_tokens/${d.item_id}`, { method: 'DELETE', headers: H }).catch(() => null);\n}\nconst json = (o: unknown, status: number) => Response.json(o, { status });\n"
21072
21367
  }
21073
21368
  },
21074
21369
  {
@@ -21463,6 +21758,19 @@ export default defineConfig({
21463
21758
  // src/migrate/adapters/csv-json.ts
21464
21759
  import { readFileSync as readFileSync7, readdirSync, existsSync as existsSync7, statSync } from "node:fs";
21465
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
21466
21774
  function parseCsv(text) {
21467
21775
  const src = text.charCodeAt(0) === 65279 ? text.slice(1) : text;
21468
21776
  const rows = [];
@@ -21652,6 +21960,9 @@ var CsvJsonAdapter = class {
21652
21960
  columns: cols,
21653
21961
  hasPasswordHash: cols.some((c) => PASSWORDISH_RE.test(c))
21654
21962
  };
21963
+ const dropped = identityDroppedWarning("csv-json", at.name, splitIdentityColumns(cols).dropped);
21964
+ if (dropped) warnings.push(dropped);
21965
+ this.markUserIdColumns(at, tables);
21655
21966
  } else {
21656
21967
  warnings.push(`csv-json: _schema.json authTable '${overrides.authTable}' has no matching file`);
21657
21968
  }
@@ -21759,22 +22070,39 @@ var CsvJsonAdapter = class {
21759
22070
  }) : t.rows;
21760
22071
  yield* this.paged(rows, cursor);
21761
22072
  }
21762
- /** identity rows from the _schema.json authTable, password-ish columns
21763
- * 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). */
21764
22076
  async *readAuthUsers(cursor) {
21765
22077
  const { schema, tables } = this.load();
21766
22078
  if (!schema.authUsers?.present || !schema.authUsers.table) {
21767
22079
  throw new Error("csv-json: no authTable declared in _schema.json \u2014 identity step unavailable");
21768
22080
  }
21769
22081
  const t = tables.get(schema.authUsers.table);
21770
- const strip = t.columns.map((c) => c.name).filter((c) => PASSWORDISH_RE.test(c));
21771
- const rows = strip.length === 0 ? t.rows : t.rows.map((r) => {
21772
- const out = { ...r };
21773
- 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];
21774
22086
  return out;
21775
22087
  });
21776
22088
  yield* this.paged(rows, cursor);
21777
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
+ }
21778
22106
  /** storage objects from the `_objects/<bucket>/**` manifest (R14). Bytes are
21779
22107
  * read LAZILY per object via stream() — a dry-run never opens them. */
21780
22108
  async *readObjects(bucket) {
@@ -22277,14 +22605,25 @@ var GenericPgAdapter = class {
22277
22605
  return;
22278
22606
  }
22279
22607
  const columns = t.columns.map((c) => c.name);
22608
+ const table = this.displayName(t.schema ?? this.schemas[0], t.name);
22280
22609
  const auth = {
22281
22610
  present: true,
22282
- table: this.displayName(t.schema ?? this.schemas[0], t.name),
22611
+ table,
22283
22612
  columns,
22284
22613
  hasPasswordHash: columns.some((c) => PASSWORDISH_RE2.test(c))
22285
22614
  };
22286
22615
  if (t.rowCountEstimate !== void 0) auth.count = t.rowCountEstimate;
22287
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)) };
22288
22627
  }
22289
22628
  findTableIn(schema, table) {
22290
22629
  return schema.tables.find((t) => t.name === table || `${t.schema ?? ""}.${t.name}` === table);
@@ -22338,17 +22677,17 @@ var GenericPgAdapter = class {
22338
22677
  else offset += rows.length;
22339
22678
  }
22340
22679
  }
22341
- /** generic identity read: the `--auth-table` rows MINUS password/secret-ish
22342
- * columns (hash values are never read out — the PII rule; the
22343
- * 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). */
22344
22684
  async *readAuthUsers(cursor) {
22345
22685
  const source = await this.introspect();
22346
22686
  if (!source.authUsers?.present || !source.authUsers.table) {
22347
22687
  throw new Error("no auth table configured \u2014 pass authTable (--auth-table) for generic-pg sources");
22348
22688
  }
22349
22689
  const t = await this.resolveTable(source.authUsers.table);
22350
- const safe = t.columns.map((c) => c.name).filter((c) => !PASSWORDISH_RE2.test(c));
22351
- yield* this.pagedRead(t, safe, cursor);
22690
+ yield* this.pagedRead(t, this.identityColumns(t).keep, cursor);
22352
22691
  }
22353
22692
  async close() {
22354
22693
  await this.sql.end({ timeout: 5 });
@@ -23214,6 +23553,8 @@ async function runApply(apply, sel = "prod", opts = {}) {
23214
23553
  const compared = newComparedCounts({ apiState: !!opts.apiState });
23215
23554
  const ignore = apply ? [] : loadDriftIgnore();
23216
23555
  const ignoredKeys = [];
23556
+ const informationalKeys = [];
23557
+ const strictRuntime = hasFlag("strict-runtime");
23217
23558
  const count = (key) => {
23218
23559
  if (isIgnored(key, ignore)) {
23219
23560
  report.ignored++;
@@ -23472,7 +23813,10 @@ functions: skipped (--skip-functions) \u2014 ${declaredFns.length} declared func
23472
23813
  if (apply && r.webhookSubscriptions && (r.webhookSubscriptions.created || r.webhookSubscriptions.deleted)) {
23473
23814
  console.log(` \u2713 webhook-trigger subscription reconciled (+${r.webhookSubscriptions.created} / -${r.webhookSubscriptions.deleted})`);
23474
23815
  }
23475
- for (const c of r.changes) if (c.kind !== "unchanged") count(`function:${c.name}`);
23816
+ const fnDrift = partitionFnDrift(r.changes, { gate, strict: strictRuntime, ignore });
23817
+ for (const key of fnDrift.count) count(key);
23818
+ report.informational = (report.informational ?? 0) + fnDrift.informational.length;
23819
+ informationalKeys.push(...fnDrift.informational);
23476
23820
  compared.functions += Object.keys(cfg.functions).length + r.changes.filter((c) => c.kind === "remote-only" || c.kind === "delete").length;
23477
23821
  if (apply && r.hookReconcile && r.hookReconcile.failed > 0) {
23478
23822
  const why = r.hookReconcile.warning ?? `${r.hookReconcile.failed} trigger subscription(s) could not be written`;
@@ -23504,7 +23848,6 @@ functions: skipped (--skip-functions) \u2014 ${declaredFns.length} declared func
23504
23848
  });
23505
23849
  }
23506
23850
  const strictSecrets = hasFlag("strict-secrets");
23507
- const informationalKeys = [];
23508
23851
  if (!apply && (gate || explain)) {
23509
23852
  await runSection("secrets", async () => {
23510
23853
  const refs = collectSecretRefs(cfg);
@@ -23591,6 +23934,7 @@ api state \u2014 ${self.target.tenantSlug ?? "(env key)"} vs ${other.target.tena
23591
23934
  informational_keys: [...informationalKeys, ...declaredApiStateKeys],
23592
23935
  strict_secrets: strictSecrets,
23593
23936
  strict_api_state: strictApiState,
23937
+ strict_runtime: strictRuntime,
23594
23938
  ...opts.apiState ? { api_state: apiStateCounts } : {}
23595
23939
  }));
23596
23940
  } else {
@@ -25796,7 +26140,7 @@ export default defineConfig(${JSON.stringify({ features: featBlocks, cms: { coll
25796
26140
  architect "<describe your app>" \u2014 plain English in, a reviewed vxil.config.ts draft out
25797
26141
  plan [--explain] \xB7 push [--dev|--target <name>|--prod] [--server [--resume <apply_id>]] [--no-gen] [--skip-functions] \xB7 pull \xB7 gen [--check] [--offline] [--out <f>] [--no-mcp] [--mcp-out <f>]
25798
26142
  push production overrides: --allow-mock-in-prod \xB7 --allow-sandbox-in-prod \xB7 --allow-dev-origins-in-prod \xB7 --yes
25799
- diff [--against <target>|--exit-code] [--explain] [--ignore <k,\u2026>] [--strict-secrets] [--strict-api-state] [--api-state] \u2014 CI gate: exit 0 no drift \xB7 1 drift \xB7 2 error (an unreadable remote is 2; secrets by name only, unstored refs informational unless --strict-secrets; declared policies/subscriptions/templates/roles/search collections compared to live, an undeclared live row informational unless --strict-api-state)
26143
+ diff [--against <target>|--exit-code] [--explain] [--ignore <k,\u2026>] [--strict-secrets] [--strict-runtime] [--strict-api-state] [--api-state] \u2014 CI gate: exit 0 no drift \xB7 1 drift \xB7 2 error (an unreadable remote is 2; secrets by name only, unstored refs informational unless --strict-secrets; a function whose only change is the platform runtime / plan CPU limit informational unless --strict-runtime; declared policies/subscriptions/templates/roles/search collections compared to live, an undeclared live row informational unless --strict-api-state)
25800
26144
  doctor [--dev|--target <name>] \u2014 + secrets referenced-vs-stored (advisory) + per-function served/published/local hash + keys with last-used (stored session)
25801
26145
  versions <feature> \xB7 rollback <feature> --to <v>
25802
26146
  config draft show|save [--file <f>]|publish|discard <feature> \xB7 config draft duplicate <feature> --from <v> \u2014 stage a config change without making it live
@@ -26654,10 +26998,13 @@ vxil functions dev '${name}' stopped.`);
26654
26998
  function migrateUsage() {
26655
26999
  return `usage: vxil migrate <plan|schema|data|verify> [--dev]
26656
27000
  plan --from supabase|generic-pg|csv-json (--url <pg-url> | --dir <folder>)
26657
- [--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]
26658
27003
  introspect the source (read-only; no API key needed) \u2192 migrate/plan.json
26659
27004
  + migrate/vxil.config.suggested.ts + migrate/RESIDUALS.md
26660
- (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)
26661
27008
  schema [--apply] create the plan's cms + vector-search collections (default: dry-run diff; additive only)
26662
27009
  data [--execute] [--only users,credentials,cms,vectors,files,payments|<table>,\u2026] [--resume]
26663
27010
  the ordered backfill users \u2192 cms (FK order) \u2192 vectors \u2192 files \u2192 payments-credits
@@ -26666,7 +27013,8 @@ function migrateUsage() {
26666
27013
  changed since then (+ --prune: delete what the source no longer has)
26667
27014
  live-app migration (supabase): [--with-password-hashes] keep every password (bcrypt,
26668
27015
  rehashed at each user's first sign-in) \xB7 [--oidc-issuer https://<ref>.supabase.co/auth/v1]
26669
- link users for the installed-app session hand-over \xB7 [--file-links shared-link] stored
27016
+ link users for the installed-app session hand-over (the dry run previews the credentials
27017
+ step on the server: created/kept/skipped by reason, nothing written) \xB7 [--file-links shared-link] stored
26670
27018
  public file URLs become stable shared links instead of object ids
26671
27019
  \xB7 [--with-oauth-identities] carry the Google/Apple/GitHub/Facebook sign-in links (needs
26672
27020
  --with-password-hashes in the same run) \xB7 [--keep-timestamps] keep each row's source
@@ -26750,7 +27098,14 @@ ${migrateUsage()}`);
26750
27098
  try {
26751
27099
  if (sub === "plan") {
26752
27100
  const skip = flag("skip")?.split(",").map((s) => s.trim()).filter(Boolean);
27101
+ const include = flag("include")?.split(",").map((s) => s.trim()).filter(Boolean);
26753
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
+ }
26754
27109
  await runMigratePlan({
26755
27110
  adapter,
26756
27111
  cwd,
@@ -26758,6 +27113,8 @@ ${migrateUsage()}`);
26758
27113
  sourceLabel: label,
26759
27114
  infer: {
26760
27115
  ...skip?.length ? { skipTables: skip } : {},
27116
+ ...include?.length ? { includeTables: include } : {},
27117
+ ...Object.keys(owners).length ? { owners } : {},
26761
27118
  ...hasFlag("include-empty") ? { includeEmpty: true } : {},
26762
27119
  ...profileTable !== void 0 ? { profileTable } : {},
26763
27120
  ...hasFlag("keep-source-id") ? { keepSourceId: true } : {}