Repository navigation
[HSF-1393] fix(collection): stop partial updates from creating documents - #54
devin-ai-integration[bot] wants to merge 2 commits into
Conversation
A collection update() entry for an id that is not in the collection now creates the document only when the merged document passes validateCreate (default: the collection schema; rejected without one). Rejected ids are dropped and reported to onRejectedCreate. This covers firestate's own undo/redo replays. add() and the undo/redo of add()/remove() restore whole documents and skip the check. Bump to 0.3.0. Co-Authored-By: bot_apk <apk@cognition.ai>
|
I'll fix CI failures and address comments from users with write access. I'll skip comments containing "(aside)".
Original prompt from Grok Bot — Billy (service account)
|
There was a problem hiding this comment.
/review on 9920ae3:
Summary. The guard does what the task asks. The check runs in updateState before the no-op collapse and before the undo entry. A partially rejected diff keeps the ids that pass. restoreId makes the undo of remove() and the redo of add() recreate the whole document. The new export (RejectedCreateContext), the col() and FirestateProvider plumbing, the 0.3.0 bump and the docs all match the code. I found three Medium problems. Two are in the default rules: the undo of a whole-document delete made through update() is not treated like the undo of remove(), and an update() create that contains a Firestore sentinel is always rejected. The third is a regression risk for hvakr, where every project collection creates documents through update().
Local checks (head 9920ae3, pnpm, Node 22):
pnpm typecheck(tsc --noEmit): pass.pnpm test(vitest): 18 files, 307 passed, 3 skipped.pnpm build(tsdown): pass.- Lint: the package has no lint script.
To confirm findings 1 and 2, I ran scratch vitest cases in a copy of the repo outside the checkout (not committed):
- Finding 1:
update({ X: deleteField(), Y: {v:2} })→ sync → undo. Y was restored. X was dropped withsource: 'undo'because its stored data fails the schema. - Finding 2:
update({ X: { name, v, t: serverTimestamp() } })on an empty collection was rejected.
Findings:
- Medium.
src/core/collection.ts:417: the undo of a whole-document delete made throughupdate({id: deleteField()})is validated as a create.remove()undo skips the check throughrestoreId. - Medium.
src/core/collection.ts:188: the defaultvalidateCreaterunsschema.safeParseon the merged diff. A create that contains aserverTimestamp(),increment()or other FieldValue fails for any typed field, and the write is dropped without an error. - Medium.
src/core/collection.ts:350: regression risk for hvakr. Every project collection creates documents throughhandle.update(diff)inProjectProvider.updateProjectCollection. Only spaces had a schema guard before this change. Any non-space create that fails its Zod schema is now dropped silently. hvakr#8191 only adds telemetry for these drops. - Low.
AGENTS.md:97still says partialupdate(diff)calls are never Zod-validated. That is no longer true for creates. - Low. An undo or redo whose entries are all rejected still pops the stack and runs
onUndo/onRedoandgetUndoPathnavigation, but nothing changes. You could report this throughonRejectedCreateonly, or leave it as is. - Low. There is no test for findings 1 and 2, and no test for a grouped undo (
undoGroupId) that mixes a rejected id with an accepted one.
| onPushUndo( | ||
| () => updateState(undoDiff as WithFieldValue<DeepPartial<Record<string, TData>>>, { undoable: false }), | ||
| () => updateState(redoDiff as WithFieldValue<DeepPartial<Record<string, TData>>>, { undoable: false }), | ||
| () => updateState(undoDiff as UpdateDiff, { undoable: false }, { source: 'undo' }), |
There was a problem hiding this comment.
Medium. The undo of a whole-document delete made through update() is validated as a create, so it can lose part of the undo.
When update({ X: deleteField() }) removes a whole document, its undo diff is the complete stored document. This closure passes { source: 'undo' } with no restoreId, so validateCreate runs on it. If the stored document fails the current schema (for example a legacy document with drifted fields, which hvakr audits for), the undo drops X. The rest of the group still applies, so the user gets a partial undo with no error. I reproduced this with a scratch test: update({ X: deleteField(), Y: {v:2} }) → sync → undo restored Y and rejected X with source: 'undo'.
remove() skips the check for the same case through restoreId. hvakr deletes most documents with update + deleteField() (it has only a few .remove( calls), so this is the common delete path there.
Suggested fix: Generalize restoreId to restoreIds: Set<string>. In updateState, compute the ids in rawBase that are absent from newLocalState (whole deletes) and pass them to the undo closure. Compute the ids this write created whole (absent from rawBase, present after the write) and pass them to the redo closure. Then use the set in addDocument and removeDocument too. Add a test for undo of an update() delete where the stored document fails the schema.
There was a problem hiding this comment.
Fixed in 11e8756. Undo/redo closures now carry restoreIds, not one restoreId. In updateState, the undo gets the ids the write deleted whole (in rawBase, absent from the new state) and the redo gets the ids it created whole. add() and remove() use the same set. Restoring those ids skips validateCreate.
Test (failed before the fix, passes now): undo of an update() whole delete restores a doc that fails the current schema. It sets up X with v: 'legacy' (fails ItemSchema), runs update({ X: deleteField(), Y: { v: 2 } }), syncs, then undoes. X comes back whole with a batch.set and nothing is rejected. A redo plus a second undo round-trips too. The new test redo of an update() create restores it whole after a remote delete covers the redo side.
| retryOnError = false, | ||
| retryInterval = 5000, | ||
| schema, | ||
| validateCreate = (data: TData) => |
There was a problem hiding this comment.
Medium. The default validateCreate rejects every update() create that contains a Firestore sentinel, and the caller gets no error.
The default is schema.safeParse(mergedDoc). A merged document such as { name, createdAt: serverTimestamp() }, or one with increment() or arrayUnion(), fails any typed Zod field (z.number(), z.date(), a Timestamp schema). I reproduced this with a scratch test: update({ X: { name: 'X', v: 1, t: serverTimestamp() } }) against t: z.number().optional() was rejected. AGENTS.md and the README say diffs are not Zod-validated because they can contain sentinels, and update() was the only create path that accepted sentinels (add() already runs schema.parse). After this change, these creates are dropped with only a console.warn by default.
Suggested fix: Run the default validation with FieldValue sentinels removed. For example, strip every FieldValue leaf before safeParse, or skip the paths that hold sentinels. Also document the limit next to validateCreate, and add a test for a create that includes serverTimestamp().
There was a problem hiding this comment.
Fixed in 11e8756. The default check still runs schema.safeParse(doc), but now ignores every issue whose path runs through or ends at a FieldValue sentinel (pathHitsSentinel). Firestore resolves sentinels on write, so a typed field does not have to match the sentinel. Other issues still reject: a missing required field still fails when a sentinel sits next to it. A custom validateCreate receives the sentinels as-is (now documented in types.ts, the README, and the docs).
Tests: accepts a create whose sentinel fields stand in for typed values (createdAt: serverTimestamp() against z.number(), plus tags: arrayUnion('a')) failed before the fix and passes now. still rejects a create that fails the schema outside its sentinels passes.
| const rejected = Object.keys(next).filter( | ||
| (docId) => | ||
| !(docId in base) && | ||
| docId !== restoreId && |
There was a problem hiding this comment.
Medium. Regression risk for existing consumers: every update() create that fails the schema is now dropped without an error. hvakr creates documents in all project collections this way.
In hvakr, ProjectProvider.updateProjectCollection creates documents in every project collection (systems, zones, equipment, wall/roof/door types, comments, and others) with handle.update(diff). Before this PR, only spaces had a schema check, through getInvalidSpaceCreates. Other collections wrote whatever the caller sent. With 0.3.0, any create in those collections that fails its Zod schema disappears from local state. Examples are AI-tool output, imports, template copies, or an optional field set to null instead of being left out. The user sees nothing; the only signals are console.warn and the PostHog capture in hvakr#8191. The PR description calls this breaking only for schemaless defineCollection users, and it says registry collections are safe because they always have a schema. That does not cover schema-invalid creates in registry collections.
I did not check how often hvakr create paths produce schema-invalid records.
Suggested fix: Pick one before you publish 0.3.0:
- (a) Audit the hvakr create call sites, or run a local check of
safeParseon each create path in tests. - (b) Release in stages: let hvakr pass a
validateCreatethat logs and allows non-space collections, and enforce after the telemetry is clean.
Also state the user-visible effect ("create silently ignored") in the release notes and the hvakr PR.
There was a problem hiding this comment.
Default policy not changed (waiting for Tryston). Audit of flowcircuits/hvakr at fb0d98df1:
How creates reach firestate. All 24 project collections are col() entries with a Zod schema (packages/app/src/contexts/Project/collections/*.ts, for example systems.ts:21). Every app write goes through ProjectProvider.updateProjectCollection → handle.update(diff) (ProjectProvider.tsx:515). There are no add() calls on project collections, so every create is an update() create, and before 0.3.0 only spaces were checked (getInvalidSpaceCreates, ProjectProvider.tsx:177). With a study open, base-collection creates become a write to the existing study document (ProjectProvider.tsx:494-513), so 0.3.0 does not check them at that point.
Create paths with real risk of failing the schema:
- Applying a study (
overwriteProjectFromStudy,ProjectProvider.tsx:614onward, writes at about line 697). The studypostrecords become new base documents throughhandle.update. These records were stored when they were written into the study, without a schema check for non-space collections, and they may predate later schema changes. Highest risk: an apply could drop part of a study, and the user would only see that afterwards. - AI applied-diff undo (
AIProjectProvider.tsx:296-326). It restores the stored originals throughupdateProjectCollection(key, diff). A document the AI deleted comes back as anupdate()create of the stored record, so a legacy record that fails the current schema would not be restored. This is not an undo-manager replay, sorestoreIds(finding 1) does not cover it. - Study-apply undo (
recordStudyApplyUndo.ts:21-89). It restores deleted documents the same way, through a directupdate(). - Stored data already fails schemas.
collections/dashboards.ts:35-55parses reads and skips dashboards that failDashboardSchema. Any flow that copies such a record into a create would now drop it.
Low risk: UI creates that build a full typed record, such as ChatView.tsx:393 and ChatView.tsx:426 (ProjectCommentData built from the Zod-inferred type). With 11e8756, serverTimestamp() fields no longer reject.
Not verified: I did not audit all ~40 caller files line by line, and I did not measure how often production records fail their schemas. There was no production data access.
Options:
- (a) Keep the strict default, and before publishing, add hvakr tests that run each create path above (study apply, AI undo, study-apply undo) against
safeParse. - (b) Staged rollout (my recommendation): hvakr passes a
validateCreatefor non-space collections that reports to PostHog (INVALID_DOCUMENT_CREATE, already wired in hvakr#8191) and returnstrue, and keeps strict checking forspaces. Enforce per collection once the telemetry is clean. firestate needs no change for this. - (c) Exempt known restore paths in hvakr: wrap study apply and AI undo so they write through
add()-like full restores. That is more code, and it still leaves UI creates strict.
…inels in creates, drop dead undo steps - Undo/redo carry restoreIds: the ids the original write deleted or created whole (add, remove, or update + deleteField). Those skip validateCreate, so undoing an update() delete of a legacy document restores it. - The default validateCreate ignores schema issues whose path runs through a FieldValue sentinel (serverTimestamp, increment, arrayUnion, ...). - An undo/redo step that applies nothing because every create was rejected returns false; the undo manager drops it instead of moving it to the other stack and skips onUndo/onRedo. - Docs: AGENTS.md, README, architecture, recipes. Co-Authored-By: bot_apk <apk@cognition.ai>
|
Lows from the review, addressed in 11e8756:
Before the fix: partial-create |
Why
Task: HSF-1393. Sibling app PR (draft, waits on this release): https://github.com/flowcircuits/hvakr/pull/8191.
@hvakr/firestatecreates a document from any partial collection diff whose id is unknown:updateStateapplies the diff tolocalState, andsync()callsbatch.setfor every id not insyncState. The hvakr app guards space writes inProjectProvider(HSF-1391, flowcircuits/hvakr#8000), but firestate's own undo/redo closures callupdateStatedirectly. So undoing a partial edit to a space that a collaborator (or a server function) deleted writes a fragment of that space back to Firestore.Summary
A collection write can now create a document only as a whole document. In
updateState, every id that the merged diff adds to the collection must passvalidateCreate; failing ids are dropped (the rest of the diff still applies) and reported toonRejectedCreate.add()is unchanged (alreadyschema.parses the full doc).restoreIds: the ids the original write deleted whole (for the undo) or created whole (for the redo), fromadd(),remove(), orupdate()(includingupdate({ X: deleteField() })). Those ids skipvalidateCreate, so undoing a delete restores the document even when it fails the current schema.serverTimestamp(),increment(),arrayUnion(), …), so a create with sentinels still passes. A customvalidateCreatereceives the sentinels as-is.false. The undo manager then drops it (no move to the redo/undo stack, noonUndo/onRedo).UndoAction.undo/redomay now returnfalsefor this.onNavigatestill runs, because it runs before the action.updateState, before the no-op collapse and undo entry, so a rejected id never reacheslocalState,sync()/batch.set, or the undo stack.RejectedCreateContext. Docs: README,docs/architecture.md,docs/api-recipes.md,AGENTS.mdcontract.0.2.1→0.3.0(behavior change forupdate()-creates). Not published, not tagged.Tests
New
src/__tests__/partial-create.test.ts: undo and redo of a partial edit to a remotely deleted doc, unknown id mixed into a valid diff, schema-validupdate()create, customvalidateCreate, no schema/no validator,add()without schema, undo/redo ofadd()andremove(), default warning.npx vitest run src/__tests__/partial-create.test.ts→7 failed | 3 passed(undo/redo recreatedXwithbatch.set).npx vitest run src/__tests__/partial-create.test.ts→4 failed | 12 passed(undo of anupdate()whole delete of a schema-failing doc, redo of anupdate()create, create withserverTimestamp()/arrayUnion(), an all-rejected undo still moved to redo).npx vitest run src/utils/undo.test.ts→3 failed | 35 passed. The grouped undo with mixed results already passed (coverage). After: both files →54 passed.10 passed.pnpm typecheck(tsc --noEmit) → clean.vitest run→18 files, 317 passed | 3 skipped.tsdownbuild → OK.master, so I did not reformat them.Migration needed: no. Only client write behavior changes; no stored data changes shape.
Decisions to confirm
schema(registry collections always have one); collections without a schema reject everyupdate()-create unless they passvalidateCreate. Alternative: allow schemaless creates by default. This is the breaking part for low-leveldefineCollectionusers who create viaupdate().onRejectedCreate/console.warn.Release: a human must publish
@hvakr/firestate@0.3.0after merge; the hvakr PR bumps to it.Opened by Devin.
update(), and only spaces were schema-checked before. Whether 0.3.0 enforces the schema for all collections at once, or hvakr starts in report-only mode for non-space collections, is in the review thread oncollection.ts(finding 3). This PR keeps the strict default.Link to Devin session: https://app.devin.ai/sessions/ff96b825ae42445bb8a11515d74ef30c
Open in Devin Desktop: https://app.devin.ai/desktop/session/ff96b825ae42445bb8a11515d74ef30c?variant=devin
Open in [code]smith · Need help on this PR? Tag
@codesmith-botwith what you need.