Skip to content

feat: sync-views, and a scheduled action that runs it for production - #184

Merged
danjamk merged 3 commits into
mainfrom
feature/166-sync-views
Aug 8, 2026
Merged

feat: sync-views, and a scheduled action that runs it for production#184
danjamk merged 3 commits into
mainfrom
feature/166-sync-views

Conversation

@danjamk

@danjamk danjamk commented Aug 8, 2026

Copy link
Copy Markdown
Owner

feat: sync-views, and a scheduled action that runs it for production

Refs: #166 · unblocks the prod half of #167

Overview

Two things, and the second is the one that matters today: production has been capturing view data
since this morning's deploy, and nothing could promote it into the durable summary. Analytics
Engine drops it after ~90 days. This adds the schedule that keeps it.

views --syncpagevault sync-views

views reads a 90-day window; the sync rescues that window before it ages out permanently. As a
flag, the consequential act looked like an option on the harmless one — and it did not read
correctly in a crontab, which now matters because a schedule is what keeps history alive.
sync-views is symmetric with sync-access: an operator command that reconciles against an
external system, run periodically, distinct from the read commands.

views --sync still works and prints a note pointing at the new name:

Note: `views --sync` is now `pagevault sync-views` — same flags, same behaviour.
      The old form keeps working; the new one reads correctly in a crontab.

Kept rather than cut because it is in the docs, in muscle memory, and quite possibly in a crontab —
and a scheduled sync that starts failing silently is the exact failure ADR-023 §9 exists to prevent.
The note goes to stderr, so a --json pipe is unaffected.

The scheduled sync, and why it has to live in CI

Production's Cloudflare credential is deliberately not on a laptop (#38). resolveWriteTarget also
refuses a sync run from a checkout whose marker names a different deployment — it would read one
account's analytics and write them to another, storing a summary whose ids match nothing and
reporting a measured zero for every document. Both are correct, and together they meant prod had
nowhere the sync could run at all.

CI is where prod's credentials already live, so that is where its schedule belongs. Daily at 03:17
UTC — the odd minute is deliberate, GitHub's scheduler is busiest on the hour and drops jobs under
load, and a missed run here is invisible.

Verified the workflow's assumptions before writing it. CI has no config.json and no registry,
only a restored marker and env vars. I ran sync-views in exactly that shape against the test
deployment — it resolved the target, queried Analytics Engine and posted the summary. The guard
passes because the restored marker and the target name the same deployment.

One thing I built and then removed

I had a health step at the end to report remaining risk. It would have been wrong: health
asserts the deployed build matches this checkout's build, and deployment is decoupled from
releases (ADR-010), so production legitimately runs a commit main has moved past. The step would
have failed the job daily on version drift that isn't a fault.

A scheduled job that cries wolf gets muted — which is precisely the failure this workflow exists to
prevent. Removed, with the reasoning left in the file so the next person doesn't re-add it. The sync
step already reports what it captured, and a real failure fails the job.

PR Prep Summary

Check Result
Old form still dispatches, tested, note asserted
Docs updated architecture, cli-reference, deploy-prod, help — ADRs left alone as historical records
health's fix hint now names sync-views
Workflow validated YAML parses; CI-shape invocation exercised against a live deployment
Scope held the make views parity gap stays in #166 — it depended on this naming decision and is now unblocked, not resolved

Testing

node --test     ✓  186 CLI + script tests (3 new)
make check-docs ✓
tsc --noEmit    ✓

New tests: sync-views dispatches and refuses a filtered sync before touching the network · the old
form still reaches the real command and says where it moved · both are reachable from help and
views no longer advertises the flag.

Not covered: the workflow itself has never run. It cannot be exercised without production's
secrets, so its first real run is on merge — I'd dispatch it manually once rather than wait for
03:17.

Files Changed

New

  • .github/workflows/sync-views-prod.yml — daily + manual, environment: production

Modified

  • cli/bin/pagevault.mjssync-views dispatch, deprecation note, usage summary
  • cli/lib/help.mjs — split into views (read-only) and sync-views
  • cli/lib/ops/health.mjs — the fix hint names the new command
  • cli/pagevault.test.mjs · CHANGELOG.md · three docs

Deployment Instructions

No Worker change. After merge, dispatch Sync production view metrics once by hand to confirm
the secrets resolve — the first scheduled run is otherwise 03:17 UTC and a silent failure there
looks identical to no data.

⚠️ CLOUDFLARE_API_TOKEN in the production environment needs Account · Account Analytics · Read.
It is the same secret deploy-prod.yml uses; if that permission was never added, the run fails with
a 403 and the CLI's error names the exact dashboard path.

Next Steps

danjamk added 3 commits August 8, 2026 07:15
`views --sync` made the consequential act look like an option on the
harmless one. `views` reads a 90-day window; the sync rescues that window
before it ages out permanently, and since 0.33.0 that is the thing keeping
history alive. It also did not read correctly in a crontab, which now
matters. `sync-views` is symmetric with `sync-access` — an operator
command that reconciles against an external system, run periodically,
distinct from the read commands.

The old form keeps working and says where it moved. Cutting it would have
broken docs, muscle memory and possibly a crontab — and a scheduled sync
that starts failing silently is the exact failure ADR-023 decision 9
exists to prevent.

Production had nowhere the sync could correctly run. Its Cloudflare
credential is deliberately not on a laptop (#38), and resolveWriteTarget
refuses a sync from a checkout whose marker names a different deployment,
because that would read one account's analytics and write them to another
— storing a summary whose ids match nothing and reporting a measured zero
for every document. CI is where prod's credentials already live, so that
is where its schedule belongs.

Verified the workflow's assumptions before writing it: CI has no
config.json and no registry, only a restored marker and env vars. Ran
sync-views in exactly that shape against the test deployment and it
resolved the target, queried Analytics Engine and posted the summary.

The workflow has no health step, deliberately. `health` asserts the
deployed build matches the checkout's, and deployment is decoupled from
releases (ADR-010) — production legitimately runs a commit main has moved
past. A daily job failing on that would cry wolf, and a scheduled job that
cries wolf gets muted, which is the failure this workflow exists to
prevent.

The make/CLI parity gap stays open in #166. It depended on this naming
decision and is now unblocked rather than resolved.
The rename changed the refusal message and the e2e suite still asserted
the old one. It passed locally because e2e needs a live wrangler dev and
is cancelled without one — so the local run was green on a suite that had
not executed. Ran it properly this time.

Asserts both spellings rather than just the new one: the old form is kept
working on purpose, and this is the test that would catch it quietly
ceasing to reach the real command.
A scheduled job that silently stops running is invisible — the same shape
of failure, one level up, as the silent data loss the sync exists to
prevent. So it gets the same treatment: say something on every successful
run and let the silence be the alarm. Failures go to the channel that
interrupts someone, and say what a failure costs rather than that a job
went red: history is quietly not being saved, and each failed run
shortens the window in which that is still fixable.

The message names the deployment, taken from the restored intent file —
the host only, never the account id. That is not secret but it is not
something to post into a chat channel either.

A plain fetch rather than a marketplace Slack action. This job can read
production's Cloudflare token, its deployment bearer and its intent file;
every third-party action added here is a supply-chain surface with access
to all three, in exchange for building a JSON body that takes ten lines.
The CLI ships zero runtime dependencies because it is the thing that asks
for your Cloudflare token, and the job holding those secrets should not be
held to a looser standard.

Both webhooks are optional and read from environment secrets, so a fork
without them skips the steps and nothing else changes. That keeps the
workflow useful as the worked example of the operator-side schedule
ADR-023 asks for, which is easy to recommend and less obvious to wire up.

Notifications never fail the job, and that had to cover a thrown fetch as
well as a non-2xx response — testing found the first version exited 1 on
an unreachable host, which would have turned a run whose sync SUCCEEDED
red. A Slack outage would then have looked exactly like lost data,
inverting the point of alerting.

Nothing is logged but HTTP status. A response can echo the request and the
request URL is the credential.
@danjamk
danjamk merged commit 23ce7ad into main Aug 8, 2026
2 checks passed
@danjamk
danjamk deleted the feature/166-sync-views branch August 8, 2026 12:45
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