@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/README.md +1 -1
- package/dist/_vxil-feature-configs.d.ts +6 -0
- package/dist/vxil-main.js +431 -74
- package/package.json +3 -3
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.
|
|
5546
|
-
var VXIL_SDK_PKG_VERSION = "0.
|
|
5547
|
-
var VXIL_CLI_PKG_VERSION = "0.
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
17276
|
-
|
|
17277
|
-
|
|
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 (
|
|
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
|
-
|
|
17327
|
-
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
21763
|
-
*
|
|
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
|
|
21771
|
-
const rows =
|
|
21772
|
-
const out = {
|
|
21773
|
-
for (const c of
|
|
22082
|
+
const { keep } = splitIdentityColumns(t.columns.map((c) => c.name));
|
|
22083
|
+
const rows = t.rows.map((r) => {
|
|
22084
|
+
const out = {};
|
|
22085
|
+
for (const c of keep) if (c in r) out[c] = r[c];
|
|
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
|
|
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
|
|
22342
|
-
* columns (
|
|
22343
|
-
* supabase subclass overrides with the
|
|
22680
|
+
/** generic identity read: the `--auth-table` rows, identity ALLOWLIST
|
|
22681
|
+
* columns only (identity.ts — a hash column in any language is never read
|
|
22682
|
+
* out, the PII rule; the supabase subclass overrides with the GoTrue
|
|
22683
|
+
* auth.users allowlist). */
|
|
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
|
-
|
|
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
|
-
|
|
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] [--
|
|
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
|
|
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 } : {}
|