UNPKG

replicache

Version:

Realtime sync for any backend stack

539 lines (533 loc) 24.9 kB
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 };