Skip to content

Add Collection.observeRecord for records that may be missing - #6

Merged
joker-777 merged 5 commits into
kenhubfrom
claude/observe-record-de1250
Oct 31, 2026
Merged

joker-777 merged 5 commits into
kenhubfrom
claude/observe-record-de1250

Conversation

@joker-777

Copy link
Copy Markdown

Summary

Adds Collection.observeRecord(id), a failsafe version of findAndObserve(id) for records that may not exist (yet), and makes @experimentalFailsafe relations use it so they recover when the related record appears.

Refs Nozbe#1257 and Nozbe#1719.

Problem

findAndObserve(id) calls observer.error() when the record does not exist and observer.complete() when it is deleted. withObservables rethrows that error in render() and ignores completion. In practice:

  • A list row whose related record is missing (not synced yet, or deleted on another device) crashes the whole React tree unless every row has its own Error Boundary.
  • A record that arrives later, for example through sync, is never shown, because the observable has already errored.
  • Relation.observe() goes through findAndObserve, so relations pointing at missing records behave the same way.
  • @experimentalFailsafe wraps observe() in catchError(() => of(fallback)). That emits the fallback once and completes, so the relation never recovers either.

Changes

  • Collection.observeRecord(id) emits:

    • the record if it exists, then again on every change;
    • null while the record is missing;
    • the record once a created change for that id arrives on the collection;
    • null again instead of completing when the record is deleted, and keeps observing after that.

    It still errors for invalid ids such as null, like find(). It reuses the existing plumbing: _fetchRecord now delegates to a new _fetchRecordIfExists that returns null instead of throwing, and creation is detected with Collection.experimentalSubscribe. There are no new dependencies. The behaviour of findAndObserve and find is unchanged.

  • Relation accepts a new failsafe option. When it is set, observe() uses observeRecord instead of findAndObserve, for both mutable and immutable relations.

  • @experimentalFailsafe marks the decorated relation as failsafe. A missing related record is mapped to the decorator's fallback value, while a relation whose id is null still emits null. The observable now recovers when the record is created. catchError stays in place for other errors and for non-relation values.

  • Flow types and the hand-maintained .d.ts for Collection and Relation.

  • Docs in CRUD.md, Components.md and Relation.md, plus a line in CHANGELOG-Unreleased.md.

I picked a separate method rather than findAndObserve(id, { failsafe: true }) because the emitted type differs (Observable<?Record> vs Observable<Record>). A separate name keeps both signatures precise in Flow and TypeScript.

Tests

  • src/Collection/test.js: the first tests for findAndObserve, covering emit, update, complete on delete, and error when missing. observeRecord cases cover missing → null, created later → record, deleted → null without completing, then re-created → record, and an uncached record whose id contains colons (12:Quiz:hand). There are also tests for a creation racing with the initial fetch, invalid ids, and cleanup on unsubscribe.
  • src/Relation/test.js: a non-failsafe relation still errors. Failsafe mutable and immutable relations emit null and recover on create, update, delete and re-point.
  • src/decorators/experimentalFailsafe/test.js (new): relations are marked failsafe, fetch() falls back, and observe() emits the fallback and then recovers. A null id stays null, and arbitrary observables still get catchError.
  • src/react/withObservables/withObservables.test.js: a null prop from observeRecord renders without throwing and re-renders when the record is created or deleted. Observable errors are still rethrown in render.

Jest for src/Collection src/Relation src/decorators src/react src/observation passes, as do eslint, tslint and flow check.

Example

withObservables(['postId'], ({ postId, database }) => ({
  post: database.get('posts').observeRecord(postId), // null until the post exists
}))

🤖 Generated with Claude Code

findAndObserve(id) errors when the record does not exist and completes
when it is deleted. In withObservables this rethrows in render, so a row
whose related record is missing crashes the tree, and a record that
arrives later (e.g. via sync) is never shown. experimentalFailsafe only
catches the error and emits its fallback once, so it never recovers.

observeRecord(id) emits null while the record is missing, the record
once it exists or a record with that id is created, and null again
instead of completing when it is deleted. It keeps observing throughout.

Relations get a failsafe option that makes observe() use observeRecord,
and @experimentalFailsafe sets it, mapping a missing record to the
fallback value, so failsafe relations recover when the record appears.

Adds tests for findAndObserve, observeRecord, failsafe relations and
withObservables rendering a null prop, plus docs and a changelog entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 31, 2026 07:12

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

Unresolved observation bugs can skip deletion notifications, expose soft-deleted records, mishandle null relation IDs, and leak listeners during synchronous unsubscription.

4 open issues
What changed in this PR

Adds Collection.observeRecord(id) so consumers can observe records that may be missing without terminating observation, and enables failsafe relations to recover when related records appear.

Changes:

  • Adds nullable record observation across creation, updates, deletion, and recreation.
  • Integrates recovery with failsafe relations and updates Flow and TypeScript declarations.
  • Adds lifecycle and React integration tests, documentation, and a changelog entry.
