Skip to content

Proposal: Store signals #260

@terrablue

Description

@terrablue

Motivation

Store methods such as find and get return snapshots — they reflect the state of the database at the moment they are called. This is sufficient for most request/response interactions, but falls short when the client needs to reflect live data as it changes over time.

A store signal bridges this gap: it is a server signal backed by a store, representing a live, bounded view of the current state of a table. Unlike a snapshot, a store signal updates automatically when the underlying data changes, and notifies all subscribers with the new value.

Store signals are intentionally limited in scope. They model the live edge of data — the latest records matching a criteria, or a single record by primary key. For historical or paginated data, Post.find() remains the right tool.

Proposed Solution

1) Two overloads on Store.signal

signal is a method on every Store instance, with two overloads mirroring the existing get/find distinction:

// single record by primary key — mirrors Post.get semantics
Post.signal(id);
 
// many records — mirrors Post.find semantics
Post.signal({ where, limit, sort });

Both return a ReadableSignal as defined in #259 — the full contract of .get(), .peek(), and .subscribe() applies. peek() is particularly useful for SSR, where the last known value can be rendered synchronously without suspending.

2) Single record signal

Watches a single record by primary key:

const post = Post.signal(1);
 
post.peek();                                  // Schema<T> | undefined — synchronous, may be stale
await post.get();                             // Schema<T> — latest known canonical value
post.subscribe(value => console.log(value));  // emits on every change, immediately emits current value if cached

Typed as ReadableSignal<Schema<T>>, mirroring Post.get semantics. Post.signal(id) assumes the record exists for the lifetime of the signal — consistent with Post.get, which also throws on a missing record. What happens if the record is deleted while the signal is active is deferred to a future Post.signal.try(id) variant, which would mirror Post.try semantics and type the signal as ReadableSignal<Schema<T> | undefined>.

If you need to watch a single record by criteria rather than primary key, the recommended pattern is a one-time prequery to retrieve the primary key, followed by a signal:

const [{ id }] = await Post.find({ where: { slug: "hello-world" }, limit: 1 });
const post = Post.signal(id);

This is intentional — signals watch known, stable identities. Primary keys do not change; criteria may.

3) Many records signal

Watches a live, bounded set of records:

const posts = Post.signal({ where: { published: true }, limit: 20 });
 
posts.peek();                                  // Schema<T>[] | undefined — synchronous, may be stale
await posts.get();                             // Schema<T>[] — latest known canonical value
posts.subscribe(value => console.log(value));  // emits on every change, immediately emits current value if cached

Typed as ReadableSignal<Schema<T>[]>.

limit is required for the many-record overload. Omitting it is a hard error at signal creation time. This keeps memory footprint predictable and makes the bounded-live-view contract explicit.

4) Store signals are for live data, not pagination

A store signal always represents the current state of a bounded dataset. It is not a pagination primitive.

For feeds and similar use cases, the recommended pattern is to combine a signal for live incoming data with a regular query for historical data:

// live — new posts as they arrive
const livePosts = Post.signal({
  where: { createdAt: { $after: now } },
  sort: { createdAt: "desc" },
  limit: 20,
});
 
// historical — user-triggered, not reactive
const olderPosts = await Post.find({
  where: { createdAt: { $before: oldest } },
  limit: 20,
});

5) Example: live feed over WebSockets

import route from "primate/route";
import response from "primate/response";
import Post from "#store/Post";
 
const livePosts = Post.signal({
  sort: { createdAt: "desc" },
  limit: 20,
});
 
route.get(() => response.ws({
  open(socket) {
    const unsub = livePosts.subscribe(value => socket.send(value));
    socket.on("close", unsub);
  },
}));

Because subscribe immediately emits the current cached value, a newly connected client receives the latest posts without waiting for the next DB mutation.

6) Error model

Store signals inherit the error model from the Server Signals proposal:

  • if the initial DB query fails before the first value is established, get() rejects with that error
  • if a recomputation fails after a value has already been established, the signal retains its last known value — subscribers are not notified of the error
  • DB errors after first value are surfaced via logging or hooks, not through subscribe

7) Implementation note

Store signals require a mechanism to detect mutations (insert, update, delete) and trigger recomputation. This may be implemented via DB-level live query support (as explored in issue #62 for SQLite, PostgreSQL, and SurrealDB) or via a bespoke mutation hook internal to the store. This is an implementation detail and out of scope for this proposal.

Security

No new security surface is introduced beyond what the store already exposes. Store signals respect the same field-level access controls as regular store queries. Consumers are responsible for what they expose over any transport.

Summary of changes

Area Change
Store class Add signal(id) and signal({ where, limit, sort }) overloads, both returning ReadableSignal
Contract Inherits full ReadableSignal<T> contract from Server Signals: .get(), .peek(), .subscribe()
Semantics Immediate emit on subscribe(); peek() available for synchronous SSR reads; DB errors after first value preserve last known state
Documentation Add "Store Signals" section with single/many examples, pagination guidance, and error model
Tests Add tests for single record signal, many records signal, subscriber notification on mutation, immediate emit, and error handling

Open questions

  • Invalidation granularity: in v1, any mutation to the Store (insert, update, delete) invalidates all signals on that Store regardless of whether the mutated record matches the signal's where clause. All signals on that store will requery. Fine-grained invalidation — only recomputing when the mutation actually affects the signal's result set — is deferred to a future revision and should be noted clearly in documentation so users are not surprised by over-recomputation on busy tables.
  • Post.signal.try(id) — a variant mirroring Post.try that types the signal as ReadableSignal<Schema<T> | undefined> and handles deletion gracefully — is deferred to a future proposal.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions