Skip to content

chore(notifications): post-#3474 migration script — 5 (effective realtime, high) rows → (daily, high)#3481

Merged
koala73 merged 2 commits into
mainfrom
migrate-realtime-high-to-daily
Apr 28, 2026
Merged

chore(notifications): post-#3474 migration script — 5 (effective realtime, high) rows → (daily, high)#3481
koala73 merged 2 commits into
mainfrom
migrate-realtime-high-to-daily

Conversation

@koala73

@koala73 koala73 commented Apr 28, 2026

Copy link
Copy Markdown
Owner

Summary

Discovery + migration driver for the grandfathered population that PR #3474 created: rows with digestMode: undefined, sensitivity: "high" whose effective pair (per relay's rule.digestMode ?? "realtime") is (realtime, high) — the combo #3474 now forbids.

Already run on tacit-curlew-777 prod:

  • Population at run time: 18 daily/all · 5 <undefined>/high (MIGRATED) · 2 twice_daily/all · 2 <undefined>/critical (valid effective realtime/critical, untouched) · 1 twice_daily/critical
  • Result: 5/5 migrated to (daily, high), 0 failed
  • Post-run discovery confirms 0 in the affected set

The 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

  • Filter mirrors relay semantics (rule.digestMode ?? "realtime"). A bare digestMode === "realtime" filter misses rows with digestMode: 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.
  • Uses setNotificationConfigForUser (atomic pair-update from PR feat(notifications): forbid (realtime, all) — server+UI+transport+relay #3461), not setDigestSettingsForUser (whose validator has no sensitivity arg).
  • 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, unreachable from ConvexHttpClient — cf. skill convex-internal-functions-unreachable-via-httpclient).

Test plan

  • Dry-run on prod: enumerated 5 affected, printed expected userIds + variants
  • Apply on prod: 5/5 migrated, 0 failed
  • Post-run dry-run: 0 affected (idempotent)
  • Population breakdown post-run shows 5 new daily/high rows; valid <undefined>/critical rows preserved

…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.
@vercel

vercel Bot commented Apr 28, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
worldmonitor Ignored Ignored Preview Apr 28, 2026 5:44am

Request Review

@greptile-apps

greptile-apps Bot commented Apr 28, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a one-shot migration script that finds the 5 grandfathered (digestMode=undefined, sensitivity=high) rows whose effective delivery pair (realtime, high) was forbidden by PR #3474, and migrates them to (daily, high) via setNotificationConfigForUser. The filter correctly mirrors relay semantics (rule.digestMode ?? "realtime"), the script is dry-run by default, and the PR description documents the already-completed prod run.

Confidence Score: 4/5

Safe 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

Filename Overview
scripts/migrate-realtime-high-to-daily.mjs One-shot migration script that correctly identifies (digestMode=undefined

Sequence Diagram

sequenceDiagram
    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
Loading

Reviews (1): Last reviewed commit: "chore(notifications): post-#3474 migrati..." | Re-trigger Greptile

Comment on lines +25 to +26
* `node scripts/migrate-realtime-high-to-daily.mjs --apply`
* For each affected row, calls

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Suggested change
* `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.
@koala73
koala73 merged commit 8fa63f9 into main Apr 28, 2026
11 checks passed
@koala73
koala73 deleted the migrate-realtime-high-to-daily branch April 28, 2026 05:48
koala73 added a commit that referenced this pull request Apr 28, 2026
…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.
fuleinist pushed a commit to fuleinist/worldmonitor that referenced this pull request May 9, 2026
…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.
fuleinist pushed a commit to fuleinist/worldmonitor that referenced this pull request May 9, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant