chore(notifications): post-#3474 migration script — 5 (effective realtime, high) rows → (daily, high)#3481
Conversation
…ws migrated to (daily, high)) Run-time discovery + migrate driver for the latent grandfathered population that PR #3474 explicitly created: rows with `digestMode: undefined, sensitivity: "high"` were exactly the population whose effective pair (per relay's `rule.digestMode ?? "realtime"`) is `(realtime, high)` — the combo PR #3474 forbids. The validator only checks on write, so existing rows weren't auto-migrated. Population at run time on tacit-curlew-777: - 18 daily/all (untouched, valid) - 5 <undefined>/high (MIGRATED to daily/high) - 2 twice_daily/all (untouched, valid) - 2 <undefined>/critical (untouched, valid: effective realtime/critical) - 1 twice_daily/critical (untouched, valid) Result: 5/5 migrated, 0 failed. Post-run discovery confirms 0 in the affected set. Implementation notes: - Filter mirrors relay semantics (`rule.digestMode ?? "realtime"`); a bare `digestMode === "realtime"` filter MISSES rows with undefined digestMode — the silent third case from PR #3461 review. First run of this script with the bare filter showed 0 matches; the corrected filter found 5. Captured as a discovery-time breakdown in the script so any future run shows the population shape, not just the matching subset. - Uses `setNotificationConfigForUser` (atomic pair-update from PR #3461), not `setDigestSettingsForUser` (which has no `sensitivity` arg in its validator). - Dry-run by default. `--apply` required to mutate. Per-row failures logged + counted; exit 1 if any failed. - Convex deploy key auth via `npx convex run` shell-out (since `setNotificationConfigForUser` is internalMutation and unreachable from `ConvexHttpClient`; cf. skill `convex-internal-functions-unreachable-via-httpclient`). Idempotent — re-running after migration finds 0 affected and no-ops. Kept committed for audit trail (matches PR #3463/#3468 pattern) and as a reusable harness if a future tightening creates a similar grandfathered population.
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Greptile SummaryThis PR adds a one-shot migration script that finds the 5 grandfathered Confidence Score: 4/5Safe to merge — script has already been run successfully on prod; only a stale comment in the header remains. The single finding is a P2 documentation inconsistency (header comment names the wrong mutation). Logic, filter semantics, auth, error handling, and idempotency are all correct. No files require special attention beyond the header comment fix in scripts/migrate-realtime-high-to-daily.mjs. Important Files Changed
Sequence DiagramsequenceDiagram
participant Operator
participant Script as migrate script
participant ConvexHTTP as ConvexHttpClient
participant npx as npx convex run
participant Convex as Convex Backend
Operator->>Script: node script.mjs [--apply]
Script->>ConvexHTTP: query getByEnabled enabled=true
ConvexHTTP->>Convex: alertRules:getByEnabled
Convex-->>ConvexHTTP: all enabled rules
ConvexHTTP-->>Script: allEnabled array
Note over Script: filter rows where digestMode ?? realtime equals realtime AND sensitivity equals high
alt dry-run default
Script-->>Operator: print breakdown and affected rows exit 0
else apply flag passed
loop for each affected row
Script->>npx: convex run alertRules:setNotificationConfigForUser
Note over npx: args userId variant digestMode=daily sensitivity=high
npx->>Convex: setNotificationConfigForUser internalMutation with deploy-key auth
Convex-->>npx: ok or error
npx-->>Script: exit 0 or non-zero
end
Script-->>Operator: print migrated and failed counts
end
Reviews (1): Last reviewed commit: "chore(notifications): post-#3474 migrati..." | Re-trigger Greptile |
| * `node scripts/migrate-realtime-high-to-daily.mjs --apply` | ||
| * For each affected row, calls |
There was a problem hiding this comment.
Stale mutation name in header comment
The "Apply" usage block in the header says alertRules:setDigestSettingsForUser, but the actual spawnSync call at line 114 (correctly) invokes alertRules:setNotificationConfigForUser. If an operator reads this header before re-running the script as a harness, they'll be confused about which mutation is called — especially because the next paragraph in the description explicitly explains why setDigestSettingsForUser is wrong (no sensitivity arg). The code is correct; only the comment needs updating.
| * `node scripts/migrate-realtime-high-to-daily.mjs --apply` | |
| * For each affected row, calls | |
| * For each affected row, calls | |
| * `npx convex run alertRules:setNotificationConfigForUser '{...}'` | |
| * with `digestMode: "daily", sensitivity: "high"`. Tracks success / |
The header docstring's 'Apply' usage block and 'Safety' paragraph both referenced setDigestSettingsForUser, but the actual spawnSync call (and inline comment + commit message) correctly invoke setNotificationConfigForUser. Update both header references and add a note explaining why setDigestSettingsForUser would NOT work (its validator has no sensitivity arg) so a future operator reading the header doesn't get confused.
…ertRules rows (#3486) * chore(notifications): cleanup script for 7 grandfathered free-tier alertRules rows Companion to PR #3483 (server-side mutation gate, layer 2) and PR #3485 (relay fail-closed, layer 3). Disables notifications for the 7 free-tier users that have grandfathered alertRules rows from before the layer-2 gate existed. Mechanism: calls internal.alertRules.setAlertRulesForUser (the INTENTIONALLY-ungated operator path — see PR #3483's contract test) with enabled=false. Preserves sensitivity/channels/eventTypes; only the on/off toggle flips. Affected userIds (verified via 2026-04-28 dry-run): user_3BwtmadXVR2XLnGfIUndMvCNn3S variant=full daily/high planKey=free user_3BybzNDxo0OIm1Q0wseyVPyZZI6 variant=full daily/high planKey=free user_3Bzq0tV0AqS1NGkGfAwkGiloBUW variant=full daily/all planKey=free user_3C0GSJz7gpTcqWdujingRCxxmMs variant=full daily/high planKey=free user_3C2rpobAryOk10hR2BKopVwaupC variant=full daily/all planKey=free user_3C4Y8NV7cVZlysodXrfOQufFBRE variant=full daily/all planKey=free user_3C6K7V9lEzPIH81TdSgthOmf2ZQ variant=full daily/all planKey=free DO NOT MERGE / RUN UNTIL #3483 + #3485 ARE DEPLOYED. Otherwise the same users could re-enable through the still-open mutation surface tomorrow. The user explicitly stated: "close it first, before doing anything to free users." Tier breakdown at time of script authoring: tier 0 (free): 7 users (25%) tier 1 (pro): 18 users tier 2 (pro+): 3 users Dry-run by default. --apply required to mutate. Per-row failures logged + counted; exit 1 on any failure. Idempotent: re-running after apply finds 0 free-tier rows in the enabled set. Audit trail kept committed (matches PR #3463 / #3468 / #3481 pattern). * fix(disable-free-notif): preserve eventTypes+channels, fail-closed on lookup errors (Greptile P1+P1) Two P1 findings on PR #3486 round 1 — both real, both fixed. P1 #1 (line 142): apply mode wiped eventTypes + channels. setAlertRulesForUser PATCHES (does NOT preserve) eventTypes and channels when they're supplied in args. The previous version sent `eventTypes: [], channels: []` — which would have wiped any user's saved subscriptions/channels, even though the script claimed to "only flip enabled". A user later upgrading to PRO would have to reconfigure from scratch. Concrete impact in the current population: user_3Bzq0tV0AqS1NGkGfAwkGiloBUW has channels=[1] (one configured channel). Old version would have wiped it. Fix: pass through `row.eventTypes ?? []` and `row.channels ?? []` from the existing row (which we already have in scope from getByEnabled). P1 #2 (line 87): silent failure on entitlement lookup. Previous version did `tier: tierMatch ? parseInt(tierMatch[1]) : -1` and then `filter(r => r.tier === 0)` — so a failed `npx convex run`, timeout, or output-format change would silently mark every user as tier=-1, get filtered out as "not free", and exit 0 with "no free users to disable." A regression in the lookup path could mask actual free users behind a green-status script run. Fix: fail-closed on three signals: - result.status !== 0 → FATAL exit code 4 - tier regex no match → FATAL exit code 4 - planKey regex no match → FATAL exit code 4 Includes the last few lines of stdout/stderr in the error so the operator can see the actual failure without re-running. Latent bug also fixed: the previous dedupe-by-userId-during-iteration would have caused multi-variant free users to only have ONE variant disabled. New version: dedupe entitlement LOOKUPS by userId (per-user data, no need to look up twice), but iterate ALL rows for the cleanup target list. Multi-variant users get every variant disabled in one pass. Dry-run on prod still shows the expected 7 rows. New diagnostic surface prints `eventTypes=[N] channels=[N]` per row so the operator sees what's preserved.
…ve realtime, high) rows → (daily, high) (koala73#3481) * chore(notifications): post-koala73#3474 migration script + audit trail (5 rows migrated to (daily, high)) Run-time discovery + migrate driver for the latent grandfathered population that PR koala73#3474 explicitly created: rows with `digestMode: undefined, sensitivity: "high"` were exactly the population whose effective pair (per relay's `rule.digestMode ?? "realtime"`) is `(realtime, high)` — the combo PR koala73#3474 forbids. The validator only checks on write, so existing rows weren't auto-migrated. Population at run time on tacit-curlew-777: - 18 daily/all (untouched, valid) - 5 <undefined>/high (MIGRATED to daily/high) - 2 twice_daily/all (untouched, valid) - 2 <undefined>/critical (untouched, valid: effective realtime/critical) - 1 twice_daily/critical (untouched, valid) Result: 5/5 migrated, 0 failed. Post-run discovery confirms 0 in the affected set. Implementation notes: - Filter mirrors relay semantics (`rule.digestMode ?? "realtime"`); a bare `digestMode === "realtime"` filter MISSES rows with undefined digestMode — the silent third case from PR koala73#3461 review. First run of this script with the bare filter showed 0 matches; the corrected filter found 5. Captured as a discovery-time breakdown in the script so any future run shows the population shape, not just the matching subset. - Uses `setNotificationConfigForUser` (atomic pair-update from PR koala73#3461), not `setDigestSettingsForUser` (which has no `sensitivity` arg in its validator). - Dry-run by default. `--apply` required to mutate. Per-row failures logged + counted; exit 1 if any failed. - Convex deploy key auth via `npx convex run` shell-out (since `setNotificationConfigForUser` is internalMutation and unreachable from `ConvexHttpClient`; cf. skill `convex-internal-functions-unreachable-via-httpclient`). Idempotent — re-running after migration finds 0 affected and no-ops. Kept committed for audit trail (matches PR koala73#3463/koala73#3468 pattern) and as a reusable harness if a future tightening creates a similar grandfathered population. * docs(migration-script): fix stale mutation name in header (Greptile P2) The header docstring's 'Apply' usage block and 'Safety' paragraph both referenced setDigestSettingsForUser, but the actual spawnSync call (and inline comment + commit message) correctly invoke setNotificationConfigForUser. Update both header references and add a note explaining why setDigestSettingsForUser would NOT work (its validator has no sensitivity arg) so a future operator reading the header doesn't get confused.
…ertRules rows (koala73#3486) * chore(notifications): cleanup script for 7 grandfathered free-tier alertRules rows Companion to PR koala73#3483 (server-side mutation gate, layer 2) and PR koala73#3485 (relay fail-closed, layer 3). Disables notifications for the 7 free-tier users that have grandfathered alertRules rows from before the layer-2 gate existed. Mechanism: calls internal.alertRules.setAlertRulesForUser (the INTENTIONALLY-ungated operator path — see PR koala73#3483's contract test) with enabled=false. Preserves sensitivity/channels/eventTypes; only the on/off toggle flips. Affected userIds (verified via 2026-04-28 dry-run): user_3BwtmadXVR2XLnGfIUndMvCNn3S variant=full daily/high planKey=free user_3BybzNDxo0OIm1Q0wseyVPyZZI6 variant=full daily/high planKey=free user_3Bzq0tV0AqS1NGkGfAwkGiloBUW variant=full daily/all planKey=free user_3C0GSJz7gpTcqWdujingRCxxmMs variant=full daily/high planKey=free user_3C2rpobAryOk10hR2BKopVwaupC variant=full daily/all planKey=free user_3C4Y8NV7cVZlysodXrfOQufFBRE variant=full daily/all planKey=free user_3C6K7V9lEzPIH81TdSgthOmf2ZQ variant=full daily/all planKey=free DO NOT MERGE / RUN UNTIL koala73#3483 + koala73#3485 ARE DEPLOYED. Otherwise the same users could re-enable through the still-open mutation surface tomorrow. The user explicitly stated: "close it first, before doing anything to free users." Tier breakdown at time of script authoring: tier 0 (free): 7 users (25%) tier 1 (pro): 18 users tier 2 (pro+): 3 users Dry-run by default. --apply required to mutate. Per-row failures logged + counted; exit 1 on any failure. Idempotent: re-running after apply finds 0 free-tier rows in the enabled set. Audit trail kept committed (matches PR koala73#3463 / koala73#3468 / koala73#3481 pattern). * fix(disable-free-notif): preserve eventTypes+channels, fail-closed on lookup errors (Greptile P1+P1) Two P1 findings on PR koala73#3486 round 1 — both real, both fixed. P1 #1 (line 142): apply mode wiped eventTypes + channels. setAlertRulesForUser PATCHES (does NOT preserve) eventTypes and channels when they're supplied in args. The previous version sent `eventTypes: [], channels: []` — which would have wiped any user's saved subscriptions/channels, even though the script claimed to "only flip enabled". A user later upgrading to PRO would have to reconfigure from scratch. Concrete impact in the current population: user_3Bzq0tV0AqS1NGkGfAwkGiloBUW has channels=[1] (one configured channel). Old version would have wiped it. Fix: pass through `row.eventTypes ?? []` and `row.channels ?? []` from the existing row (which we already have in scope from getByEnabled). P1 #2 (line 87): silent failure on entitlement lookup. Previous version did `tier: tierMatch ? parseInt(tierMatch[1]) : -1` and then `filter(r => r.tier === 0)` — so a failed `npx convex run`, timeout, or output-format change would silently mark every user as tier=-1, get filtered out as "not free", and exit 0 with "no free users to disable." A regression in the lookup path could mask actual free users behind a green-status script run. Fix: fail-closed on three signals: - result.status !== 0 → FATAL exit code 4 - tier regex no match → FATAL exit code 4 - planKey regex no match → FATAL exit code 4 Includes the last few lines of stdout/stderr in the error so the operator can see the actual failure without re-running. Latent bug also fixed: the previous dedupe-by-userId-during-iteration would have caused multi-variant free users to only have ONE variant disabled. New version: dedupe entitlement LOOKUPS by userId (per-user data, no need to look up twice), but iterate ALL rows for the cleanup target list. Multi-variant users get every variant disabled in one pass. Dry-run on prod still shows the expected 7 rows. New diagnostic surface prints `eventTypes=[N] channels=[N]` per row so the operator sees what's preserved.
Summary
Discovery + migration driver for the grandfathered population that PR #3474 created: rows with
digestMode: undefined, sensitivity: "high"whose effective pair (per relay'srule.digestMode ?? "realtime") is(realtime, high)— the combo #3474 now forbids.Already run on
tacit-curlew-777prod:<undefined>/high(MIGRATED) · 2 twice_daily/all · 2<undefined>/critical(valid effective realtime/critical, untouched) · 1 twice_daily/critical(daily, high), 0 failedThe 5 affected userIds are committed in the run log of this branch's commit message; one of them is
[email protected](the user whose notification flood started this whole initiative).Why a committed script vs dashboard click-through
Audit trail: matches the PR #3463 / #3468 pattern. The committed script + commit message + this PR's stdout-equivalent in the description = operator-facing record of exactly which userIds were touched and why. Also reusable as a harness if a future tightening creates a similar grandfathered population.
Implementation notes
rule.digestMode ?? "realtime"). A baredigestMode === "realtime"filter misses rows withdigestMode: undefined— the silent third case from PR feat(notifications): forbid (realtime, all) — server+UI+transport+relay #3461 review. The script's first run with the bare filter showed 0 matches; the corrected filter found 5. Captured a discovery-time breakdown so any future run shows the population shape, not just the matching subset.setNotificationConfigForUser(atomic pair-update from PR feat(notifications): forbid (realtime, all) — server+UI+transport+relay #3461), notsetDigestSettingsForUser(whose validator has nosensitivityarg).--applyrequired to mutate. Per-row failures logged + counted; exit 1 if any failed.npx convex runshell-out (sincesetNotificationConfigForUserisinternalMutation, unreachable fromConvexHttpClient— cf. skillconvex-internal-functions-unreachable-via-httpclient).Test plan
daily/highrows; valid<undefined>/criticalrows preserved