replicache
Version:
Realtime sync for any backend stack
539 lines (533 loc) • 24.9 kB
TypeScript
import { LogContext } from '@rocicorp/logger';
import { av as Hash, d as MaybePromise, aw as Store, ax as Chunk, ay as ChunkHasher, az as Read, aA as Write, aB as Refs, ad as ClientID, aC as QueryInternal, aD as SubscriptionsManager, M as MutatorDefs, a as MakeMutators, P as Puller, b as Pusher, c as RequestOptions, U as UpdateNeededReason, R as ReplicacheOptions, aE as PokeInternal, aF as BeginPullResult, f as ReadTransaction, g as SubscribeOptions, W as WatchNoIndexCallback, h as WatchOptions, i as WatchCallbackForOptions, C as Cookie, j as PendingMutation } from './chunk-C1TWiB5U.js';
import { Infer, Type } from '@badrap/valita';
type HeadChange = {
new: Hash | undefined;
old: Hash | undefined;
};
interface RefCountUpdatesDelegate {
getRefCount: (hash: Hash) => MaybePromise<number | undefined>;
getRefs: (hash: Hash) => MaybePromise<readonly Hash[] | undefined>;
/**
* Should be implemented if the store lazily loads refs, returning whether
* or not the chunks refs have already been counted (i.e. are reflected
* in `getRefCount`).
*
* If defined then:
* - `getRefs` should return undefined for refs that have not been loaded,
* but should never return undefined for hashes in `putChunks`.
* - it is assumed that chunks in `putChunks` may have been reachable before
* the write, but may not have been counted. This method is used to
* determine if they have been counted or not. If they have not been
* counted, and are reachable with the write applied, the returned
* ref count updates will include updates for counting them.
*
* If undefined then:
* - `getRefs` should never return undefined
* - it is assumed that the refs of any chunks which were reachable before
* the write are already counted
*/
areRefsCounted?: (hash: Hash) => boolean;
}
/**
* Dag Store which lazily loads values from a source store and then caches
* them in an LRU cache. The memory cache for chunks from the source store
* size is limited to `sourceCacheSizeLimit` bytes, and values are evicted in an
* LRU fashion. The purpose of this store is to avoid holding the entire client
* view (i.e. the source store's content) in each tab's JavaScript heap.
*
* This store's heads are independent from the heads of source store, and are
* only stored in memory.
*
* Chunks which are created via this store's {@link Write} transaction's
* {@link createChunk} method are assumed to not be persisted to the source
* store and thus are cached separately from the source store chunks. These
* memory-only chunks will not be evicted, and their sizes are not counted
* towards the source chunk cache size. A memory-only chunk will be deleted if
* it is no longer reachable from one of this store's heads.
*
* Writes only manipulate the in memory state of this store and do not alter the
* source store. Thus values must be written to the source store through a
* separate process (see {@link persist}).
*
* Intended use:
* 1. source store is the 'perdag', a slower persistent store (i.e.
* dag.StoreImpl using a kv.IDBStore)
* 2. this store's 'main' head is initialized to the hash of a chunk containing
* a commit in the source store
* 3. reads lazily read chunks from the source store and cache them
* 3. writes are initially made to this store with memory-only chunks
* 4. writes are asynchronously persisted to the source store through a separate
* process (see {@link persist}}. This process gathers memory-only chunks
* from this store and then writes them to the source store. It then informs
* this store that these chunks are no longer memory-only by calling
* {@link chunksPersisted}, which move these chunks
* to this store's LRU cache of source chunks (making them eligible for
* eviction).
*
* @param sourceStore Store to lazy load and cache values from.
* @param sourceCacheSizeLimit Size limit in bytes for cache of chunks loaded
* from `sourceStore`. This size of a value is determined using
* `getSizeOfValue`. Keys do not count towards cache size. Memory-only chunks
* do not count towards cache size.
* @param getSizeOfValue Function for measuring the size in bytes of a value.
*/
declare class LazyStore implements Store {
#private;
/** The following are protected so testing subclass can access. */
protected readonly _memOnlyChunks: Map<Hash, Chunk<unknown>>;
protected readonly _sourceChunksCache: ChunksCache;
/**
* Ref counts are maintained so that chunks which are unreachable
* from this stores heads can be eagerly and deterministically deleted from
* `this._memOnlyChunks` and `this._sourceChunksCache`.
*
* These ref counts are independent from `this._sourceStore`'s ref counts.
* These ref counts are based on reachability from `this._heads`.
* A chunk is deleted from `this._memOnlyChunks` or
* `this._sourceChunksCache` (which ever it is in) when its ref count becomes
* zero.
* These ref counts count the refs in `this._heads` and `this._refs`.
*
* Not all reachable chunk's refs are included in `this._refs`, because this
* would require loading all chunks reachable in the source store in a
* non-lazy manner. `this._refs` contains the refs of all currently reachable
* chunks that were ever in `this._memOnlyChunks` or
* `this._sourceChunksCache` (even if they have been evicted). A
* chunk's ref information is lazily discovered and stored in `this._refs` and
* counted in `this._refCounts`. A chunk's entries in `this._refs` and
* `this._refCounts` are only deleted when a chunk is deleted due to it
* becoming unreachable (it is not deleted if the chunk is evicted from the
* source-store cache).
*
* The major implication of this lazy discovery of source store refs, is that
* a reachable source store chunk may not be cached when loaded, because it is
* not known to be reachable because some of the pertinent refs have not been
* discovered. However, in practice chunks are read by traversing the graph
* starting from a head, and all pertinent refs are discovered as part of the
* traversal.
*
* These ref counts can be changed in two ways:
* 1. A LazyRead has a cache miss and loads a chunk from the source store that
* is reachable from this._heads. If this chunk's refs are not currently
* counted, it will not have an entry in `this._refs`. In this case, the
* chunks refs will be put in `this._refs` and `this._refCounts` will be
* updated to count them.
* 2. A LazyWrite commit updates a head (which can result in increasing or
* decreasing ref count) or puts a reachable chunk (either a `memory-only` or
* `source` chunk) that references this hash (increasing ref count). The
* computation of these ref count changes is delegated to the
* `computeRefCountUpdates` shared with dag.StoreImpl. In order to
* delegate determining reachability to `computeRefCountUpdates` and defer
* this determination until commit time, LazyWrite treats cache misses
* as a 'put' of the lazily-loaded chunk.
*
* A chunk's hash may have an entry in `this._refCounts` without that
* chunk have ever been in `this._memOnlyChunks` or `this._sourceChunksCache`.
* This is the case when a head or a reachable chunk that was ever in
* `this._memOnlyChunks` or `this._sourceChunksCache` references a chunk
* which is not currently cached (either because it has not been read, or
* because it has been evicted).
*/
protected readonly _refCounts: Map<Hash, number>;
protected readonly _refs: Map<Hash, readonly Hash[]>;
constructor(sourceStore: Store, sourceCacheSizeLimit: number, chunkHasher: ChunkHasher, assertValidHash: (hash: Hash) => void, getSizeOfChunk?: (chunk: Chunk) => number);
read(): Promise<LazyRead>;
write(): Promise<LazyWrite>;
close(): Promise<void>;
/**
* Does not acquire any lock on the store.
*/
isCached(chunkHash: Hash): boolean;
withSuspendedSourceCacheEvictsAndDeletes<T>(fn: () => MaybePromise<T>): Promise<T>;
}
declare class LazyRead implements Read {
#private;
protected readonly _heads: Map<string, Hash>;
protected readonly _memOnlyChunks: Map<Hash, Chunk>;
protected readonly _sourceChunksCache: ChunksCache;
protected readonly _sourceStore: Store;
readonly assertValidHash: (hash: Hash) => void;
constructor(heads: Map<string, Hash>, memOnlyChunks: Map<Hash, Chunk>, sourceChunksCache: ChunksCache, sourceStore: Store, release: () => void, assertValidHash: (hash: Hash) => void);
isMemOnlyChunkHash(hash: Hash): boolean;
hasChunk(hash: Hash): Promise<boolean>;
getChunk(hash: Hash): Promise<Chunk | undefined>;
mustGetChunk(hash: Hash): Promise<Chunk>;
getHead(name: string): Promise<Hash | undefined>;
release(): void;
get closed(): boolean;
protected _getSourceRead(): Promise<Read>;
}
declare class LazyWrite extends LazyRead implements Write, RefCountUpdatesDelegate {
#private;
protected readonly _pendingHeadChanges: Map<string, HeadChange>;
protected readonly _pendingMemOnlyChunks: Map<Hash, Chunk<unknown>>;
protected readonly _pendingCachedChunks: Map<Hash, {
chunk: Chunk;
size: number;
}>;
constructor(heads: Map<string, Hash>, memOnlyChunks: Map<Hash, Chunk>, sourceChunksCache: ChunksCache, sourceStore: Store, refCounts: Map<Hash, number>, refs: Map<Hash, readonly Hash[]>, release: () => void, chunkHasher: ChunkHasher, assertValidHash: (hash: Hash) => void);
createChunk: <V>(data: V, refs: Refs) => Chunk<V>;
putChunk<V>(c: Chunk<V>, size?: number): Promise<void>;
setHead(name: string, hash: Hash): Promise<void>;
removeHead(name: string): Promise<void>;
isMemOnlyChunkHash(hash: Hash): boolean;
getChunk(hash: Hash): Promise<Chunk | undefined>;
getHead(name: string): Promise<Hash | undefined>;
commit(): Promise<void>;
getRefCount(hash: Hash): number | undefined;
getRefs(hash: Hash): readonly Hash[] | undefined;
areRefsCounted(hash: Hash): boolean;
chunksPersisted(chunkHashes: readonly Hash[]): void;
}
type CacheEntry = {
chunk: Chunk;
size: number;
};
declare class ChunksCache {
#private;
/**
* Iteration order is from least to most recently used.
*
* Public so that testing subclass can access.
*/
readonly cacheEntries: Map<Hash, CacheEntry>;
constructor(cacheSizeLimit: number, getSizeOfChunk: (v: Chunk) => number, refCounts: Map<Hash, number>, refs: Map<Hash, readonly Hash[]>);
get(hash: Hash): Chunk | undefined;
getWithoutUpdatingLRU(hash: Hash): Chunk | undefined;
put(chunk: Chunk): void;
updateForCommit(chunksToPut: Map<Hash, {
chunk: Chunk;
size: number;
}>, refCountUpdates: Map<Hash, number>): void;
persisted(chunks: Iterable<Chunk>): void;
withSuspendedEvictsAndDeletes<T>(fn: () => MaybePromise<T>): Promise<T>;
}
type ClientMap = ReadonlyMap<ClientID, ClientV4 | ClientV5 | ClientV6>;
declare const clientV4Schema: Type<Readonly<{
heartbeatTimestampMs: number;
headHash: Hash;
mutationID: number;
lastServerAckdMutationID: number;
}>>;
type ClientV4 = Infer<typeof clientV4Schema>;
declare const clientV5Schema: Type<Readonly<{
heartbeatTimestampMs: number;
headHash: Hash;
tempRefreshHash: Hash | null;
clientGroupID: string;
}>>;
type ClientV5 = Infer<typeof clientV5Schema>;
declare const clientV6Schema: Type<Readonly<{
heartbeatTimestampMs: number;
refreshHashes: readonly Hash[];
persistHash: Hash | null;
clientGroupID: string;
}>>;
type ClientV6 = Infer<typeof clientV6Schema>;
interface MakeSubscriptionsManager {
(queryInternal: QueryInternal, lc: LogContext): SubscriptionsManager;
}
interface ReplicacheImplOptions {
/**
* Defaults to true.
*/
enableMutationRecovery?: boolean | undefined;
/**
* Defaults to true.
*/
enableScheduledPersist?: boolean | undefined;
/**
* Defaults to true.
*/
enableScheduledRefresh?: boolean | undefined;
/**
* Defaults to true.
*/
enablePullAndPushInOpen?: boolean | undefined;
/**
* Default is `defaultMakeSubscriptionsManager`.
*/
makeSubscriptionsManager?: MakeSubscriptionsManager | undefined;
/**
* Default is `true`. If `false` if an exact match client group
* is not found, a new client group is always made instead of forking
* from an existing client group.
*/
enableClientGroupForking?: boolean | undefined;
}
declare class ReplicacheImpl<MD extends MutatorDefs = {}> {
#private;
/** The URL to use when doing a pull request. */
pullURL: string;
/** The URL to use when doing a push request. */
pushURL: string;
/** The authorization token used when doing a push request. */
auth: string;
/** The name of the Replicache database. Populated by {@link ReplicacheOptions#name}. */
readonly name: string;
readonly subscriptions: SubscriptionsManager;
/**
* Client groups gets disabled when the server does not know about it.
* A disabled client group prevents the client from pushing and pulling.
*/
isClientGroupDisabled: boolean;
lastMutationID: number;
/**
* This is the name Replicache uses for the IndexedDB database where data is
* stored.
*/
get idbName(): string;
/** The schema version of the data understood by this application. */
readonly schemaVersion: string;
/**
* The mutators that was registered in the constructor.
*/
readonly mutate: MakeMutators<MD>;
/**
* The duration between each periodic {@link pull}. Setting this to `null`
* disables periodic pull completely. Pull will still happen if you call
* {@link pull} manually.
*/
pullInterval: number | null;
/**
* The delay between when a change is made to Replicache and when Replicache
* attempts to push that change.
*/
pushDelay: number;
/**
* The function to use to pull data from the server.
*/
puller: Puller;
/**
* The function to use to push data to the server.
*/
pusher: Pusher;
readonly memdag: LazyStore;
readonly perdag: Store;
/**
* The options used to control the {@link pull} and push request behavior. This
* object is live so changes to it will affect the next pull or push call.
*/
get requestOptions(): Required<RequestOptions>;
/**
* `onSync(true)` is called when Replicache transitions from no push or pull
* happening to at least one happening. `onSync(false)` is called in the
* opposite case: when Replicache transitions from at least one push or pull
* happening to none happening.
*
* This can be used in a React like app by doing something like the following:
*
* ```js
* const [syncing, setSyncing] = useState(false);
* useEffect(() => {
* rep.onSync = setSyncing;
* }, [rep]);
* ```
*/
onSync: ((syncing: boolean) => void) | null;
/**
* `onClientStateNotFound` is called when the persistent client has been
* garbage collected. This can happen if the client has no pending mutations
* and has not been used for a while.
*
* The default behavior is to reload the page (using `location.reload()`). Set
* this to `null` or provide your own function to prevent the page from
* reloading automatically.
*/
onClientStateNotFound: (() => void) | null;
/**
* `onUpdateNeeded` is called when a code update is needed.
*
* A code update can be needed because:
* - the server no longer supports the {@link pushVersion},
* {@link pullVersion} or {@link schemaVersion} of the current code.
* - a new Replicache client has created a new client group, because its code
* has different mutators, indexes, schema version and/or format version
* from this Replicache client. This is likely due to the new client having
* newer code. A code update is needed to be able to locally sync with this
* new Replicache client (i.e. to sync while offline, the clients can still
* sync with each other via the server).
*
* The default behavior is to reload the page (using `location.reload()`). Set
* this to `null` or provide your own function to prevent the page from
* reloading automatically. You may want to provide your own function to
* display a toast to inform the end user there is a new version of your app
* available and prompting them to refresh.
*/
onUpdateNeeded: ((reason: UpdateNeededReason) => void) | null;
/**
* This gets called when we get an HTTP unauthorized (401) response from the
* push or pull endpoint. Set this to a function that will ask your user to
* reauthenticate.
*/
getAuth: (() => MaybePromise<string | null | undefined>) | null | undefined;
onPushInvoked: () => undefined;
onBeginPull: () => undefined;
onRecoverMutations: (r: Promise<boolean>) => Promise<boolean>;
constructor(options: ReplicacheOptions<MD>, implOptions?: ReplicacheImplOptions);
/**
* The browser profile ID for this browser profile. Every instance of Replicache
* browser-profile-wide shares the same profile ID.
*/
get profileID(): Promise<string>;
/**
* The client ID for this instance of Replicache. Each instance of Replicache
* gets a unique client ID.
*/
get clientID(): string;
/**
* The client group ID for this instance of Replicache. Instances of
* Replicache will have the same client group ID if and only if they have
* the same name, mutators, indexes, schema version, format version, and
* browser profile.
*/
get clientGroupID(): Promise<string>;
/**
* `onOnlineChange` is called when the {@link online} property changes. See
* {@link online} for more details.
*/
onOnlineChange: ((online: boolean) => void) | null;
/**
* A rough heuristic for whether the client is currently online. Note that
* there is no way to know for certain whether a client is online - the next
* request can always fail. This property returns true if the last sync attempt succeeded,
* and false otherwise.
*/
get online(): boolean;
/**
* Whether the Replicache database has been closed. Once Replicache has been
* closed it no longer syncs and you can no longer read or write data out of
* it. After it has been closed it is pretty much useless and should not be
* used any more.
*/
get closed(): boolean;
/**
* Closes this Replicache instance.
*
* When closed all subscriptions end and no more read or writes are allowed.
*/
close(): Promise<void>;
maybeEndPull(syncHead: Hash, requestID: string): Promise<void>;
/**
* Push pushes pending changes to the {@link pushURL}.
*
* You do not usually need to manually call push. If {@link pushDelay} is
* non-zero (which it is by default) pushes happen automatically shortly after
* mutations.
*
* If the server endpoint fails push will be continuously retried with an
* exponential backoff.
*
* @param [now=false] If true, push will happen immediately and ignore
* {@link pushDelay}, {@link RequestOptions.minDelayMs} as well as the
* exponential backoff in case of errors.
* @returns A promise that resolves when the next push completes. In case of
* errors the first error will reject the returned promise. Subsequent errors
* will not be reflected in the promise.
*/
push({ now }?: {
now?: boolean | undefined;
}): Promise<void>;
/**
* Pull pulls changes from the {@link pullURL}. If there are any changes local
* changes will get replayed on top of the new server state.
*
* If the server endpoint fails pull will be continuously retried with an
* exponential backoff.
*
* @param [now=false] If true, pull will happen immediately and ignore
* {@link RequestOptions.minDelayMs} as well as the exponential backoff in
* case of errors.
* @returns A promise that resolves when the next pull completes. In case of
* errors the first error will reject the returned promise. Subsequent errors
* will not be reflected in the promise.
*/
pull({ now }?: {
now?: boolean | undefined;
}): Promise<void>;
/**
* Applies an update from the server to Replicache.
* Throws an error if cookie does not match. In that case the server thinks
* this client has a different cookie than it does; the caller should disconnect
* from the server and re-register, which transmits the cookie the client actually
* has.
*
* @experimental This method is under development and its semantics will change.
*/
poke(poke: PokeInternal): Promise<void>;
beginPull(): Promise<BeginPullResult>;
persist(): Promise<void>;
refresh(): Promise<void>;
disableClientGroup(): Promise<void>;
/**
* Subscribe to the result of a {@link query}. The `body` function is
* evaluated once and its results are returned via `onData`.
*
* Thereafter, each time the the result of `body` changes, `onData` is fired
* again with the new result.
*
* `subscribe()` goes to significant effort to avoid extraneous work
* re-evaluating subscriptions:
*
* 1. subscribe tracks the keys that `body` accesses each time it runs. `body`
* is only re-evaluated when those keys change.
* 2. subscribe only re-fires `onData` in the case that a result changes by
* way of the `isEqual` option which defaults to doing a deep JSON value
* equality check.
*
* Because of (1), `body` must be a pure function of the data in Replicache.
* `body` must not access anything other than the `tx` parameter passed to it.
*
* Although subscribe is as efficient as it can be, it is somewhat constrained
* by the goal of returning an arbitrary computation of the cache. For even
* better performance (but worse dx), see {@link experimentalWatch}.
*
* If an error occurs in the `body` the `onError` function is called if
* present. Otherwise, the error is logged at log level 'error'.
*
* To cancel the subscription, call the returned function.
*
* @param body The function to evaluate to get the value to pass into
* `onData`.
* @param options Options is either a function or an object. If it is a
* function it is equivalent to passing it as the `onData` property of an
* object.
*/
subscribe<R>(body: (tx: ReadTransaction) => Promise<R>, options: SubscribeOptions<R> | ((result: R) => void)): () => void;
/**
* Watches Replicache for changes.
*
* The `callback` gets called whenever the underlying data changes and the
* `key` changes matches the `prefix` of {@link ExperimentalWatchIndexOptions} or
* {@link ExperimentalWatchNoIndexOptions} if present. If a change
* occurs to the data but the change does not impact the key space the
* callback is not called. In other words, the callback is never called with
* an empty diff.
*
* This gets called after commit (a mutation or a rebase).
*
* @experimental This method is under development and its semantics will
* change.
*/
experimentalWatch(callback: WatchNoIndexCallback): () => void;
experimentalWatch<Options extends WatchOptions>(callback: WatchCallbackForOptions<Options>, options?: Options): () => void;
/**
* Query is used for read transactions. It is recommended to use transactions
* to ensure you get a consistent view across multiple calls to `get`, `has`
* and `scan`.
*/
query<R>(body: (tx: ReadTransaction) => Promise<R> | R): Promise<R>;
get cookie(): Promise<Cookie>;
recoverMutations(preReadClientMap?: ClientMap): Promise<boolean>;
/**
* List of pending mutations. The order of this is from oldest to newest.
*
* Gives a list of local mutations that have `mutationID` >
* `syncHead.mutationID` that exists on the main client group.
*
* @experimental This method is experimental and may change in the future.
*/
experimentalPendingMutations(): Promise<readonly PendingMutation[]>;
}
export { type MakeSubscriptionsManager, ReplicacheImpl, type ReplicacheImplOptions };