Repair Firestore document metadata against the realtime database [CLUE-643] - #2977
Open
scytacki wants to merge 19 commits into
Open
Repair Firestore document metadata against the realtime database [CLUE-643]#2977scytacki wants to merge 19 commits into
scytacki wants to merge 19 commits into
Conversation
…LUE-643] The repair scripts need to tell three states apart: a document with content and metadata, one whose content is gone, and one the metadata tree cannot see at all. An index built from documentMetadata alone collapses the first two and misses the third entirely, which is how a whole population stayed invisible to the census. resolveSpace refuses qa and dev rather than defaulting them off. Their realtime side is purged by delete-qa-user-data.ts, so a repair run there would try to create thousands of rows for content that no longer exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Documents whose stored class disagrees with the class they live in show up in the wrong teacher's Sort Work and throw when opened. The repair reads the true home from the index rather than the legacy contextId field: 10 of the 35 in production carry the placeholder "ignored", so copying that field would fix 25 and leave 10 looking correct. A uid disagreement is reported and left alone. That axis was never analysed, and guessing at a correction could do more harm than the mismatch. Writes are credited only once their commit resolves, so a crash mid-sweep leaves a report that understates rather than overstates what landed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tabase [CLUE-643] Only documents whose content still exists get a row. A document with metadata but no content is already unreachable; a Firestore row would promote it into Sort Work where opening it throws. That skip is why the index reads both realtime halves. Offering-contained documents need a curriculum position as well as an offeringId, because isInClassUnitContainer reads the absence of offeringId as class-contained. The position comes from a sibling in the same offering where one exists -- free, since the pass already scans the space -- and from the portal otherwise. When neither answers, the document is reported and skipped rather than written without the fields, which would place it on the wrong container axis and hand it to the offeringId backfill as new work. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ollision [CLUE-643] The Sep 2025 migration consolidated Firestore metadata documents; it moved nothing out of the realtime database. Three structures there still hold fields Firestore has never stored, so deleting the realtime metadata without moving them first would lose them. groupId is the one that cannot simply be copied. In the realtime database it names the group that published a problem document; in Firestore it is an owner axis field saying the document belongs to that group. Copying one into the other would make 14,325 published documents read as group-owned. A migration needs a different field name for the publishing group. Also confirms the context_id guess this doc recorded in Sep 2025 and left unverified: the cloud function still stamps the commenter's class. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…-643] Only originDoc, and only for personal and learning log publications: 296 of 296 learning log publications and 238 of 295 personal ones carry it in production, against 0 of 14,325 problem publications. Nothing else comes from those lists. pubVersion and userId are on no Firestore document at all, and their groupId names the group that published the document whereas Firestore's groupId says the document belongs to that group -- copying it would make published documents read as group-owned. Tests assert all three stay out. See docs/document-metadata/firestore-migration.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…eed [CLUE-643] Shallow REST reads rather than the admin SDK, which would pull a class's whole document subtree when the index needs only its child keys. A 401 refreshes the token and retries, since a sweep over a large space outlives the token it began with, and a persistent failure throws rather than returning empty -- an empty result reads as "this space has no documents" and would let a run report a clean sweep having looked at nothing. A SPACES filter narrows the runnable set but cannot widen it: naming qa still refuses it, because the reason is about the data, not about caution. A filter entry matching no space is reported, since it is almost always a typo that would otherwise run nothing quietly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both scripts now run: they enumerate spaces from Firestore, index each one from the realtime database, and report per space. APPLY=1 writes, SPACES= limits the run, DATABASE_URL overrides the project lookup. Two things a dry run against demo spaces showed: An offering that cannot be resolved is now cached as such. Demo spaces carry authored offering ids like "m2s101" that the portal knows nothing about, and without caching the failure every document sharing one re-queried. The portal is consulted only for authed spaces. A demo space's realtime root is demo/<name>/portals/demo and its offerings never came from learn.concord.org, so asking about them was noise rather than recovery. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… id [CLUE-643] Nothing in the realtime database records unit, investigation or problem: the offering node holds only its own id, and a document's metadata node holds type, createdAt and offeringId. The client always knew its unit from the URL, so the realtime database never needed to store it. For a demo document the offering id is therefore the only source, and the runtime builds it deterministically in createFakeOfferingIdFromProblem. Decoding splits a string into a name and a number, which can split in the wrong place: a unit code ending in a digit gives "unit2101", read as unit "unit", investigation 21. Every decode is checked against a clue-curriculum checkout, so a position no problem directory backs is refused rather than written, and each one is logged so a dry run shows what it would stamp. Unlike scripts/ai/update-metadata.ts, an id with no unit prefix is refused rather than defaulted to "sas". That default is a guess about which unit a document belongs to, which is tolerable when reading and not when writing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…CLUE-643] The first full dry run rejected 260 decodes as "not in the curriculum" that were real positions -- msa 1.1 among them. The directory layout is not the index of what exists: a problem's sections can live under a shared sections/ tree, so msa/investigation-1/ holds only problem-2 while content.json declares problems 1 and 2. Reading content.json is both correct and the curriculum's own model of itself. That run also found 54 bare offering ids. A demo session launched with no unit parameter leaves the unit code out of the id while the app still loads curriculumConfig.defaultUnit, so a bare id means that unit -- read from curriculum-config.json rather than hardcoded. Capped at four digits so a portal offering id is never mistaken for an encoding. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ined [CLUE-643] A full sweep makes tens of thousands of requests, and one of them hitting a DNS failure ended a run outright: only a non-ok response was retried, while a rejected fetch propagated. Transport failures are now retried with a backoff, so a retry does not land inside the same outage, and the last error surfaces if they all fail. Skipped documents now carry createdAt, type and offeringId, and the run reports them by year and writes the full list to a file. What to do with the residue -- all of it outside production -- depends on how old it is, and createdAt is the only timestamp these nodes reliably carry. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…E-643] The repair leaves a residue it will not write metadata for: 665 documents as of 2026-08-24, none created in the past year. This removes them from the realtime database, which is the only place they exist -- by definition they have no Firestore row. Production is protected unconditionally rather than by flag. Its three entries are content with no metadata node, which is student work rather than demo debris and wants looking at individually. The rules live in lib/deletion-plan.ts so they can be read and tested apart from the deleting. Every rule refuses rather than adapts, and each document is re-checked against the live database before removal, so a stale report cannot cause a wrong deletion. Skip reports now carry the class and uid, without which a key addresses nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #2977 +/- ##
===========================================
- Coverage 85.67% 70.53% -15.15%
===========================================
Files 987 990 +3
Lines 56591 57038 +447
Branches 14946 15070 +124
===========================================
- Hits 48487 40233 -8254
- Misses 8084 16769 +8685
- Partials 20 36 +16
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
collaborative-learning
|
||||||||||||||||||||||||||||
| Project |
collaborative-learning
|
| Branch Review |
CLUE-643-metadata-repair
|
| Run status |
|
| Run duration | 03m 23s |
| Commit |
|
| Committer | Scott Cytacki |
| View all properties for this run ↗︎ | |
| Test results | |
|---|---|
|
|
0
|
|
|
0
|
|
|
0
|
|
|
0
|
|
|
4
|
| View all changes introduced in this branch ↗︎ | |
…UE-643] A report names real classes and users, so the default has to be "not committed" for anything a run writes rather than for the one filename that exists today. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
] Sort Work finds personal and learning-log documents with where("unit", "==", null), and Firestore cannot match a field that is absent. A row created without it would be invisible under every filter but "All". All 19,649 class-contained rows in production carry the field, and the client's "class" container stamps it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
useDocumentSyncToFirebase keeps this field in step, but only from the moment a row exists: its updater finds rows by query, so every toggle made while the row was missing updated nothing. Taking the node's value makes the row right now rather than at the owner's next toggle, which for these documents may never come. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ument [CLUE-643] The client recomputes tools on every content save, but its updater finds rows by query, so these documents' saves recorded nothing. Without the field Sort Work files all of them under "No Tools". Content is read last, after every skip, so the documents this run declines never pull the largest node in the database. Content that will not parse leaves the field absent rather than empty: [] asserts the document has no tiles, which is a different claim from not being able to tell. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…LUE-643] 44 of demo/Joe4's 145 nodes are {self, type, version} with no content key: created and never written to. Those have no tiles, and `tools: []` says so. Reserving undefined for content that will not parse drops the space's unreadable count from 44 to 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tabase [CLUE-643] Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Repair scripts only — no product code changes, and nothing here has been run against a database yet.
The problem
Firestore document metadata has drifted from the realtime database in two ways:
context_idnames the wrong class. A metadata row records a class the document does notactually live in. 35 rows in production, roughly 8 elsewhere. Ten of the 35 carry the literal
string
"ignored".spaces have no Firestore row at all, 4,996 of them in demo spaces. A document with no row is
invisible to Sort Work, to the class dashboard, and to every other Firestore-driven view.
The client-side cause is already fixed, so this is a one-time repair rather than something that
needs to keep running. It should run before any other sweep that resolves a document through its
context_id, since both defects would make such a sweep mis-report — specifically before theofferingIdbackfill in #2980.What's here
Three scripts rather than one with modes: they have different risk profiles — one rewrites a field
on live rows, one creates rows, one deletes nodes — and an operator will want to run and judge them
separately.
The index
For one space, walk
classes→usersand read both child lists per user-class pair,documentsanddocumentMetadata. Reads are shallow (?shallow=true) over the REST API ratherthan
.once("value"), which would pull entire subtrees for data the index does not need.Having both halves is what makes the skip rules below decidable: it distinguishes a document whose
content exists from a metadata node whose content is gone.
Both repairs drive from the index and never from the legacy
contextIdfield, which is exactly thefield known to be wrong.
What repair 2 writes
The field set was checked against all 114,882 production rows rather than against the
IDocumentMetadatainterface, since a field can be declared and never written. A row gets:keytypeuidcreatedAtcontext_idnetworkproperties{}titlevisibilityunitunitinvestigationproblemofferingIdoriginDoctoolsThree of those need justifying, because a naive reading of the interface would get them wrong:
unit: null, not omitted. Sort Work finds class-contained documents with.where("unit", "==", null)(sorted-documents.ts), and Firestore cannot match a field that isabsent. A row without it is invisible under every filter but "All". All 19,649 class-contained rows
in production carry it, and the client's
classcontainer stamps it.visibilityandtoolsare copied rather than left to the client. Both are maintained byuseDocumentSyncToFirebase, whose updater finds rows by query and callsupdate()— so with norow to match, every visibility toggle and every content save these documents ever had wrote nothing
at all. Waiting for the owner's next edit is not realistic for a document last touched years ago.
toolsin particular decides how Sort Work groups a document; without it all 5,682 file under "NoTools".
The same query filters on
context_id == user.classHash, which gives repair 1 a consequence beyondtidiness: a row whose
context_idis wrong has been silently failing to recordtoolsandvisibilityfor as long as the field has been wrong.strategiesis deliberately absent. It is recomputed byon-document-taggedfrom the commentssubcollection of the metadata row. A document with no row can have no comments, so there is nothing
to derive, and it fills in the first time a teacher tags one.
Two fields on many existing rows are deliberately not written.
teachersis a denormalizedclass-teacher list that firestore.rules uses only as a fallback for "legacy documents which contain
their own list" — the live path resolves teachers from
context_id. AndcontextId(camelCase,distinct from
context_id) holds the literal string"ignored"in 130 of 130 sampled rows; it isthe dead field from the deleted v1 comment path, the same one repair 1 refuses to trust.
Repair 2's curriculum ladder
An offering-contained document needs
unit/investigation/problem. The script tries, in order:authed/spaces only, the portal, which returns the offering'sactivity_url;`${unitCode}${investigation * 100 + problem}`— and a bare numeric id decodes against the default
sasunit.Every candidate is validated against the unit's
content.jsonbefore it is used. A document thatsurvives none of these is reported and skipped, never guessed at.
Skip rules
Refusals, not filters — each prevents a write that would make things worse:
invisible orphan into a Sort Work entry that throws when opened.
look up in
documentMetadata.enough to handle by hand.
.,#,$,[,],/).The deletion script
The skip rules leave a residue that no product surface can reach.
delete-unrepairable-documents.tsremoves it from the realtime database, reading the report a repair-2 dry run writes. It never
touches Firestore, because by definition these documents have no Firestore row. Its guards:
authed/learn_concord_orgis never touched, under any flag.would classify the same way next time.
report can only cause a skip, which the run reports — never a wrong deletion.
Dry-run results (2026-08-25, all spaces)
Repair 2:
The sweep takes about 19 minutes, up from 12 before
toolswas derived: the content read happenslast, after every skip check, so the documents the run declines never pull the largest node in the
database.
Production resolves completely: every offering-contained document there found its curriculum
position, and nothing in production is left unresolved.
Deletion: 662 documents / 1,235 realtime-database nodes planned, 3 refused as protected — the
three production entries, which are content with no metadata node and want looking at individually
rather than sweeping. None of the 665 was created in the past year; the newest dates from
2025-04-25 and 455 of them predate 2023.
Safety
Every script is dry run by default,
APPLY=1to act, and prints its mode at startup. Writesbatch at 400 and increment the written count only after a commit resolves, so a crash cannot
overstate what landed. Per-space and per-type counts are reported for every bucket including the
skipped ones — judge a run by the per-space lines rather than the totals.
SPACES=limits a run tonamed spaces for a staged rollout.
qa,devandtestspaces are refused outright.The realtime-database reader retries a 401 by refreshing its token, since a sweep over a large space
outlives the token it started with, and retries a thrown fetch with backoff, since a sweep making
tens of thousands of requests will hit a transport failure sooner or later. It never returns empty
on failure — that would read as "this space has no documents", and the run would report a clean
sweep having looked at nothing.
Also in this PR
docs/document-metadata/firestore-migration.mdgains a section on metadata that still lives only inthe realtime database, including a field that would collide with an existing Firestore field if it
were mirrored under its current name.
scripts/output/is added to.gitignore, and is where a run's reports go. Ignored as a directoryrather than by filename: a report names real classes and users, so the default has to be "not
committed" for anything a run writes rather than for the one filename that exists today.
Testing
120 unit tests across 8 suites, against a mock Firestore and a mock realtime database. Worth a look
in review: the batch-tail case (a commit that fails does not increment
written), the duplicate-homecase (a key mapping to two class/user pairs is reported as a violation), the
toolsmodule'sdistinction between a document that was never saved and content that will not parse, and the
deletion plan's refusal cases.
Not in this PR
Running any of it. The repairs and the deletion both need a separate go-ahead.
The intended order is repair 2, then re-run its dry run, then delete from a report that reflects
post-repair reality — rather than deleting first. Both orders cost the same three passes, because
the deletion's input is a repair dry run. What differs is the risk: 330 of the 573
unresolved-curriculum documents sit in
authed/spaces, where the curriculum position is resolved bya call to the portal. A transient portal failure buckets them as unresolvable, and a deletion run
would then remove documents that a later run could have repaired. Deleting last means the residue has
been confirmed by the repair that actually ran, and the before/after reports can be compared for
stability first.
🤖 Generated with Claude Code