File Description
src/​Relation/​test.js Tests missing-record errors and failsafe relation recovery.
src/​Relation/​index.js Adds the failsafe relation option and state.
src/​Relation/​index.d.ts Declares failsafe relation options in TypeScript.
src/​Relation/​helpers.js Routes failsafe observation through observeRecord.
src/​react/​withObservables/​withObservables.test.js Tests nullable rendering, recovery, and error propagation.
src/​decorators/​experimentalFailsafe/​test.js Adds decorator fallback and recovery tests.
src/​decorators/​experimentalFailsafe/​index.js Enables relation recovery and maps missing records to fallbacks.
src/​Collection/​test.js Tests observation lifecycles, fetch races, and cleanup.
src/​Collection/​index.js Implements observeRecord and nullable fetching.
src/​Collection/​index.d.ts Declares nullable collection observation and fetching.
docs-website/​docs/​docs/​Relation.md Documents failsafe relation usage.
docs-website/​docs/​docs/​CRUD.md Explains record observation semantics.
docs-website/​docs/​docs/​Components.md Shows missing-record handling in React components.
CHANGELOG-Unreleased.md Announces nullable observation and relation recovery.

Review effort: Balanced

Comment thread src/Collection/index.js Resolved
Comment thread src/Collection/index.js Resolved
Comment thread src/Collection/index.js Resolved
Comment thread src/Relation/helpers.js Resolved
- Model and Collection notify a copy of their subscriber list, so a
  subscriber that unsubscribes while being notified no longer makes the
  next subscriber miss the notification (e.g. a second observeRecord
  subscription missing the null on deletion).
- observeRecord subscribes to the record before emitting it, so a
  consumer that unsubscribes synchronously does not leak the listener.
- observeRecord treats a fetched record marked as deleted as missing.
- Failsafe relations with a null id emit null instead of erroring.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 31, 2026 07:19

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 Needs a closer look

Unresolved notification and fetch races can emit deleted or stale models, failsafe observation types omit possible null values, and retained listeners add O(N × M) batch-scanning work.

No open issues 🎉

3 previously missed issues

In code that hasn't changed since last review

Medium severity Do not emit created records deleted in the same batch

src/​Collection/​index.js:171

Creating and deleting this ID inside Database.experimentalBatchNotifications() leaves the queued created notification pointing to an already-deleted model. This branch emits that model before null, so consumers using filter(Boolean), take(1) can finish with a deleted record. Check record.syncStatus !== 'deleted' before observing a creation, as the initial-fetch path already does.

Medium severity Avoid scanning each batch for every observed record

src/​Collection/​index.js:173

Every subscription retains this collection listener after finding a live record, so each batch is scanned once per observed record, including updates already delivered through model listeners. With N observed rows and M changes, this adds O(N × M) work to the notification path, including for existing failsafe relations now using this method. Avoid full-batch scans for live records through ID-based dispatch or missing-only listeners, while preserving deletion/recreation handling within a batch.

Medium severity Invalidate pending fetches after matching lifecycle notifications

src/​Collection/​index.js:179

If creation and deletion occur before the initial find callback returns, deletion clears observedRecord and lets the pending result through again. A delayed result containing pre-deletion raw data then repopulates the cache and emits an obsolete model after null. Permanently invalidate the initial fetch after matching lifecycle notifications, checking validity before converting raw data into cached models.

4 resolved since last review

Review effort: Balanced

- On a change notification for the observed id, look the record up in
  the record cache (which is updated before notifications are sent)
  instead of trusting the notification's record. A record created and
  deleted within batched notifications is no longer emitted, and a
  record deleted and re-created within one batch is switched to
  directly.
- Only subscribe to collection changes while the record is missing, so
  observed live records no longer scan every change batch.
- Ignore the initial fetch result once a change notification for the id
  has arrived, so a late result cannot emit an outdated record.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 31, 2026 07:28

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

Pending-fetch races can suppress the initial null emission and cache an outdated row as a live model, causing later lookups or subscriptions to return a deleted record.

1 open issue
1 previously missed issue

In code that hasn't changed since last review

High severity Reject superseded fetch results before caching deleted records

src/​Collection/​index.js:353

When an initial fetch returns an outdated row after the record is created and deleted, this conversion caches a live-looking model before observeRecord checks initialFetchSuperseded. The original observer ignores the result, but a subsequent find() or observeRecord() returns the deleted record from cache. Reject superseded results before materializing or caching them, and extend the delayed-fetch regression test to check the cache and a fresh subscription.

Review effort: Balanced

Comment thread src/Collection/index.js Resolved
If a record was created and deleted while the initial fetch was still
pending, the change notification superseded the fetch without emitting
anything, so the subscription never emitted its initial null. Emit null
in that case, without emitting null twice in a row.

Also document why a superseded fetch result is still turned into a
cached model: the adapter marks the record as sent to JS, so dropping
it would leave the adapter-side and JS caches out of sync.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 31, 2026 07:34

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 Needs a closer look

Human assessment is needed because findings omitted from the published review identify failsafe relation types that hide nullable results and batched notifications that can emit deleted records.

No open issues 🎉

1 resolved since last review

Review effort: Balanced

- With batched notifications, an update notification can arrive after
  the record was deleted. observeRecord now only emits an update while
  the record is still the cached one; the deletion notification that
  follows emits null.
- In TypeScript, Relation now accepts a nullable record type (as Flow
  already does), so failsafe relations and relations with an optional
  ID can be declared as e.g. Relation<User | undefined>, and observe()
  and fetch() are typed accordingly. Documented in Relation.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 31, 2026 07:42

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟢 Approval recommended

There are no active review issues blocking approval.

No open issues 🎉

Review effort: Balanced

@joker-777
joker-777 merged commit a99aa20 into kenhub Oct 31, 2026
1 check passed
@joker-777

Copy link
Copy Markdown
Author

Offered upstream as Nozbe#1982, rebuilt on upstream master.

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.

2 participants