diff --git a/eslint.config.mjs b/eslint.config.mjs index c66047f2..d93b3b50 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -6,6 +6,9 @@ export default antfu({ '**/.nitro', 'docs/guide/migration/**', '**/skills/**', + // Benchmark fixtures: contains a verbatim snapshot of the previous cache + // implementation (the baseline) plus intentional console reporting. + 'packages/*/benchmark/**', ], rules: { 'vue/object-property-newline': ['error', { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index eacff25e..2b718ecc 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -10,6 +10,7 @@ export * from './module.js' export * from './mutation/index.js' export * from './plugin.js' export * from './query/index.js' +export * from './store-engine/index.js' export * from './store.js' export * from './subscription/index.js' export * from './tombstone.js' diff --git a/packages/core/src/store-engine/engine.ts b/packages/core/src/store-engine/engine.ts new file mode 100644 index 00000000..2dc7e739 --- /dev/null +++ b/packages/core/src/store-engine/engine.ts @@ -0,0 +1,259 @@ +import type { CacheLayer, FieldTimestampValue } from '@rstore/shared' +import type { EngineContext, EngineOptions, StoreEngine } from './types.js' +import { createTombstoneStore, gcTombstones as gcTombstonesStore, scheduleTombstoneGc } from '../tombstone.js' +import { getLayerNow } from './layers.js' +import { createObserverRegistry } from './observers.js' +import { createStaggering, enqueueOperation, flushQueuedOperations } from './queue.js' +import { getIndexBucket, getVisibleKeys, resolveItem } from './resolve.js' +import { getState as serializeState } from './serialize.js' +import { deleteItemFromBase, getFieldTimestamps, setFieldTimestamps } from './write.js' + +/** + * Create the framework-agnostic storage engine. All state lives in plain JS + * structures; reactivity is delegated to subscribers via the observer + * registry, so a host framework (e.g. `@rstore/vue`) can map observers onto + * its own signals without the engine ever importing it. + */ +export function createStoreEngine(options: EngineOptions): StoreEngine { + const { + callbacks, + cacheStaggering = 0, + tombstoneGc = {}, + isServer = false, + } = options + + const observers = createObserverRegistry() + const staggering = createStaggering(cacheStaggering) + const tombstones = createTombstoneStore() + + const ctx: EngineContext = { + collections: new Map(), + markers: {}, + modules: new Map(), + fieldTimestamps: new Map(), + tombstones, + queryMeta: {}, + layerIdToCollection: new Map(), + paused: false, + queue: [], + isFlushingQueue: false, + callbacks, + observers, + staggering, + ensureCollection: undefined as any, + } + + // Lazily create a collection's plain-JS storage container. + ctx.ensureCollection = (name) => { + let collectionState = ctx.collections.get(name) + if (!collectionState) { + collectionState = { base: new Map(), indexes: new Map(), layers: [] } + ctx.collections.set(name, collectionState) + } + return collectionState + } + + // The staggering budget-reset timer re-drives the queue. + staggering.setFlush(() => flushQueuedOperations(ctx)) + + // Auto-GC keeps the tombstone store bounded in long-lived clients. Skipped + // on the server (request-scoped cache) and where timers are unavailable. + let stopTombstoneGc: (() => void) | undefined + const canScheduleTombstoneGc = !isServer && typeof setInterval !== 'undefined' + if (tombstoneGc !== false && canScheduleTombstoneGc) { + stopTombstoneGc = scheduleTombstoneGc(tombstones, { + intervalMs: tombstoneGc.intervalMs ?? 60_000, + ttlMs: tombstoneGc.ttlMs ?? 24 * 60 * 60 * 1000, + }) + } + + return { + readItemRaw({ collection, key }) { + return resolveItem(ctx, collection.name, key) + }, + + resolveKeys({ collection, marker, keys, indexKey, indexValue }) { + // A list gated on an unset marker reads as empty (no fetch happened yet). + if (marker && !ctx.markers[marker]) { + return [] + } + // Index lookups resolve to the bucket's key set. + if (keys == null && indexKey != null) { + const bucket = getIndexBucket(ctx, collection.name, indexKey, indexValue ?? '') + return bucket ? Array.from(bucket) : [] + } + if (keys != null) { + return keys + } + return getVisibleKeys(ctx, collection.name) + }, + + getIndexBucket(collection, indexKey, indexValue) { + return getIndexBucket(ctx, collection, indexKey, indexValue) + }, + + hasMarker(marker) { + return ctx.markers[marker] === true + }, + + writeItem(params) { + enqueueOperation(ctx, { type: 'writeItem', params }) + }, + + writeItems(params) { + enqueueOperation(ctx, { type: 'writeItems', params, index: 0 }) + }, + + deleteItem(params) { + enqueueOperation(ctx, { type: 'deleteItem', params }) + }, + + writeItemForRelation({ parentCollection, relationKey, relation, childItem, meta }) { + const possibleCollections = Object.keys(relation.to) + const nestedItemCollection = ctx.callbacks.resolveChildCollection(childItem, possibleCollections) + if (!nestedItemCollection) { + throw new Error(`Could not determine type for relation ${parentCollection.name}.${String(relationKey)}`) + } + const nestedKey = nestedItemCollection.getKey(childItem) + if (nestedKey == null) { + throw new Error(`Could not determine key for relation ${parentCollection.name}.${String(relationKey)}`) + } + this.writeItem({ + collection: nestedItemCollection, + key: nestedKey, + item: childItem, + meta, + }) + }, + + readFieldTimestamps({ collectionName, key }) { + return getFieldTimestamps(ctx, collectionName, key) + }, + + writeFieldTimestamps({ collectionName, key, timestamps }) { + setFieldTimestamps(ctx, collectionName, key, { ...timestamps }) + }, + + getModuleState(name, key, initState) { + const cacheKey = `${name}:${key}` + let mod = ctx.modules.get(cacheKey) + if (!mod) { + mod = { value: initState } + ctx.modules.set(cacheKey, mod) + } + return mod.value + }, + + getState() { + return serializeState(ctx) + }, + + setState(state) { + enqueueOperation(ctx, { type: 'setState', state }) + }, + + clear() { + enqueueOperation(ctx, { type: 'clear' }) + }, + + clearCollection({ collection }) { + ctx.fieldTimestamps.delete(collection.name) + const tombs = Array.from(ctx.tombstones.entries(), ([, t]) => t) + .filter(t => t.collection === collection.name) + for (const t of tombs) { + ctx.tombstones.clear(t.collection, t.key) + } + const collectionState = ctx.collections.get(collection.name) + if (collectionState) { + // Batch all deletes behind a single flush: enqueuing per key while + // unpaused would drain + dispatch observers once per item (O(N) on + // large collections). Pause around the loop, then flush once. If the + // caller already paused, leave the deletes queued for their resume. + const wasPaused = ctx.paused + ctx.paused = true + try { + // Snapshot keys: each delete mutates `base` as the queue drains. + for (const key of Array.from(collectionState.base.keys())) { + enqueueOperation(ctx, { type: 'deleteItem', params: { collection, key } }) + } + } + finally { + ctx.paused = wasPaused + if (!wasPaused) { + flushQueuedOperations(ctx) + } + } + } + }, + + garbageCollectKey(collection, key) { + const removed = deleteItemFromBase(ctx, { collection, key }) + if (removed) { + ctx.observers.flush() + } + return removed + }, + + forEachKey(collection, cb) { + const collectionState = ctx.collections.get(collection) + if (!collectionState) { + return + } + for (const key of Array.from(collectionState.base.keys())) { + cb(key) + } + }, + + addLayer(layer) { + enqueueOperation(ctx, { type: 'addLayer', layer }) + }, + + getLayer(layerId) { + return getLayerNow(ctx, layerId) + }, + + removeLayer(layerId) { + enqueueOperation(ctx, { type: 'removeLayer', layerId }) + }, + + tombstones, + + gcTombstones(olderThan: FieldTimestampValue) { + return gcTombstonesStore(ctx.tombstones, olderThan) + }, + + pause() { + ctx.paused = true + }, + + resume() { + ctx.paused = false + flushQueuedOperations(ctx) + }, + + dispose() { + stopTombstoneGc?.() + stopTombstoneGc = undefined + // Cancel any pending staggering budget-reset timer. + staggering.dispose() + }, + + observeItem: observers.observeItem, + observeList: observers.observeList, + observeIndex: observers.observeIndex, + + _getLayers() { + const result = new Map() + for (const [name, collectionState] of ctx.collections) { + result.set(name, collectionState.layers) + } + return result + }, + + _getQueryMeta() { + return ctx.queryMeta + }, + + _ctx: ctx, + } +} diff --git a/packages/core/src/store-engine/index.ts b/packages/core/src/store-engine/index.ts new file mode 100644 index 00000000..2ed6440e --- /dev/null +++ b/packages/core/src/store-engine/index.ts @@ -0,0 +1,20 @@ +export { createStoreEngine } from './engine.js' +export { createObserverRegistry } from './observers.js' +export { + getIndexBucket, + getVisibleKeys, + resolveItem, +} from './resolve.js' +export type { + EngineAfterWritePayload, + EngineCallbacks, + EngineCollectionState, + EngineConflictPayload, + EngineContext, + EngineOptions, + ObserverCallback, + ObserverRegistry, + StoreEngine, + TombstoneGcOptions, + Unsubscribe, +} from './types.js' diff --git a/packages/core/src/store-engine/layers.ts b/packages/core/src/store-engine/layers.ts new file mode 100644 index 00000000..12c7238d --- /dev/null +++ b/packages/core/src/store-engine/layers.ts @@ -0,0 +1,99 @@ +import type { CacheLayer } from '@rstore/shared' +import type { EngineContext } from './types.js' +import { resolveItem, updateItemIndexes } from './resolve.js' + +/** Find a layer by id across the collection it was registered against. */ +export function getLayerNow(ctx: EngineContext, layerId: string): CacheLayer | undefined { + const collectionName = ctx.layerIdToCollection.get(layerId) + if (!collectionName) { + return undefined + } + const collectionState = ctx.collections.get(collectionName) + return collectionState?.layers.find(l => l.id === layerId) +} + +/** + * Touch the item observers for every key a layer affects (inserts, modifies + * and deletes) plus the collection list, so a layer add/remove re-runs exactly + * the dependent reactive scopes. + */ +function touchLayerScopes(ctx: EngineContext, layer: CacheLayer): void { + for (const key of Object.keys(layer.state)) { + ctx.observers.touchItem(layer.collectionName, key) + } + for (const key of layer.deletedItems) { + ctx.observers.touchItem(layer.collectionName, key) + } + ctx.observers.touchList(layer.collectionName) +} + +/** + * Add an optimistic layer to its collection and reconcile relation indexes so + * that layer-inserted/modified items are findable by relation lookups. + * + * An existing layer with the same id is removed first (replace semantics). + * Index entries are computed against the pre-layer resolved item so the diff + * is correct, then applied after the layer is in place. + */ +export function addLayerNow(ctx: EngineContext, layer: CacheLayer): void { + const collection = ctx.callbacks.getCollection(layer.collectionName) + if (!collection) { + throw new Error(`Collection not found for layer: ${layer.collectionName}`) + } + + removeLayerNow(ctx, layer.id) + + // Capture the pre-layer resolved item per affected key for index diffing. + const queuedIndexUpdates: Array<[string | number, any, any]> = [] + for (const key in layer.state) { + const existing = resolveItem(ctx, collection.name, key) + queuedIndexUpdates.push([key, existing, layer.state[key]]) + } + + const collectionState = ctx.ensureCollection(collection.name) + collectionState.layers = [...collectionState.layers, layer] + ctx.layerIdToCollection.set(layer.id, layer.collectionName) + + for (const [key, existing, newData] of queuedIndexUpdates) { + updateItemIndexes(ctx, collection, key, existing, newData) + } + + touchLayerScopes(ctx, layer) + ctx.callbacks.onLayerAdd?.(layer) +} + +/** + * Remove a layer and revert the relation indexes its state contributed, so + * lookups again reflect the underlying base items. + */ +export function removeLayerNow(ctx: EngineContext, layerId: string): void { + const collectionName = ctx.layerIdToCollection.get(layerId) + if (!collectionName) { + return + } + const collectionState = ctx.collections.get(collectionName) + if (!collectionState) { + return + } + const layer = collectionState.layers.find(l => l.id === layerId) + if (!layer) { + return + } + + collectionState.layers = collectionState.layers.filter(l => l.id !== layerId) + ctx.layerIdToCollection.delete(layerId) + + const collection = ctx.callbacks.getCollection(layer.collectionName) + if (collection) { + // With the layer gone, `currentData` is the reverted (base) item; rebuild + // the pre-removal value to move the index entry back. + for (const key in layer.state) { + const currentData = resolveItem(ctx, collection.name, key) + const previousData = { ...currentData, ...layer.state[key] } + updateItemIndexes(ctx, collection, key, previousData, currentData) + } + } + + touchLayerScopes(ctx, layer) + ctx.callbacks.onLayerRemove?.(layer) +} diff --git a/packages/core/src/store-engine/observers.ts b/packages/core/src/store-engine/observers.ts new file mode 100644 index 00000000..36288eec --- /dev/null +++ b/packages/core/src/store-engine/observers.ts @@ -0,0 +1,181 @@ +import type { ObserverCallback, ObserverRegistry, Unsubscribe } from './types.js' + +/** Pending change accumulator, deduped per scope until the next flush. */ +interface PendingChange { + items: Map> + lists: Set + indexes: Map>> +} + +/** + * Create the fine-grained observer registry. + * + * Three independent registries track per-item, per-list (collection) and + * per-index-bucket subscriptions. Mutating code records touched scopes via + * `touch*`; {@link ObserverRegistry.flush} then invokes each matching + * callback exactly once and clears the accumulated change. + * + * Callbacks run synchronously inside a try/catch so one faulty subscriber + * can't break the notification fan-out for the others. + */ +export function createObserverRegistry(): ObserverRegistry { + // collection -> key -> callbacks + const itemObservers = new Map>>() + // collection -> callbacks + const listObservers = new Map>() + // collection -> indexKey -> indexValue -> callbacks + const indexObservers = new Map>>>() + + let change = createPending() + + function createPending(): PendingChange { + return { + items: new Map(), + lists: new Set(), + indexes: new Map(), + } + } + + function observeItem(collection: string, key: string | number, cb: ObserverCallback): Unsubscribe { + let byKey = itemObservers.get(collection) + if (!byKey) { + byKey = new Map() + itemObservers.set(collection, byKey) + } + let set = byKey.get(key) + if (!set) { + set = new Set() + byKey.set(key, set) + } + set.add(cb) + return () => { + set!.delete(cb) + if (set!.size === 0) { + byKey!.delete(key) + } + } + } + + function observeList(collection: string, cb: ObserverCallback): Unsubscribe { + let set = listObservers.get(collection) + if (!set) { + set = new Set() + listObservers.set(collection, set) + } + set.add(cb) + return () => { + set!.delete(cb) + } + } + + function observeIndex(collection: string, indexKey: string, indexValue: string, cb: ObserverCallback): Unsubscribe { + let byIndexKey = indexObservers.get(collection) + if (!byIndexKey) { + byIndexKey = new Map() + indexObservers.set(collection, byIndexKey) + } + let byValue = byIndexKey.get(indexKey) + if (!byValue) { + byValue = new Map() + byIndexKey.set(indexKey, byValue) + } + let set = byValue.get(indexValue) + if (!set) { + set = new Set() + byValue.set(indexValue, set) + } + set.add(cb) + return () => { + set!.delete(cb) + if (set!.size === 0) { + byValue!.delete(indexValue) + } + } + } + + function touchItem(collection: string, key: string | number): void { + let set = change.items.get(collection) + if (!set) { + set = new Set() + change.items.set(collection, set) + } + set.add(key) + } + + function touchList(collection: string): void { + change.lists.add(collection) + } + + function touchIndex(collection: string, indexKey: string, indexValue: string): void { + let byIndexKey = change.indexes.get(collection) + if (!byIndexKey) { + byIndexKey = new Map() + change.indexes.set(collection, byIndexKey) + } + let values = byIndexKey.get(indexKey) + if (!values) { + values = new Set() + byIndexKey.set(indexKey, values) + } + values.add(indexValue) + } + + /** Invoke a set of callbacks, isolating failures. */ + function runAll(callbacks: Set | undefined): void { + if (!callbacks || callbacks.size === 0) { + return + } + // Copy so observers can (un)subscribe during notification. + for (const cb of [...callbacks]) { + try { + cb() + } + catch (error) { + console.error('[rstore] observer callback failed', error) + } + } + } + + function flush(): void { + if (change.items.size === 0 && change.lists.size === 0 && change.indexes.size === 0) { + return + } + const flushing = change + change = createPending() + + for (const [collection, keys] of flushing.items) { + const byKey = itemObservers.get(collection) + if (byKey) { + for (const key of keys) { + runAll(byKey.get(key)) + } + } + } + for (const collection of flushing.lists) { + runAll(listObservers.get(collection)) + } + for (const [collection, byIndexKey] of flushing.indexes) { + const obsByIndexKey = indexObservers.get(collection) + if (obsByIndexKey) { + for (const [indexKey, values] of byIndexKey) { + const obsByValue = obsByIndexKey.get(indexKey) + if (obsByValue) { + for (const value of values) { + runAll(obsByValue.get(value)) + } + } + } + } + } + } + + return { + observeItem, + observeList, + observeIndex, + touchItem, + touchList, + touchIndex, + flush, + } +} diff --git a/packages/core/src/store-engine/queue.ts b/packages/core/src/store-engine/queue.ts new file mode 100644 index 00000000..c867f242 --- /dev/null +++ b/packages/core/src/store-engine/queue.ts @@ -0,0 +1,165 @@ +import type { EngineContext, QueuedOperation, Staggering } from './types.js' +import { addLayerNow, removeLayerNow } from './layers.js' +import { clearNow, setStateNow } from './serialize.js' +import { deleteItemFromBase, writeItemNow } from './write.js' + +/** + * Create the write-staggering controller. When `cacheStaggering > 0`, at most + * that many writes are applied per 10ms window so a flood of cache writes does + * not block the main thread; the budget refills on a timer that re-runs the + * flush. `0` disables staggering entirely (writes apply immediately). + */ +export function createStaggering(cacheStaggering: number): Staggering { + const budgetMax = Math.max(0, Math.floor(cacheStaggering)) + let budget = budgetMax + let resetTimer: ReturnType | undefined + let flush: (() => void) | undefined + + function scheduleReset(): void { + if (!budgetMax || resetTimer) { + return + } + resetTimer = setTimeout(() => { + resetTimer = undefined + budget = budgetMax + flush?.() + }, 10) + } + + return { + enabled: budgetMax > 0, + canProcess() { + if (!budgetMax) { + return true + } + if (budget > 0) { + return true + } + scheduleReset() + return false + }, + consume() { + if (!budgetMax) { + return + } + scheduleReset() + budget = Math.max(0, budget - 1) + }, + setFlush(fn) { + flush = fn + }, + dispose() { + // Cancel a pending budget refill so it can't re-drive a torn-down engine. + if (resetTimer) { + clearTimeout(resetTimer) + resetTimer = undefined + } + }, + } +} + +/** + * Push an operation onto the FIFO queue and, unless paused, drain it. + */ +export function enqueueOperation(ctx: EngineContext, operation: QueuedOperation): void { + ctx.queue.push(operation) + if (!ctx.paused) { + flushQueuedOperations(ctx) + } +} + +/** + * Drain the operation queue in FIFO order, applying each op to the engine + * state. Honors the staggering budget: when exhausted, returns early leaving + * the remainder queued (the budget-reset timer re-invokes this). Observer + * notifications accumulated during applied ops are dispatched once in the + * `finally`, after the flush guard is released so reactive callbacks may + * safely enqueue further work. + */ +export function flushQueuedOperations(ctx: EngineContext): void { + if (ctx.isFlushingQueue || ctx.paused) { + return + } + + ctx.isFlushingQueue = true + try { + while (ctx.queue.length) { + const operation = ctx.queue[0]! + switch (operation.type) { + case 'writeItem': + if (!ctx.staggering.canProcess()) { + return + } + writeItemNow(ctx, operation.params) + ctx.staggering.consume() + ctx.queue.shift() + break + case 'writeItems': + while (operation.index < operation.params.items.length) { + if (!ctx.staggering.canProcess()) { + return + } + const { key, value: item } = operation.params.items[operation.index]! + writeItemNow(ctx, { + collection: operation.params.collection, + key, + item, + meta: operation.params.meta, + fromWriteItems: true, + }) + operation.index++ + ctx.staggering.consume() + } + // Marker + the single batch-level write callback fire only after the + // whole batch has been applied. + if (operation.params.marker) { + ctx.markers[operation.params.marker] = true + ctx.observers.touchList(operation.params.collection.name) + } + ctx.callbacks.onAfterWrite?.({ + collection: operation.params.collection, + result: operation.params.items, + marker: operation.params.marker, + operation: 'write', + }) + ctx.queue.shift() + break + case 'deleteItem': { + const { collection, key, deletedAt } = operation.params + // Field timestamps are always cleared on delete; a tombstone is only + // recorded when an explicit delete time is provided. + const collectionTs = ctx.fieldTimestamps.get(collection.name) + if (collectionTs) { + collectionTs.delete(key) + } + if (deletedAt != null) { + ctx.tombstones.set({ collection: collection.name, key, deletedAt }) + } + deleteItemFromBase(ctx, operation.params) + ctx.queue.shift() + break + } + case 'addLayer': + addLayerNow(ctx, operation.layer) + ctx.queue.shift() + break + case 'removeLayer': + removeLayerNow(ctx, operation.layerId) + ctx.queue.shift() + break + case 'setState': + setStateNow(ctx, operation.state) + ctx.queue.shift() + break + case 'clear': + clearNow(ctx) + ctx.queue.shift() + break + } + } + } + finally { + ctx.isFlushingQueue = false + ctx.observers.flush() + } +} diff --git a/packages/core/src/store-engine/resolve.ts b/packages/core/src/store-engine/resolve.ts new file mode 100644 index 00000000..7ac158ea --- /dev/null +++ b/packages/core/src/store-engine/resolve.ts @@ -0,0 +1,231 @@ +import type { CacheLayer, ResolvedCollection } from '@rstore/shared' +import type { EngineCollectionState, EngineContext } from './types.js' + +/** + * Resolve the layer-merged raw item for a key, or `undefined` if it does not + * exist or has been deleted by a layer. + * + * Fast path: when a collection has no layers, the canonical base item is + * returned directly with no allocation. This is the core perf win over the + * previous implementation, which rebuilt a whole-collection snapshot on + * every write regardless of whether layers were involved. + */ +export function resolveItem(ctx: EngineContext, collectionName: string, key: string | number): any | undefined { + const collection = ctx.collections.get(collectionName) + if (!collection) { + return undefined + } + // Reconcile string/number key forms: items are stored under the canonical + // `getKey` value (often numeric), but callers may look them up with the + // string form (e.g. object-key iteration). The previous Record-based store + // coerced every key to a string; mirror that leniency here. + key = resolveStoredKey(collection, key) + if (collection.layers.length === 0) { + return collection.base.get(key) + } + + let result = collection.base.get(key) + // Apply layer states (insert / modify) in order; each merges on top. + for (const layer of collection.layers) { + if (!layer.skip && Object.prototype.hasOwnProperty.call(layer.state, key)) { + result = { + ...result, + ...layer.state[key], + $layer: layer, + } + } + } + // Layer deletes win over any state, matching the previous behaviour. + for (const layer of collection.layers) { + if (!layer.skip && layer.deletedItems.has(key)) { + return undefined + } + } + return result +} + +/** + * Compute the set of visible keys for a collection: base keys plus + * layer-inserted keys, minus layer-deleted keys. + */ +export function getVisibleKeys(ctx: EngineContext, collectionName: string): Array { + const collection = ctx.collections.get(collectionName) + if (!collection) { + return [] + } + if (collection.layers.length === 0) { + return Array.from(collection.base.keys()) + } + + const keys = new Set(collection.base.keys()) + for (const layer of collection.layers) { + if (layer.skip) { + continue + } + for (const key of Object.keys(layer.state)) { + keys.add(coerceKey(key, collection)) + } + } + for (const layer of collection.layers) { + if (layer.skip) { + continue + } + for (const key of layer.deletedItems) { + keys.delete(key) + } + } + return Array.from(keys) +} + +/** + * Resolve a lookup key to the form actually stored in `base`. Items are keyed + * by the canonical `getKey` value (often numeric), but callers and object-key + * iteration may pass the string counterpart. Falls back to the numeric/string + * twin when the exact key is absent, mirroring the previous Record-based store + * (which coerced every key to a string). + */ +export function resolveStoredKey(collection: EngineCollectionState, key: string | number): string | number { + if (collection.base.has(key)) { + return key + } + if (typeof key === 'string') { + const asNumber = Number(key) + if (key !== '' && !Number.isNaN(asNumber) && collection.base.has(asNumber)) { + return asNumber + } + } + else { + const asString = String(key) + if (collection.base.has(asString)) { + return asString + } + } + return key +} + +/** + * Layer state objects are keyed by string (object keys). When the base item + * keys are numeric, reconcile the type so `Set` membership works as expected. + */ +function coerceKey(key: string, collection: EngineCollectionState): string | number { + return resolveStoredKey(collection, key) +} + +/** + * Get (creating if needed) the value->keys map for a collection index. + */ +export function getCollectionIndex( + collection: EngineCollectionState, + indexKey: string, +): Map> { + let index = collection.indexes.get(indexKey) + if (!index) { + index = new Map() + collection.indexes.set(indexKey, index) + } + return index +} + +/** + * Read an index bucket's key set, or `undefined` if the bucket is empty / + * the index does not exist. + */ +export function getIndexBucket( + ctx: EngineContext, + collectionName: string, + indexKey: string, + indexValue: string, +): ReadonlySet | undefined { + const collection = ctx.collections.get(collectionName) + if (!collection) { + return undefined + } + return collection.indexes.get(indexKey)?.get(indexValue) +} + +/** + * Maintain a collection's indexes for a single item edit and record the + * affected index buckets so their observers fire on the next flush. + * + * Mirrors the previous `updateItemIndexes`: only indexes whose fields + * actually changed are touched, and only fully-defined values are indexed. + */ +export function updateItemIndexes( + ctx: EngineContext, + collection: ResolvedCollection, + key: string | number, + previousData: any, + newData: any = {}, +): void { + const collectionState = ctx.ensureCollection(collection.name) + for (const [indexKey, indexFields] of collection.indexes) { + // Skip indexes whose fields are unchanged by this edit. + if (!indexFields.some(f => f in newData && (!previousData || newData[f] !== previousData[f]))) { + continue + } + const index = getCollectionIndex(collectionState, indexKey) + + if (previousData) { + const values = indexFields.map(f => previousData?.[f]) + if (values.every(v => v != null)) { + const previousValue = values.join(':') + const existingKeys = index.get(previousValue) + if (existingKeys) { + existingKeys.delete(key) + ctx.observers.touchIndex(collection.name, indexKey, previousValue) + } + } + } + + // Use `f in newData` (not `??`) so an explicit `null`/`undefined` in the + // patch — i.e. clearing an indexed field — drops the item from the index + // instead of falling back to its previous value. Fields absent from the + // patch still inherit the previous value (preserves partial updates). + const newValues = indexFields.map(f => f in newData ? newData[f] : previousData?.[f]) + if (newValues.every(v => v != null)) { + const newValue = newValues.join(':') + let existingKeys = index.get(newValue) + if (!existingKeys) { + existingKeys = new Set() + index.set(newValue, existingKeys) + } + existingKeys.add(key) + ctx.observers.touchIndex(collection.name, indexKey, newValue) + } + } +} + +/** + * Remove a key from all of a collection's indexes (used on delete). Records + * the affected buckets for observer notification. + */ +export function removeKeyFromIndexes( + ctx: EngineContext, + collection: ResolvedCollection, + key: string | number, + item: any, +): void { + if (!item) { + return + } + const collectionState = ctx.collections.get(collection.name) + if (!collectionState) { + return + } + for (const [indexKey, indexFields] of collection.indexes) { + const index = collectionState.indexes.get(indexKey) + if (!index) { + continue + } + const previousValue = indexFields.map(f => item[f]).join(':') + const existingKeys = index.get(previousValue) + if (existingKeys && existingKeys.delete(key)) { + ctx.observers.touchIndex(collection.name, indexKey, previousValue) + } + } +} + +/** A layer participates in resolution unless it is explicitly skipped. */ +export function isLayerActive(layer: CacheLayer): boolean { + return !layer.skip +} diff --git a/packages/core/src/store-engine/serialize.ts b/packages/core/src/store-engine/serialize.ts new file mode 100644 index 00000000..c4b6dc75 --- /dev/null +++ b/packages/core/src/store-engine/serialize.ts @@ -0,0 +1,149 @@ +import type { CustomCacheState } from '@rstore/shared' +import type { EngineContext } from './types.js' +import { updateItemIndexes } from './resolve.js' + +/** + * Serialize the cache to a plain JSON-safe snapshot for SSR transfer. + * + * Only the canonical `base` items are emitted (never layer-merged values), so + * optimistic state does not leak into the hydrated payload. Empty collection + * entries are preserved so a cleared collection still round-trips. + */ +export function getState(ctx: EngineContext): CustomCacheState { + const result: CustomCacheState = { + collections: {}, + markers: { ...ctx.markers }, + modules: {}, + queryMeta: ctx.queryMeta, + } + + for (const [collectionName, collectionState] of ctx.collections) { + const target: Record = result.collections[collectionName] = {} + for (const [key, item] of collectionState.base) { + if (item) { + target[key] = item + } + } + } + + for (const [moduleKey, mod] of ctx.modules) { + result.modules[moduleKey] = mod.value + } + + return result +} + +/** + * Replace a module holder's contents in place so any reactive wrapper the + * bridge created over it keeps observing the same object reference. + */ +function replaceModuleContents(target: any, source: any): void { + if (!(target && typeof target === 'object' && source && typeof source === 'object')) { + return + } + // Arrays must be truncated in place: deleting indices leaves stale `length` + // and empty holes (`[9, <2 empty>]`), corrupting the array. Reset length and + // re-fill from the source when it is also an array (an empty/object source — + // e.g. `clear()` passing `{}` — just empties the array). + if (Array.isArray(target)) { + target.length = 0 + if (Array.isArray(source)) { + target.push(...source) + } + return + } + for (const key of Object.keys(target)) { + delete target[key] + } + Object.assign(target, source) +} + +/** + * Hydrate the cache from a snapshot. Rebuilds base items and relation indexes + * per collection; module holders are mutated in place to preserve reactive + * bridge wrappers. Fires the reset callback so the bridge can drop wrapped + * items and re-track signals. + */ +export function setStateNow(ctx: EngineContext, value: CustomCacheState): void { + ctx.markers = value.markers || {} + + // Reset base + indexes for every known collection (layers are preserved). + for (const [collectionName, collectionState] of ctx.collections) { + collectionState.base.clear() + collectionState.indexes.clear() + ctx.observers.touchList(collectionName) + } + + for (const collectionName in value.collections) { + const collection = ctx.callbacks.getCollection(collectionName) + if (!collection) { + continue + } + const collectionState = ctx.ensureCollection(collectionName) + const incoming = value.collections[collectionName as keyof typeof value.collections] as Record + for (const rawKey in incoming) { + const item = incoming[rawKey] + if (item) { + // Object keys are always strings; recover the canonical (possibly + // numeric) key from the item so it matches direct-write storage. + const derived = collection.getKey(item) + const key = derived != null ? derived : rawKey + collectionState.base.set(key, item) + updateItemIndexes(ctx, collection, key, undefined, item) + } + } + ctx.observers.touchList(collectionName) + } + + // Modules: keep existing holders so reactive wrappers stay valid; mutate in + // place. Holders absent from the incoming state are emptied. + for (const [moduleKey, mod] of ctx.modules) { + if (!(moduleKey in (value.modules ?? {}))) { + replaceModuleContents(mod.value, {}) + } + } + for (const moduleKey in value.modules) { + const incoming = value.modules[moduleKey] + const existing = ctx.modules.get(moduleKey) + if (existing) { + replaceModuleContents(existing.value, incoming) + } + else { + ctx.modules.set(moduleKey, { value: incoming }) + } + } + + ctx.callbacks.onReset?.() + + ctx.queryMeta = value.queryMeta || {} +} + +/** + * Reset the cache to empty: clears markers, every collection's base + indexes, + * field timestamps and tombstones, and empties module holders in place. Layers + * are left untouched (matching the previous behaviour). + */ +export function clearNow(ctx: EngineContext): void { + ctx.markers = {} + + for (const [collectionName, collectionState] of ctx.collections) { + collectionState.base.clear() + collectionState.indexes.clear() + ctx.observers.touchList(collectionName) + } + + for (const [, mod] of ctx.modules) { + replaceModuleContents(mod.value, {}) + } + + ctx.fieldTimestamps.clear() + + // Drop every tombstone (snapshot the entries first to avoid mutating during + // iteration). + const tombs = Array.from(ctx.tombstones.entries(), ([, t]) => t) + for (const t of tombs) { + ctx.tombstones.clear(t.collection, t.key) + } + + ctx.callbacks.onReset?.() +} diff --git a/packages/core/src/store-engine/types.ts b/packages/core/src/store-engine/types.ts new file mode 100644 index 00000000..54158386 --- /dev/null +++ b/packages/core/src/store-engine/types.ts @@ -0,0 +1,262 @@ +import type { + CacheLayer, + Collection, + CollectionDefaults, + CollectionRelation, + CustomCacheState, + CustomHookMeta, + FieldConflict, + FieldTimestamps, + FieldTimestampValue, + ResolvedCollection, + ResolvedCollectionItemBase, + StoreSchema, +} from '@rstore/shared' +import type { TombstoneStore } from '../tombstone.js' + +/** Unsubscribe handle returned by the engine's `observe*` methods. */ +export type Unsubscribe = () => void + +/** A plain callback fired when an observed scope changes. */ +export type ObserverCallback = () => void + +/** Fine-grained observer registry exposed by the engine. */ +export interface ObserverRegistry { + /** Observe changes to a single item by key. */ + observeItem: (collection: string, key: string | number, cb: ObserverCallback) => Unsubscribe + /** Observe changes to the visible key set of a collection. */ + observeList: (collection: string, cb: ObserverCallback) => Unsubscribe + /** Observe changes to a single index bucket. */ + observeIndex: (collection: string, indexKey: string, indexValue: string, cb: ObserverCallback) => Unsubscribe + /** Record an item value change for the next flush. */ + touchItem: (collection: string, key: string | number) => void + /** Record a visible-key-set (or marker) change for the next flush. */ + touchList: (collection: string) => void + /** Record an index bucket change for the next flush. */ + touchIndex: (collection: string, indexKey: string, indexValue: string) => void + /** Notify all observers matching the accumulated change set, then reset. */ + flush: () => void +} + +/** Payload passed to {@link EngineCallbacks.onAfterWrite}. */ +export interface EngineAfterWritePayload { + collection: ResolvedCollection + key?: string | number + result?: any + marker?: string + operation: 'write' | 'delete' +} + +/** Payload passed to {@link EngineCallbacks.onConflict}. */ +export interface EngineConflictPayload { + collection: ResolvedCollection + key: string | number + conflicts: FieldConflict[] +} + +/** Callbacks injected by the embedding framework. */ +export interface EngineCallbacks { + /** Resolve a collection by name. */ + getCollection: (name: string) => ResolvedCollection | undefined + /** Resolve the collection of a related child item among candidate names. */ + resolveChildCollection: (item: any, possibleNames: string[]) => ResolvedCollection | null + /** Fired after a write or delete is applied (drives `afterCacheWrite`). */ + onAfterWrite?: (payload: EngineAfterWritePayload) => void + /** Fired when a CRDT field merge yields conflicts. */ + onConflict?: (payload: EngineConflictPayload) => void + /** Fired after a layer is added. */ + onLayerAdd?: (layer: CacheLayer) => void + /** Fired after a layer is removed. */ + onLayerRemove?: (layer: CacheLayer) => void + /** Fired after the cache is reset (clear / setState). */ + onReset?: () => void +} + +/** Tombstone auto-GC configuration. `false` disables the background sweep. */ +export type TombstoneGcOptions = false | { + /** Sweep interval in ms. Defaults to 60_000. */ + intervalMs?: number + /** Drop tombstones older than this many ms. Defaults to 86_400_000 (24h). */ + ttlMs?: number +} + +/** Options for {@link createStoreEngine}. */ +export interface EngineOptions { + callbacks: EngineCallbacks + /** + * Maximum number of writes applied synchronously per 10ms window. `0` + * disables staggering (writes apply immediately). + */ + cacheStaggering?: number + /** Tombstone auto-GC settings. */ + tombstoneGc?: TombstoneGcOptions + /** Whether the engine belongs to a server-side store (disables auto-GC). */ + isServer?: boolean +} + +/** + * Per-collection plain-JS storage. No reactivity — the bridge maps the + * engine's observers onto its own signals. + */ +export interface EngineCollectionState { + /** Canonical raw items keyed by primary key. */ + base: Map + /** indexKey (`field1:field2`) -> joined value -> set of item keys. */ + indexes: Map>> + /** Ordered optimistic layers affecting this collection. */ + layers: CacheLayer[] +} + +/** + * A queued cache operation. Operations are applied in FIFO order when the + * cache is resumed or when the staggering budget allows. + */ +export type QueuedOperation + = | { type: 'writeItem', params: WriteItemParams } + | { type: 'writeItems', params: WriteItemsParams, index: number } + | { type: 'deleteItem', params: DeleteItemParams } + | { type: 'addLayer', layer: CacheLayer } + | { type: 'removeLayer', layerId: string } + | { type: 'setState', state: CustomCacheState } + | { type: 'clear' } + +export interface WriteItemParams { + collection: ResolvedCollection + key: string | number + item: ResolvedCollectionItemBase + marker?: string + fromWriteItems?: boolean + meta?: CustomHookMeta + fieldTimestamps?: FieldTimestamps +} + +export interface WriteItemsParams { + collection: ResolvedCollection + items: Array<{ key: string | number, value: ResolvedCollectionItemBase }> + marker?: string + meta?: CustomHookMeta +} + +export interface DeleteItemParams { + collection: ResolvedCollection + key: string | number + deletedAt?: FieldTimestampValue +} + +export interface WriteItemForRelationParams { + parentCollection: ResolvedCollection + relationKey: string | number | symbol + relation: CollectionRelation + childItem: any + meta?: CustomHookMeta +} + +/** Parameters for reading a list of candidate keys from the engine. */ +export interface ResolveKeysParams { + collection: ResolvedCollection + marker?: string + keys?: Array + indexKey?: string + indexValue?: string +} + +/** Internal mutable engine context shared across the engine's focused modules. */ +export interface EngineContext { + collections: Map + markers: Record + modules: Map + fieldTimestamps: Map> + tombstones: TombstoneStore + queryMeta: Record + layerIdToCollection: Map + paused: boolean + queue: QueuedOperation[] + isFlushingQueue: boolean + callbacks: EngineCallbacks + observers: ObserverRegistry + /** Staggering controller. */ + staggering: Staggering + /** Lazily get or create a collection's storage. */ + ensureCollection: (name: string) => EngineCollectionState +} + +/** Staggering controller for throttling writes per 10ms window. */ +export interface Staggering { + canProcess: () => boolean + consume: () => void + /** Re-run the flush callback after the budget resets. */ + setFlush: (flush: () => void) => void + /** Whether staggering is active (budget > 0 configured). */ + enabled: boolean + /** Cancel any pending budget-reset timer (called on engine dispose). */ + dispose: () => void +} + +/** + * Public, framework-agnostic storage engine. Reads return raw plain objects + * (with `$layer` attached for layered items), never framework proxies. + */ +export interface StoreEngine< + // Phantom type params: kept so the Vue bridge can carry schema typing on the + // engine handle, even though the engine's runtime surface is schema-agnostic. + _TSchema extends StoreSchema = StoreSchema, + _TCollectionDefaults extends CollectionDefaults = CollectionDefaults, +> { + /** Read the resolved (layer-merged) raw item for a key, or undefined. */ + readItemRaw: (params: { collection: ResolvedCollection, key: string | number }) => any | undefined + /** Resolve the candidate key list for a list read (honors layers/index). */ + resolveKeys: (params: ResolveKeysParams) => Array + /** Read an index bucket's key set (after layer reconciliation). */ + getIndexBucket: (collection: string, indexKey: string, indexValue: string) => ReadonlySet | undefined + /** Whether a marker has been set. */ + hasMarker: (marker: string) => boolean + writeItem: (params: WriteItemParams) => void + writeItems: (params: WriteItemsParams) => void + deleteItem: (params: DeleteItemParams) => void + writeItemForRelation: (params: WriteItemForRelationParams) => void + readFieldTimestamps: (params: { collectionName: string, key: string | number }) => FieldTimestamps | undefined + writeFieldTimestamps: (params: { collectionName: string, key: string | number, timestamps: FieldTimestamps }) => void + getModuleState: (name: string, key: string, initState: any) => any + getState: () => CustomCacheState + setState: (state: CustomCacheState) => void + clear: () => void + clearCollection: (params: { collection: ResolvedCollection }) => void + /** Immediately delete a key without queuing (used by GC). Returns true if removed. */ + garbageCollectKey: (collection: ResolvedCollection, key: string | number) => boolean + /** Iterate the base keys of a collection (used by GC). */ + forEachKey: (collection: string, cb: (key: string | number) => void) => void + addLayer: (layer: CacheLayer) => void + getLayer: (layerId: string) => CacheLayer | undefined + removeLayer: (layerId: string) => void + tombstones: TombstoneStore + gcTombstones: (olderThan: FieldTimestampValue) => Array<{ collection: string, key: string | number }> + pause: () => void + resume: () => void + dispose: () => void + observeItem: ObserverRegistry['observeItem'] + observeList: ObserverRegistry['observeList'] + observeIndex: ObserverRegistry['observeIndex'] + /** + * Direct access to the layer index for devtools/debug. + * @internal + */ + _getLayers: () => Map + /** + * Live access to the query meta record (persisted via SSR). + * @internal + */ + _getQueryMeta: () => Record + _ctx: EngineContext +} + +// Re-export for convenience so consumers import engine types from one place. +export type { + CacheLayer, + Collection, + CollectionDefaults, + FieldConflict, + FieldTimestamps, + FieldTimestampValue, + ResolvedCollection, + StoreSchema, +} diff --git a/packages/core/src/store-engine/write.ts b/packages/core/src/store-engine/write.ts new file mode 100644 index 00000000..36c7ca53 --- /dev/null +++ b/packages/core/src/store-engine/write.ts @@ -0,0 +1,217 @@ +import type { FieldTimestamps } from '@rstore/shared' +import type { DeleteItemParams, EngineContext, WriteItemForRelationParams, WriteItemParams } from './types.js' +import { pickNonSpecialProps } from '@rstore/shared' +import { mergeItemFields } from '../crdt/index.js' +import { isKeyDefined } from '../key.js' +import { shouldResurrect } from '../tombstone.js' +import { removeKeyFromIndexes, updateItemIndexes } from './resolve.js' + +/** + * Get (creating if needed) the per-collection field-timestamp map used by the + * CRDT field-level merge. + */ +function ensureCollectionTimestamps(ctx: EngineContext, collectionName: string): Map { + let map = ctx.fieldTimestamps.get(collectionName) + if (!map) { + map = new Map() + ctx.fieldTimestamps.set(collectionName, map) + } + return map +} + +/** Read a key's stored field timestamps, or `undefined`. */ +export function getFieldTimestamps(ctx: EngineContext, collectionName: string, key: string | number): FieldTimestamps | undefined { + return ctx.fieldTimestamps.get(collectionName)?.get(key) +} + +/** Store a key's field timestamps. */ +export function setFieldTimestamps(ctx: EngineContext, collectionName: string, key: string | number, timestamps: FieldTimestamps): void { + ensureCollectionTimestamps(ctx, collectionName).set(key, timestamps) +} + +/** + * Resolve and write a related child item, recursing through {@link writeItemNow}. + * The child collection is resolved from the relation's candidate target names. + */ +export function writeItemForRelationNow(ctx: EngineContext, params: WriteItemForRelationParams): void { + const { parentCollection, relationKey, relation, childItem, meta } = params + const possibleCollections = Object.keys(relation.to) + const nestedItemCollection = ctx.callbacks.resolveChildCollection(childItem, possibleCollections) + if (!nestedItemCollection) { + throw new Error(`Could not determine type for relation ${parentCollection.name}.${String(relationKey)}`) + } + const nestedKey = nestedItemCollection.getKey(childItem) + if (!isKeyDefined(nestedKey)) { + throw new Error(`Could not determine key for relation ${parentCollection.name}.${String(relationKey)}`) + } + + writeItemNow(ctx, { + collection: nestedItemCollection, + key: nestedKey, + item: childItem, + meta, + }) +} + +/** + * Apply a single item write to the canonical base store, maintaining indexes, + * tombstones, CRDT timestamps and observer notifications. + * + * Observer granularity is the core perf win: a field update to an existing + * item touches only that item's observer, never the collection list — so a + * 1000-item live list does not re-run when one of its members changes a field. + * Inserts (new keys) and markers touch the list because they change the + * visible-key set / gating of list reads. + */ +export function writeItemNow(ctx: EngineContext, params: WriteItemParams): void { + const { collection, key, item, marker, fromWriteItems, meta } = params + const collectionState = ctx.ensureCollection(collection.name) + + // Tombstone guard: a write with per-field timestamps is dropped when the + // tombstone is newer; writes without timestamps always resurrect (explicit + // user intent). A surviving tombstone is cleared so the item comes back. + const tomb = ctx.tombstones.get(collection.name, key) + if (tomb) { + if (params.fieldTimestamps && !shouldResurrect(tomb, params.fieldTimestamps)) { + return + } + ctx.tombstones.clear(collection.name, key) + } + + const isNew = !collectionState.base.has(key) + const isFrozen = Object.isFrozen(item) + if (isFrozen) { + // Frozen items are stored verbatim (no relation extraction / index work). + collectionState.base.set(key, item) + } + else { + const rawData = pickNonSpecialProps(item, true) + + // Split out relation fields and write nested items recursively. + const data: Record = {} + for (const field in rawData) { + if (field in collection.relations) { + const relation = collection.relations[field] + const rawItem = rawData[field] + if (!rawItem || !relation) { + continue + } + if (relation.many && !Array.isArray(rawItem)) { + throw new Error(`Expected array for relation ${collection.name}.${field}`) + } + else if (!relation.many && Array.isArray(rawItem)) { + throw new Error(`Expected object for relation ${collection.name}.${field}`) + } + if (Array.isArray(rawItem)) { + for (const nestedItem of rawItem as any[]) { + writeItemForRelationNow(ctx, { + parentCollection: collection, + relationKey: field, + relation, + childItem: nestedItem, + meta, + }) + } + } + else { + writeItemForRelationNow(ctx, { + parentCollection: collection, + relationKey: field, + relation, + childItem: rawItem, + meta, + }) + } + } + else { + data[field] = rawData[field] + } + } + + const existing = collectionState.base.get(key) + updateItemIndexes(ctx, collection, key, existing, data) + + if (!existing) { + collectionState.base.set(key, data) + if (params.fieldTimestamps) { + setFieldTimestamps(ctx, collection.name, key, { ...params.fieldTimestamps }) + } + } + else if (params.fieldTimestamps) { + // CRDT field-level LWW merge against the existing item. + const localTimestamps = getFieldTimestamps(ctx, collection.name, key) ?? {} + const { merged, mergedTimestamps, conflicts } = mergeItemFields( + existing, + data, + localTimestamps, + params.fieldTimestamps, + ) + setFieldTimestamps(ctx, collection.name, key, mergedTimestamps) + collectionState.base.set(key, merged) + if (conflicts.length > 0) { + ctx.callbacks.onConflict?.({ collection, key, conflicts }) + } + } + else { + collectionState.base.set(key, { ...existing, ...data }) + } + } + + // Notify: the item always; the list only when the visible-key set changed. + ctx.observers.touchItem(collection.name, key) + if (isNew) { + ctx.observers.touchList(collection.name) + } + + if (marker) { + ctx.markers[marker] = true + // A marker flip un-gates list reads short-circuited on `!markers[marker]`. + ctx.observers.touchList(collection.name) + } + + if (meta?.$queryTracking) { + meta.$queryTracking.items[collection.name] ??= new Set() + meta.$queryTracking.items[collection.name]!.add(key) + } + + if (!fromWriteItems) { + ctx.callbacks.onAfterWrite?.({ + collection, + key, + result: [item], + marker, + operation: 'write', + }) + } +} + +/** + * Remove a key from the canonical base store and its indexes, notifying the + * item and list observers. Field timestamps and tombstones are handled by the + * queue's delete path, not here (this mirrors the previous internal + * `deleteItem`). Returns `true` if an item was actually removed. + */ +export function deleteItemFromBase(ctx: EngineContext, params: DeleteItemParams): boolean { + const { collection, key } = params + const collectionState = ctx.collections.get(collection.name) + if (!collectionState) { + return false + } + const item = collectionState.base.get(key) + if (item === undefined) { + return false + } + + removeKeyFromIndexes(ctx, collection, key, item) + collectionState.base.delete(key) + + ctx.observers.touchItem(collection.name, key) + ctx.observers.touchList(collection.name) + + ctx.callbacks.onAfterWrite?.({ + collection, + key, + operation: 'delete', + }) + return true +} diff --git a/packages/core/test/store-engine/clear-collection.spec.ts b/packages/core/test/store-engine/clear-collection.spec.ts new file mode 100644 index 00000000..6bf67f2f --- /dev/null +++ b/packages/core/test/store-engine/clear-collection.spec.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from 'vitest' +import { buildCollection, createTestEngine } from './helpers' + +describe('store-engine: clearCollection', () => { + it('removes every item of the collection', () => { + const collection = buildCollection('User') + const { engine } = createTestEngine([collection]) + for (let i = 1; i <= 5; i++) { + engine.writeItem({ collection, key: i, item: { id: i } }) + } + + engine.clearCollection({ collection }) + + expect(engine.resolveKeys({ collection })).toEqual([]) + }) + + it('notifies the list observer once for the whole clear, not once per item', () => { + // Each deleted key touches the list; batching the loop behind a single + // queue flush dedupes those into one observer dispatch. + const collection = buildCollection('User') + const { engine } = createTestEngine([collection]) + for (let i = 1; i <= 5; i++) { + engine.writeItem({ collection, key: i, item: { id: i } }) + } + + const listCb = vi.fn() + engine.observeList('User', listCb) + + engine.clearCollection({ collection }) + + expect(listCb).toHaveBeenCalledTimes(1) + }) + + it('leaves the queue paused-state unchanged when clearing while paused', () => { + const collection = buildCollection('User') + const { engine } = createTestEngine([collection]) + engine.writeItem({ collection, key: 1, item: { id: 1 } }) + + engine.pause() + engine.clearCollection({ collection }) + // While paused, the deletes stay queued (nothing applied yet). + expect(engine.resolveKeys({ collection })).toEqual([1]) + + engine.resume() + expect(engine.resolveKeys({ collection })).toEqual([]) + }) +}) diff --git a/packages/core/test/store-engine/crdt.spec.ts b/packages/core/test/store-engine/crdt.spec.ts new file mode 100644 index 00000000..b46bfede --- /dev/null +++ b/packages/core/test/store-engine/crdt.spec.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { buildCollection, createTestEngine } from './helpers' + +describe('store-engine: CRDT field merge', () => { + it('keeps the local field when the incoming timestamp is older', () => { + const collection = buildCollection('User') + const { engine } = createTestEngine([collection]) + + engine.writeItem({ collection, key: 1, item: { id: 1, name: 'Local' }, fieldTimestamps: { name: 100 } }) + engine.writeItem({ collection, key: 1, item: { id: 1, name: 'Remote' }, fieldTimestamps: { name: 50 } }) + + expect(engine.readItemRaw({ collection, key: 1 }).name).toBe('Local') + }) + + it('takes the incoming field when its timestamp is newer', () => { + const collection = buildCollection('User') + const { engine } = createTestEngine([collection]) + + engine.writeItem({ collection, key: 1, item: { id: 1, name: 'Local' }, fieldTimestamps: { name: 100 } }) + engine.writeItem({ collection, key: 1, item: { id: 1, name: 'Remote' }, fieldTimestamps: { name: 200 } }) + + expect(engine.readItemRaw({ collection, key: 1 }).name).toBe('Remote') + }) + + it('records a conflict when timestamps tie but values differ', () => { + const collection = buildCollection('User') + const { engine, events } = createTestEngine([collection]) + + engine.writeItem({ collection, key: 1, item: { id: 1, name: 'Local' }, fieldTimestamps: { name: 100 } }) + engine.writeItem({ collection, key: 1, item: { id: 1, name: 'Remote' }, fieldTimestamps: { name: 100 } }) + + expect(events.conflicts).toHaveLength(1) + expect(events.conflicts[0]!.conflicts[0]!.field).toBe('name') + // Local value kept by default on ties. + expect(engine.readItemRaw({ collection, key: 1 }).name).toBe('Local') + }) + + it('exposes stored field timestamps', () => { + const collection = buildCollection('User') + const { engine } = createTestEngine([collection]) + + engine.writeItem({ collection, key: 1, item: { id: 1, name: 'A' }, fieldTimestamps: { name: 100 } }) + + expect(engine.readFieldTimestamps({ collectionName: 'User', key: 1 })).toEqual({ name: 100 }) + }) +}) diff --git a/packages/core/test/store-engine/helpers.ts b/packages/core/test/store-engine/helpers.ts new file mode 100644 index 00000000..5c90f5d0 --- /dev/null +++ b/packages/core/test/store-engine/helpers.ts @@ -0,0 +1,95 @@ +import type { CacheLayer, CollectionRelation, ResolvedCollection } from '@rstore/shared' +import type { EngineAfterWritePayload, EngineConflictPayload, StoreEngine } from '../../src' +import { createStoreEngine } from '../../src' + +/** + * Build a minimal {@link ResolvedCollection} for engine tests. Only the fields + * the engine actually touches are populated. + */ +export function buildCollection(name: string, options: { + getKey?: (item: any) => string | number + relations?: Record + indexes?: Map +} = {}): ResolvedCollection { + return { + '~resolved': true, + 'name': name, + 'getKey': options.getKey ?? ((item: any) => item.id), + 'isInstanceOf': () => true, + 'relations': options.relations ?? {}, + 'normalizedRelations': {}, + 'oppositeRelations': {}, + 'indexes': options.indexes ?? new Map(), + 'computed': {}, + 'fields': {}, + 'formSchema': {} as any, + 'hooks': undefined, + } as ResolvedCollection +} + +/** Captured engine callback events, for assertions. */ +export interface RecordedEvents { + afterWrite: EngineAfterWritePayload[] + conflicts: EngineConflictPayload[] + layerAdd: CacheLayer[] + layerRemove: CacheLayer[] + reset: number +} + +/** + * Create an engine wired to in-memory test collections. `isServer` is set so + * the tombstone GC timer never starts (keeps tests free of dangling timers). + */ +export function createTestEngine( + collections: Array>, + options: { cacheStaggering?: number } = {}, +): { engine: StoreEngine, events: RecordedEvents, byName: Map> } { + const byName = new Map(collections.map(c => [c.name, c])) + const events: RecordedEvents = { + afterWrite: [], + conflicts: [], + layerAdd: [], + layerRemove: [], + reset: 0, + } + + const engine = createStoreEngine({ + isServer: true, + cacheStaggering: options.cacheStaggering ?? 0, + callbacks: { + getCollection: name => byName.get(name), + resolveChildCollection: (_item, names) => { + for (const name of names) { + const collection = byName.get(name) + if (collection) { + return collection + } + } + return null + }, + onAfterWrite: payload => events.afterWrite.push(payload), + onConflict: payload => events.conflicts.push(payload), + onLayerAdd: layer => events.layerAdd.push(layer), + onLayerRemove: layer => events.layerRemove.push(layer), + onReset: () => { events.reset++ }, + }, + }) + + return { engine, events, byName } +} + +/** Build an optimistic cache layer for a collection. */ +export function buildLayer( + id: string, + collectionName: string, + state: Record = {}, + deletedItems: Array = [], +): CacheLayer { + return { + id, + collectionName, + state, + deletedItems: new Set(deletedItems), + optimistic: true, + } +} diff --git a/packages/core/test/store-engine/layers.spec.ts b/packages/core/test/store-engine/layers.spec.ts new file mode 100644 index 00000000..a64703f9 --- /dev/null +++ b/packages/core/test/store-engine/layers.spec.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest' +import { buildCollection, buildLayer, createTestEngine } from './helpers' + +describe('store-engine: layers', () => { + it('overlays a layer on top of the base item with $layer attached', () => { + const collection = buildCollection('User') + const { engine } = createTestEngine([collection]) + engine.writeItem({ collection, key: 1, item: { id: 1, name: 'Alice' } }) + + const layer = buildLayer('l1', 'User', { 1: { name: 'Optimistic' } }) + engine.addLayer(layer) + + const resolved = engine.readItemRaw({ collection, key: 1 }) + expect(resolved.name).toBe('Optimistic') + expect(resolved.id).toBe(1) + expect(resolved.$layer).toBe(layer) + }) + + it('hides an item deleted by a layer', () => { + const collection = buildCollection('User') + const { engine } = createTestEngine([collection]) + engine.writeItem({ collection, key: 1, item: { id: 1, name: 'Alice' } }) + + engine.addLayer(buildLayer('l1', 'User', {}, [1])) + + expect(engine.readItemRaw({ collection, key: 1 })).toBeUndefined() + expect(engine.resolveKeys({ collection })).not.toContain(1) + }) + + it('reverts to the base item when the layer is removed', () => { + const collection = buildCollection('User') + const { engine } = createTestEngine([collection]) + engine.writeItem({ collection, key: 1, item: { id: 1, name: 'Alice' } }) + + const layer = buildLayer('l1', 'User', { 1: { name: 'Optimistic' } }) + engine.addLayer(layer) + engine.removeLayer('l1') + + expect(engine.readItemRaw({ collection, key: 1 })).toEqual({ id: 1, name: 'Alice' }) + }) + + it('exposes a layer-inserted key in the visible key set', () => { + const collection = buildCollection('User') + const { engine } = createTestEngine([collection]) + + engine.addLayer(buildLayer('l1', 'User', { 99: { id: 99, name: 'Ghost' } })) + + const keys = engine.resolveKeys({ collection }).map(String) + expect(keys).toContain('99') + }) + + it('fires layer add and remove callbacks', () => { + const collection = buildCollection('User') + const { engine, events } = createTestEngine([collection]) + + const layer = buildLayer('l1', 'User', { 1: { id: 1 } }) + engine.addLayer(layer) + engine.removeLayer('l1') + + expect(events.layerAdd).toHaveLength(1) + expect(events.layerRemove).toHaveLength(1) + expect(events.layerAdd[0]!.id).toBe('l1') + }) + + it('reconciles relation indexes for a layer-modified field', () => { + const collection = buildCollection('Post', { + indexes: new Map([['authorId', ['authorId']]]), + }) + const { engine } = createTestEngine([collection]) + engine.writeItem({ collection, key: 1, item: { id: 1, authorId: 'a' } }) + + // Layer moves the post to author 'b'. + engine.addLayer(buildLayer('l1', 'Post', { 1: { authorId: 'b' } })) + + expect(engine.resolveKeys({ collection, indexKey: 'authorId', indexValue: 'b' }).map(String)).toContain('1') + }) + + it('getLayer returns the registered layer', () => { + const collection = buildCollection('User') + const { engine } = createTestEngine([collection]) + const layer = buildLayer('l1', 'User', { 1: { id: 1 } }) + engine.addLayer(layer) + + expect(engine.getLayer('l1')).toBe(layer) + }) +}) diff --git a/packages/core/test/store-engine/observers.spec.ts b/packages/core/test/store-engine/observers.spec.ts new file mode 100644 index 00000000..6900c286 --- /dev/null +++ b/packages/core/test/store-engine/observers.spec.ts @@ -0,0 +1,131 @@ +import { describe, expect, it, vi } from 'vitest' +import { buildCollection, createTestEngine } from './helpers' + +describe('store-engine: observers', () => { + it('notifies an item observer on write', () => { + const collection = buildCollection('User') + const { engine } = createTestEngine([collection]) + const cb = vi.fn() + engine.observeItem('User', 1, cb) + + engine.writeItem({ collection, key: 1, item: { id: 1, name: 'A' } }) + + expect(cb).toHaveBeenCalledTimes(1) + }) + + it('a field update does NOT re-run the list observer (perf win)', () => { + const collection = buildCollection('User') + const { engine } = createTestEngine([collection]) + engine.writeItem({ collection, key: 1, item: { id: 1, name: 'A' } }) + + const listCb = vi.fn() + const itemCb = vi.fn() + engine.observeList('User', listCb) + engine.observeItem('User', 1, itemCb) + + // Update an existing item's field. + engine.writeItem({ collection, key: 1, item: { id: 1, name: 'B' } }) + + expect(itemCb).toHaveBeenCalledTimes(1) + expect(listCb).not.toHaveBeenCalled() + }) + + it('an insert re-runs both item and list observers', () => { + const collection = buildCollection('User') + const { engine } = createTestEngine([collection]) + + const listCb = vi.fn() + engine.observeList('User', listCb) + const itemCb = vi.fn() + engine.observeItem('User', 2, itemCb) + + engine.writeItem({ collection, key: 2, item: { id: 2 } }) + + expect(itemCb).toHaveBeenCalledTimes(1) + expect(listCb).toHaveBeenCalledTimes(1) + }) + + it('a delete re-runs both item and list observers', () => { + const collection = buildCollection('User') + const { engine } = createTestEngine([collection]) + engine.writeItem({ collection, key: 1, item: { id: 1 } }) + + const listCb = vi.fn() + engine.observeList('User', listCb) + const itemCb = vi.fn() + engine.observeItem('User', 1, itemCb) + + engine.deleteItem({ collection, key: 1 }) + + expect(itemCb).toHaveBeenCalledTimes(1) + expect(listCb).toHaveBeenCalledTimes(1) + }) + + it('a batch write fires each item observer once and the list once', () => { + const collection = buildCollection('User') + const { engine } = createTestEngine([collection]) + + const listCb = vi.fn() + engine.observeList('User', listCb) + const item1 = vi.fn() + const item2 = vi.fn() + engine.observeItem('User', 1, item1) + engine.observeItem('User', 2, item2) + + engine.writeItems({ + collection, + items: [ + { key: 1, value: { id: 1 } }, + { key: 2, value: { id: 2 } }, + ], + }) + + expect(item1).toHaveBeenCalledTimes(1) + expect(item2).toHaveBeenCalledTimes(1) + expect(listCb).toHaveBeenCalledTimes(1) + }) + + it('notifies an index observer when a relation bucket changes', () => { + const collection = buildCollection('Post', { + indexes: new Map([['authorId', ['authorId']]]), + }) + const { engine } = createTestEngine([collection]) + + const indexCb = vi.fn() + engine.observeIndex('Post', 'authorId', 'a', indexCb) + + engine.writeItem({ collection, key: 1, item: { id: 1, authorId: 'a' } }) + + expect(indexCb).toHaveBeenCalledTimes(1) + }) + + it('stops notifying after unsubscribe', () => { + const collection = buildCollection('User') + const { engine } = createTestEngine([collection]) + const cb = vi.fn() + const unsubscribe = engine.observeItem('User', 1, cb) + + engine.writeItem({ collection, key: 1, item: { id: 1 } }) + unsubscribe() + engine.writeItem({ collection, key: 1, item: { id: 1, name: 'B' } }) + + expect(cb).toHaveBeenCalledTimes(1) + }) + + it('isolates a throwing observer from the others', () => { + const collection = buildCollection('User') + const { engine } = createTestEngine([collection]) + const bad = vi.fn(() => { + throw new Error('boom') + }) + const good = vi.fn() + engine.observeItem('User', 1, bad) + engine.observeItem('User', 1, good) + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + engine.writeItem({ collection, key: 1, item: { id: 1 } }) + + expect(good).toHaveBeenCalledTimes(1) + spy.mockRestore() + }) +}) diff --git a/packages/core/test/store-engine/serialize.spec.ts b/packages/core/test/store-engine/serialize.spec.ts new file mode 100644 index 00000000..0d1b727d --- /dev/null +++ b/packages/core/test/store-engine/serialize.spec.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest' +import { buildCollection, createTestEngine } from './helpers' + +describe('store-engine: serialize', () => { + it('serializes base items, markers and modules', () => { + const collection = buildCollection('User') + const { engine } = createTestEngine([collection]) + engine.writeItem({ collection, key: 1, item: { id: 1, name: 'A' }, marker: 'all' }) + engine.getModuleState('counter', 'default', { count: 5 }) + + const state = engine.getState() + expect(state.collections.User).toEqual({ 1: { id: 1, name: 'A' } }) + expect(state.markers).toEqual({ all: true }) + expect(state.modules['counter:default']).toEqual({ count: 5 }) + }) + + it('keeps an emptied collection entry after clear', () => { + const collection = buildCollection('User') + const { engine, events } = createTestEngine([collection]) + engine.writeItem({ collection, key: 1, item: { id: 1 } }) + + engine.clear() + + expect(engine.getState()).toEqual({ + collections: { User: {} }, + markers: {}, + modules: {}, + queryMeta: {}, + }) + expect(events.reset).toBe(1) + }) + + it('round-trips through setState (base + indexes rebuilt)', () => { + const collection = buildCollection('Post', { + indexes: new Map([['authorId', ['authorId']]]), + }) + const { engine } = createTestEngine([collection]) + + engine.setState({ + collections: { Post: { 1: { id: 1, authorId: 'a' }, 2: { id: 2, authorId: 'a' } } }, + markers: { all: true }, + modules: {}, + queryMeta: {}, + }) + + expect(engine.readItemRaw({ collection, key: 1 })).toEqual({ id: 1, authorId: 'a' }) + expect(engine.hasMarker('all')).toBe(true) + expect(engine.resolveKeys({ collection, indexKey: 'authorId', indexValue: 'a' }).map(String).sort()).toEqual(['1', '2']) + }) + + it('returns a stable module object across calls', () => { + const collection = buildCollection('User') + const { engine } = createTestEngine([collection]) + + const a = engine.getModuleState('m', 'k', { count: 0 }) + const b = engine.getModuleState('m', 'k', { count: 999 }) + + expect(a).toBe(b) + expect(b.count).toBe(0) + }) + + it('empties module contents in place on clear (preserves reference)', () => { + const collection = buildCollection('User') + const { engine } = createTestEngine([collection]) + const mod = engine.getModuleState('m', 'k', { count: 5 }) + + engine.clear() + + expect(mod).toEqual({}) + // Same reference so any reactive wrapper over it stays valid. + expect(engine.getModuleState('m', 'k', { count: 1 })).toBe(mod) + }) + + it('replaces an array module in place on setState without leaving stale slots', () => { + const collection = buildCollection('User') + const { engine } = createTestEngine([collection]) + const mod = engine.getModuleState('list', 'k', [1, 2, 3]) as number[] + + engine.setState({ + collections: {}, + markers: {}, + modules: { 'list:k': [9] }, + queryMeta: {}, + }) + + // Truncated in place (length corrected, no leftover holes), same reference. + expect(mod).toEqual([9]) + expect(mod.length).toBe(1) + expect(engine.getModuleState('list', 'k', [])).toBe(mod) + }) + + it('empties an array module to length 0 on clear', () => { + const collection = buildCollection('User') + const { engine } = createTestEngine([collection]) + const mod = engine.getModuleState('list', 'k', [1, 2, 3]) as number[] + + engine.clear() + + expect(Array.isArray(mod)).toBe(true) + expect(mod.length).toBe(0) + }) +}) diff --git a/packages/core/test/store-engine/staggering.spec.ts b/packages/core/test/store-engine/staggering.spec.ts new file mode 100644 index 00000000..147e7437 --- /dev/null +++ b/packages/core/test/store-engine/staggering.spec.ts @@ -0,0 +1,59 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { buildCollection, createTestEngine } from './helpers' + +describe('store-engine: staggering & pause', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('applies writes stepwise within the per-window budget', () => { + const collection = buildCollection('User') + const { engine } = createTestEngine([collection], { cacheStaggering: 2 }) + + for (let i = 1; i <= 5; i++) { + engine.writeItem({ collection, key: i, item: { id: i } }) + } + + // Only the first 2 are applied synchronously. + expect(engine.resolveKeys({ collection })).toEqual([1, 2]) + + vi.advanceTimersByTime(10) + expect(engine.resolveKeys({ collection })).toEqual([1, 2, 3, 4]) + + vi.advanceTimersByTime(10) + expect(engine.resolveKeys({ collection })).toEqual([1, 2, 3, 4, 5]) + }) + + it('does not apply queued writes while paused, then flushes on resume', () => { + const collection = buildCollection('User') + const { engine } = createTestEngine([collection]) + + engine.pause() + engine.writeItem({ collection, key: 1, item: { id: 1 } }) + engine.writeItem({ collection, key: 2, item: { id: 2 } }) + expect(engine.resolveKeys({ collection })).toEqual([]) + + engine.resume() + expect(engine.resolveKeys({ collection })).toEqual([1, 2]) + }) + + it('clears the budget-reset timer on dispose so queued writes never apply', () => { + const collection = buildCollection('User') + const { engine } = createTestEngine([collection], { cacheStaggering: 2 }) + + for (let i = 1; i <= 5; i++) { + engine.writeItem({ collection, key: i, item: { id: i } }) + } + // Budget exhausted after 2; the rest are queued behind a pending reset timer. + expect(engine.resolveKeys({ collection })).toEqual([1, 2]) + + engine.dispose() + vi.advanceTimersByTime(50) + + // The pending timer was cancelled: no late flush re-drove the queue. + expect(engine.resolveKeys({ collection })).toEqual([1, 2]) + }) +}) diff --git a/packages/core/test/store-engine/tombstones.spec.ts b/packages/core/test/store-engine/tombstones.spec.ts new file mode 100644 index 00000000..6b75dca0 --- /dev/null +++ b/packages/core/test/store-engine/tombstones.spec.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import { buildCollection, createTestEngine } from './helpers' + +describe('store-engine: tombstones', () => { + it('records a tombstone when a delete carries a timestamp', () => { + const collection = buildCollection('User') + const { engine } = createTestEngine([collection]) + engine.writeItem({ collection, key: 1, item: { id: 1 } }) + + engine.deleteItem({ collection, key: 1, deletedAt: 100 }) + + expect(engine.tombstones.get('User', 1)?.deletedAt).toBe(100) + }) + + it('does not record a tombstone for a delete without a timestamp', () => { + const collection = buildCollection('User') + const { engine } = createTestEngine([collection]) + engine.writeItem({ collection, key: 1, item: { id: 1 } }) + + engine.deleteItem({ collection, key: 1 }) + + expect(engine.tombstones.get('User', 1)).toBeUndefined() + }) + + it('drops a timestamped write older than the tombstone', () => { + const collection = buildCollection('User') + const { engine } = createTestEngine([collection]) + engine.writeItem({ collection, key: 1, item: { id: 1 } }) + engine.deleteItem({ collection, key: 1, deletedAt: 100 }) + + engine.writeItem({ collection, key: 1, item: { id: 1, name: 'Stale' }, fieldTimestamps: { name: 50 } }) + + expect(engine.readItemRaw({ collection, key: 1 })).toBeUndefined() + }) + + it('resurrects on a timestamped write newer than the tombstone', () => { + const collection = buildCollection('User') + const { engine } = createTestEngine([collection]) + engine.writeItem({ collection, key: 1, item: { id: 1 } }) + engine.deleteItem({ collection, key: 1, deletedAt: 100 }) + + engine.writeItem({ collection, key: 1, item: { id: 1, name: 'Fresh' }, fieldTimestamps: { name: 200 } }) + + expect(engine.readItemRaw({ collection, key: 1 })).toEqual({ id: 1, name: 'Fresh' }) + expect(engine.tombstones.get('User', 1)).toBeUndefined() + }) + + it('always resurrects on a write without a timestamp', () => { + const collection = buildCollection('User') + const { engine } = createTestEngine([collection]) + engine.writeItem({ collection, key: 1, item: { id: 1 } }) + engine.deleteItem({ collection, key: 1, deletedAt: 100 }) + + engine.writeItem({ collection, key: 1, item: { id: 1, name: 'Forced' } }) + + expect(engine.readItemRaw({ collection, key: 1 })).toEqual({ id: 1, name: 'Forced' }) + }) +}) diff --git a/packages/core/test/store-engine/write.spec.ts b/packages/core/test/store-engine/write.spec.ts new file mode 100644 index 00000000..42632447 --- /dev/null +++ b/packages/core/test/store-engine/write.spec.ts @@ -0,0 +1,178 @@ +import { describe, expect, it } from 'vitest' +import { buildCollection, createTestEngine } from './helpers' + +describe('store-engine: write & read', () => { + it('writes and reads back a raw item', () => { + const collection = buildCollection('User') + const { engine } = createTestEngine([collection]) + + engine.writeItem({ collection, key: 1, item: { id: 1, name: 'Alice' } }) + + expect(engine.readItemRaw({ collection, key: 1 })).toEqual({ id: 1, name: 'Alice' }) + }) + + it('returns undefined for a missing key', () => { + const collection = buildCollection('User') + const { engine } = createTestEngine([collection]) + + expect(engine.readItemRaw({ collection, key: 99 })).toBeUndefined() + }) + + it('merges fields on update of an existing item', () => { + const collection = buildCollection('User') + const { engine } = createTestEngine([collection]) + + engine.writeItem({ collection, key: 1, item: { id: 1, name: 'Alice', age: 30 } }) + engine.writeItem({ collection, key: 1, item: { id: 1, name: 'Alicia' } }) + + expect(engine.readItemRaw({ collection, key: 1 })).toEqual({ id: 1, name: 'Alicia', age: 30 }) + }) + + it('deletes an item', () => { + const collection = buildCollection('User') + const { engine } = createTestEngine([collection]) + + engine.writeItem({ collection, key: 1, item: { id: 1, name: 'Alice' } }) + engine.deleteItem({ collection, key: 1 }) + + expect(engine.readItemRaw({ collection, key: 1 })).toBeUndefined() + }) + + it('stores a frozen item verbatim', () => { + const collection = buildCollection('User') + const { engine } = createTestEngine([collection]) + const frozen = Object.freeze({ id: 1, name: 'Frozen' }) + + engine.writeItem({ collection, key: 1, item: frozen }) + + expect(engine.readItemRaw({ collection, key: 1 })).toBe(frozen) + }) + + it('writes a batch and sets the marker only after the batch', () => { + const collection = buildCollection('User') + const { engine, events } = createTestEngine([collection]) + + engine.writeItems({ + collection, + marker: 'all', + items: [ + { key: 1, value: { id: 1, name: 'A' } }, + { key: 2, value: { id: 2, name: 'B' } }, + ], + }) + + expect(engine.hasMarker('all')).toBe(true) + expect(engine.resolveKeys({ collection })).toEqual([1, 2]) + // One batch-level afterWrite, no per-item ones. + expect(events.afterWrite).toHaveLength(1) + expect(events.afterWrite[0]!.operation).toBe('write') + }) + + it('resolveKeys returns [] for an unset marker', () => { + const collection = buildCollection('User') + const { engine } = createTestEngine([collection]) + + engine.writeItem({ collection, key: 1, item: { id: 1 } }) + + expect(engine.resolveKeys({ collection, marker: 'never' })).toEqual([]) + }) + + it('fires afterWrite on single write and delete', () => { + const collection = buildCollection('User') + const { engine, events } = createTestEngine([collection]) + + engine.writeItem({ collection, key: 1, item: { id: 1 } }) + engine.deleteItem({ collection, key: 1 }) + + expect(events.afterWrite.map(e => e.operation)).toEqual(['write', 'delete']) + }) + + it('writes nested relation items into their own collection', () => { + const userCollection = buildCollection('User', { + relations: { + posts: { many: true, to: { Post: { on: {} } } as any }, + }, + }) + const postCollection = buildCollection('Post') + const { engine } = createTestEngine([userCollection, postCollection]) + + engine.writeItem({ + collection: userCollection, + key: 1, + item: { + id: 1, + name: 'Alice', + posts: [ + { id: 10, title: 'Hello' }, + { id: 11, title: 'World' }, + ], + }, + }) + + // The relation field is not stored on the parent... + expect(engine.readItemRaw({ collection: userCollection, key: 1 })).toEqual({ id: 1, name: 'Alice' }) + // ...but the children land in the Post collection. + expect(engine.readItemRaw({ collection: postCollection, key: 10 })).toEqual({ id: 10, title: 'Hello' }) + expect(engine.readItemRaw({ collection: postCollection, key: 11 })).toEqual({ id: 11, title: 'World' }) + }) +}) + +describe('store-engine: indexes', () => { + it('resolves keys via an index bucket', () => { + const collection = buildCollection('Post', { + indexes: new Map([['authorId', ['authorId']]]), + }) + const { engine } = createTestEngine([collection]) + + engine.writeItem({ collection, key: 1, item: { id: 1, authorId: 'a' } }) + engine.writeItem({ collection, key: 2, item: { id: 2, authorId: 'b' } }) + engine.writeItem({ collection, key: 3, item: { id: 3, authorId: 'a' } }) + + expect(engine.resolveKeys({ collection, indexKey: 'authorId', indexValue: 'a' }).sort()).toEqual([1, 3]) + expect(engine.resolveKeys({ collection, indexKey: 'authorId', indexValue: 'b' })).toEqual([2]) + }) + + it('removes a key from its index bucket on delete', () => { + const collection = buildCollection('Post', { + indexes: new Map([['authorId', ['authorId']]]), + }) + const { engine } = createTestEngine([collection]) + + engine.writeItem({ collection, key: 1, item: { id: 1, authorId: 'a' } }) + engine.deleteItem({ collection, key: 1 }) + + expect(engine.resolveKeys({ collection, indexKey: 'authorId', indexValue: 'a' })).toEqual([]) + }) + + it('removes a key from its index bucket when an indexed field is cleared to null', () => { + // Detaching a relation by setting its FK to null must drop the item from + // its old bucket — not leave it indexed under the stale value. + const collection = buildCollection('Comment', { + indexes: new Map([['postId', ['postId']]]), + }) + const { engine } = createTestEngine([collection]) + + engine.writeItem({ collection, key: 1, item: { id: 1, postId: 'p1' } }) + expect(engine.resolveKeys({ collection, indexKey: 'postId', indexValue: 'p1' })).toEqual([1]) + + engine.writeItem({ collection, key: 1, item: { id: 1, postId: null } }) + + expect(engine.resolveKeys({ collection, indexKey: 'postId', indexValue: 'p1' })).toEqual([]) + }) + + it('keeps untouched index fields when a partial update changes a sibling field', () => { + // Regression guard for the `f in newData` fix: a multi-field index must + // retain the previous value of a field absent from the patch. + const collection = buildCollection('Event', { + indexes: new Map([['venue', ['city', 'room']]]), + }) + const { engine } = createTestEngine([collection]) + + engine.writeItem({ collection, key: 1, item: { id: 1, city: 'paris', room: 'A' } }) + // Patch only `room`; `city` is absent and must be preserved in the index. + engine.writeItem({ collection, key: 1, item: { id: 1, room: 'B' } }) + + expect(engine.resolveKeys({ collection, indexKey: 'venue', indexValue: 'paris:A' })).toEqual([]) + expect(engine.resolveKeys({ collection, indexKey: 'venue', indexValue: 'paris:B' })).toEqual([1]) + }) +}) diff --git a/packages/nuxt/src/runtime/devtools/cache.ts b/packages/nuxt/src/runtime/devtools/cache.ts index 1fb680c3..5871b3ed 100644 --- a/packages/nuxt/src/runtime/devtools/cache.ts +++ b/packages/nuxt/src/runtime/devtools/cache.ts @@ -1,5 +1,4 @@ import { createEventHook } from '@vueuse/core' -import { watch } from 'vue' /** Register cache update events consumed by Nuxt Devtools. */ export function installCacheDevtoolsHooks(nuxtApp: any, hook: any) { @@ -19,11 +18,4 @@ export function installCacheDevtoolsHooks(nuxtApp: any, hook: any) { hook('cacheLayerRemove', () => { cacheUpdated.trigger() }) - setTimeout(() => { - watch(() => (nuxtApp.$rstore as any).$cache._private.layers.value, () => { - cacheUpdated.trigger() - }, { - deep: true, - }) - }) } diff --git a/packages/playground/app/utils/devtools.ts b/packages/playground/app/utils/devtools.ts index be78cde3..09f2fa83 100644 --- a/packages/playground/app/utils/devtools.ts +++ b/packages/playground/app/utils/devtools.ts @@ -1,5 +1,4 @@ export function useCache() { const store = useStore() - // @ts-expect-error private fields - return computed(() => store.$cache._private.state.value) + return computed(() => store.$cache.getState()) } diff --git a/packages/shared/src/types/cache.ts b/packages/shared/src/types/cache.ts index 73293ddb..f517bcff 100644 --- a/packages/shared/src/types/cache.ts +++ b/packages/shared/src/types/cache.ts @@ -45,7 +45,19 @@ If there wasn't a marker, the cache would return a list with the single user tha */ -export interface CustomCacheState {} +/** + * Serializable snapshot of the store engine's state (used for SSR transfer). + * + * These are the canonical fields the engine reads and writes. The interface + * stays open for declaration merging so a host framework can attach extra + * state if needed. + */ +export interface CustomCacheState { + markers: Record + collections: Record> + modules: Record + queryMeta: Record +} export interface WriteItem< TCollection extends Collection = Collection, diff --git a/packages/vue/benchmark/cache.bench.ts b/packages/vue/benchmark/cache.bench.ts new file mode 100644 index 00000000..5598a599 --- /dev/null +++ b/packages/vue/benchmark/cache.bench.ts @@ -0,0 +1,280 @@ +import type { Cache, CacheLayer, ResolvedCollection } from '@rstore/shared' +import { normalizeCollectionRelations, resolveCollectionOppositeRelations, resolveCollections } from '@rstore/core' +import { bench, describe } from 'vitest' +import { effectScope, watchEffect } from 'vue' +import { createCache as createEngineCache } from '../src/cache' +import { createCache as createLegacyCache } from './legacy-cache' + +/** + * Benchmark: the previous Vue-reactivity cache (a per-collection version marker + * bumped on *every* write, with list reads rebuilding a whole-collection + * snapshot) vs the new framework-agnostic engine + fine-grained signal bridge. + * + * Two kinds of win are measured: + * - Reactivity granularity: a field write / unrelated-relation write re-runs + * *no* list query under the engine, vs every query under the legacy marker. + * - Read cost: even when reactivity must fire (insert / delete genuinely + * change the visible set), the engine resolves items incrementally instead + * of rebuilding the entire collection snapshot per read. + */ + +type CacheFactory = (opts: { getStore: () => any, isServer?: boolean, tombstoneGc?: false }) => Cache + +interface Counts { list: number, item: number, relation: number } +interface Scenario { counts: Counts, op: (i: number) => void, teardown: () => void } + +const ITEMS = 1000 +const WATCHERS = 20 + +const TODO_SCHEMA = [{ name: 'Todo' }] +const BLOG_SCHEMA = [ + { name: 'Post', relations: { comments: { many: true, to: { Comment: { on: { postId: 'id' } } } } } }, + { name: 'Comment' }, +] + +function newCounts(): Counts { + return { list: 0, item: 0, relation: 0 } +} + +/** + * Wire a cache to a minimal store stub. Both implementations only reach the + * store for collection lookup, child-collection resolution and (no-op) hooks, + * so the stub isolates the storage + reactivity paths. + */ +function makeCtx(createCache: CacheFactory, schema: any[]) { + const collections = resolveCollections(schema as any) as unknown as ResolvedCollection[] + normalizeCollectionRelations(collections as any) + resolveCollectionOppositeRelations(collections as any) + const store: any = { + $collections: collections, + $getCollection: (_item: any, names: string[]) => collections.find(c => names.includes(c.name)), + $hooks: { callHookSync() {} }, + } + // `tombstoneGc: false` avoids leaving a dangling GC interval after the bench. + const cache = createCache({ getStore: () => store, isServer: false, tombstoneGc: false }) + store.$cache = cache + return { cache, collections } +} + +/** Seed `n` rows into a collection (optionally gated behind a marker). */ +function seed(cache: Cache, collection: ResolvedCollection, n: number, marker?: string) { + for (let i = 1; i <= n; i++) { + cache.writeItem({ collection, key: i, item: { id: i, label: `i${i}`, n: 0 }, marker }) + } +} + +/** Mount `count` sync effects that each read the full visible list length. */ +function mountListWatchers(cache: Cache, collection: ResolvedCollection, count: number, counts: Counts) { + for (let w = 0; w < count; w++) { + watchEffect(() => { + void cache.readItems({ collection, marker: 'all' }).length + counts.list++ + }, { flush: 'sync' }) + } +} + +// --- Scenario builders ------------------------------------------------------- + +/** Field write to an existing item while N list queries are live. */ +function fieldWriteUnderLists(create: CacheFactory): Scenario { + const { cache, collections } = makeCtx(create, TODO_SCHEMA) + const collection = collections[0]! + seed(cache, collection, ITEMS, 'all') + const counts = newCounts() + const scope = effectScope() + scope.run(() => mountListWatchers(cache, collection, WATCHERS, counts)) + return { + counts, + op: (i) => { + const key = (i % ITEMS) + 1 + cache.writeItem({ collection, key, item: { id: key, n: i } }) + }, + teardown: () => { scope.stop(); cache.dispose() }, + } +} + +/** Field write while N single-item queries are live (item-level granularity). */ +function fieldWriteUnderItems(create: CacheFactory): Scenario { + const { cache, collections } = makeCtx(create, TODO_SCHEMA) + const collection = collections[0]! + seed(cache, collection, ITEMS) + const counts = newCounts() + const scope = effectScope() + scope.run(() => { + for (let w = 0; w < WATCHERS; w++) { + const key = w + 1 + watchEffect(() => { + const item = cache.readItem({ collection, key }) as any + void (item ? item.n : 0) + counts.item++ + }, { flush: 'sync' }) + } + }) + return { + counts, + op: (i) => { + const key = (i % ITEMS) + 1 + cache.writeItem({ collection, key, item: { id: key, n: i } }) + }, + teardown: () => { scope.stop(); cache.dispose() }, + } +} + +/** Insert a brand-new item while N list queries are live (both must re-run). */ +function insertUnderLists(create: CacheFactory): Scenario { + const { cache, collections } = makeCtx(create, TODO_SCHEMA) + const collection = collections[0]! + seed(cache, collection, ITEMS, 'all') + const counts = newCounts() + const scope = effectScope() + scope.run(() => mountListWatchers(cache, collection, WATCHERS, counts)) + return { + counts, + op: (i) => { + const key = ITEMS + 1 + i + cache.writeItem({ collection, key, item: { id: key, n: i }, marker: 'all' }) + }, + teardown: () => { scope.stop(); cache.dispose() }, + } +} + +/** Delete an item while N list queries are live (both must re-run). */ +function deleteUnderLists(create: CacheFactory): Scenario { + const { cache, collections } = makeCtx(create, TODO_SCHEMA) + const collection = collections[0]! + seed(cache, collection, ITEMS, 'all') + const counts = newCounts() + const scope = effectScope() + scope.run(() => mountListWatchers(cache, collection, WATCHERS, counts)) + return { + counts, + op: (i) => { + cache.deleteItem({ collection, key: i + 1 }) + }, + teardown: () => { scope.stop(); cache.dispose() }, + } +} + +/** Repeated full-list reads with an optimistic layer over 10% of the items. */ +function listReadUnderLayer(create: CacheFactory): Scenario { + const { cache, collections } = makeCtx(create, TODO_SCHEMA) + const collection = collections[0]! + seed(cache, collection, ITEMS, 'all') + const state: Record = {} + for (let i = 1; i <= ITEMS; i += 10) { + state[i] = { n: 999 } + } + const layer: CacheLayer = { id: 'opt', collectionName: collection.name, state, deletedItems: new Set(), optimistic: true } + cache.addLayer(layer) + return { + counts: newCounts(), + op: () => { + void cache.readItems({ collection, marker: 'all' }).length + }, + teardown: () => cache.dispose(), + } +} + +/** + * A live query reads `post.comments` (resolved via the Comment `postId` index); + * the workload writes comments belonging to a *different* post. The engine's + * per-index-bucket signal means the reader does not re-run; the legacy marker + * re-runs it on every Comment write. + */ +function relationUnrelatedWrites(create: CacheFactory): Scenario { + const { cache, collections } = makeCtx(create, BLOG_SCHEMA) + const post = collections.find(c => c.name === 'Post')! + const comment = collections.find(c => c.name === 'Comment')! + cache.writeItem({ collection: post, key: 1, item: { id: 1 } }) + for (let i = 1; i <= 10; i++) { + cache.writeItem({ collection: comment, key: i, item: { id: i, postId: 1 } }) + } + const counts = newCounts() + const scope = effectScope() + scope.run(() => { + watchEffect(() => { + const p = cache.readItem({ collection: post, key: 1 }) as any + void p?.comments?.length + counts.relation++ + }, { flush: 'sync' }) + }) + let nextKey = 10_000 + return { + counts, + op: () => { + const key = nextKey++ + cache.writeItem({ collection: comment, key, item: { id: key, postId: 2 } }) + }, + teardown: () => { scope.stop(); cache.dispose() }, + } +} + +// --- Deterministic report ---------------------------------------------------- + +/** Run `k` ops against a freshly-built scenario, capturing re-runs + wall time. */ +function run(build: () => Scenario, k: number) { + const s = build() + s.counts.list = 0 + s.counts.item = 0 + s.counts.relation = 0 + const start = performance.now() + for (let i = 0; i < k; i++) { + s.op(i) + } + const ms = performance.now() - start + s.teardown() + return { + listReruns: s.counts.list, + itemReruns: s.counts.item, + relationReruns: s.counts.relation, + ms: Number(ms.toFixed(2)), + } +} + +const SCENARIOS: Array<{ name: string, build: (c: CacheFactory) => Scenario, k: number }> = [ + { name: `field write / ${WATCHERS} list watchers`, build: fieldWriteUnderLists, k: 100 }, + { name: `field write / ${WATCHERS} item watchers`, build: fieldWriteUnderItems, k: 100 }, + { name: `insert / ${WATCHERS} list watchers`, build: insertUnderLists, k: 100 }, + { name: `delete / ${WATCHERS} list watchers`, build: deleteUnderLists, k: 100 }, + { name: 'list read / optimistic layer', build: listReadUnderLayer, k: 500 }, + { name: 'relation read / unrelated writes', build: relationUnrelatedWrites, k: 100 }, +] + +const rows: any[] = [] +for (const sc of SCENARIOS) { + const legacy = run(() => sc.build(createLegacyCache as CacheFactory), sc.k) + const engine = run(() => sc.build(createEngineCache as CacheFactory), sc.k) + rows.push( + { scenario: sc.name, ops: sc.k, impl: 'legacy', ...legacy, speedup: '1x' }, + { scenario: '', ops: '', impl: 'engine', ...engine, speedup: `${(legacy.ms / Math.max(engine.ms, 1e-6)).toFixed(0)}x` }, + ) +} +// eslint-disable-next-line no-console +console.log(`\n=== deterministic workload (${ITEMS} items, ${WATCHERS} watchers) — re-runs + wall time ===`) +// eslint-disable-next-line no-console +console.table(rows) + +// --- Throughput (vitest bench) ---------------------------------------------- + +describe(`throughput: field write under ${WATCHERS} live list watchers (${ITEMS} items)`, () => { + const legacy = fieldWriteUnderLists(createLegacyCache as CacheFactory) + const engine = fieldWriteUnderLists(createEngineCache as CacheFactory) + let n = 0 + bench('legacy (collection marker)', () => legacy.op(n++)) + bench('engine (fine-grained)', () => engine.op(n++)) +}) + +describe(`throughput: full-list read with optimistic layer (${ITEMS} items)`, () => { + const legacy = listReadUnderLayer(createLegacyCache as CacheFactory) + const engine = listReadUnderLayer(createEngineCache as CacheFactory) + bench('legacy (snapshot rebuild)', () => legacy.op(0)) + bench('engine (incremental resolve)', () => engine.op(0)) +}) + +describe(`throughput: relation read while writing unrelated items`, () => { + const legacy = relationUnrelatedWrites(createLegacyCache as CacheFactory) + const engine = relationUnrelatedWrites(createEngineCache as CacheFactory) + let n = 0 + bench('legacy (collection marker)', () => legacy.op(n++)) + bench('engine (index bucket)', () => engine.op(n++)) +}) diff --git a/packages/vue/benchmark/legacy-cache.ts b/packages/vue/benchmark/legacy-cache.ts new file mode 100644 index 00000000..0f5258d8 --- /dev/null +++ b/packages/vue/benchmark/legacy-cache.ts @@ -0,0 +1,1031 @@ +import type { TombstoneStore } from '@rstore/core' +import type { Cache, CacheLayer, Collection, CollectionDefaults, CustomCacheState, CustomHookMeta, FieldTimestamps, FieldTimestampValue, ResolvedCollection, ResolvedCollectionItem, ResolvedCollectionItemBase, StoreSchema, WrappedItem } from '@rstore/shared' +import type { Ref } from 'vue' +import type { WrappedItemMetadata } from '../src/item' +import type { VueStore } from '../src/store' +import { createTombstoneStore, gcTombstones, isKeyDefined, mergeItemFields, scheduleTombstoneGc, shouldResurrect } from '@rstore/core' +import { pickNonSpecialProps } from '@rstore/shared' +import { computed, markRaw, ref, shallowRef, toValue } from 'vue' +import { wrapItem } from '../src/item' + +type QueuedOperation + = | { type: 'writeItem', params: Parameters[0] } + | { type: 'writeItems', params: Parameters[0], index: number } + | { type: 'deleteItem', params: Parameters[0] } + | { type: 'addLayer', layer: Parameters[0] } + | { type: 'removeLayer', layerId: Parameters[0] } + | { type: 'setState', state: CustomCacheState } + | { type: 'clear' } + +interface InternalCacheState { + markers: Record + collections: Record>> + /** + * collection name -> relation fields (ex: `authorId` or `field1:field2`) -> relation value (values.join(':')) -> item keys + */ + collectionIndexes: Map>>>> + modules: Record> + queryMeta: Record + pageRefs: Map }> + paused: boolean + queue: QueuedOperation[] + /** + * Per-field timestamps for CRDT field-level LWW merge. + * collectionName -> key -> FieldTimestamps + */ + fieldTimestamps: Map> + /** + * Deletion tombstones keyed by `${collection}:${key}`. + * Used to suppress concurrent updates that arrive after a delete. + */ + tombstones: TombstoneStore +} + +export interface CreateCacheOptions< + TSchema extends StoreSchema, + TCollectionDefaults extends CollectionDefaults, +> { + getStore: () => VueStore + cacheStaggering?: number + /** + * Auto-GC settings for the per-cache tombstone store. Defaults to a + * 60s sweep with a 24h TTL. Pass `false` to disable (e.g. on the + * server, where the cache lives only as long as the request). + */ + tombstoneGc?: false | { + /** Sweep interval in ms. Defaults to 60_000. */ + intervalMs?: number + /** Drop tombstones older than this many ms. Defaults to 86_400_000 (24h). */ + ttlMs?: number + } + /** + * Whether this cache belongs to a server-side store instance. + */ + isServer?: boolean +} + +export function createCache< + TSchema extends StoreSchema, + TCollectionDefaults extends CollectionDefaults, +>({ + getStore, + cacheStaggering: rawCacheStaggering = 0, + tombstoneGc = {}, + isServer = (import.meta as unknown as { server?: boolean }).server === true, +}: CreateCacheOptions): Cache { + const cacheStaggering = Math.max(0, Math.floor(rawCacheStaggering)) + const state: InternalCacheState = { + markers: {}, + collections: {}, + collectionIndexes: new Map(), + modules: {}, + queryMeta: {}, + pageRefs: new Map(), + paused: false, + queue: [], + fieldTimestamps: new Map(), + tombstones: createTombstoneStore(), + } + + // Auto-GC keeps the tombstone store from growing unboundedly in long-lived + // clients. Skipped during SSR / when explicitly disabled. + let stopTombstoneGc: (() => void) | undefined + const canScheduleTombstoneGc = !isServer && typeof setInterval !== 'undefined' + if (tombstoneGc !== false && canScheduleTombstoneGc) { + stopTombstoneGc = scheduleTombstoneGc(state.tombstones, { + intervalMs: tombstoneGc.intervalMs ?? 60_000, + ttlMs: tombstoneGc.ttlMs ?? 24 * 60 * 60 * 1000, + }) + } + + const layers: Record> = {} + const layerIdToCollectionName: Record = {} + + const wrappedItems = new Map>() + const wrappedItemsMetadata = new Map>() + const wrappedItemKeysPerLayer = new Map>() + + const collectionStateCache = new Map>() + const collectionStateCacheReactivityMarker = new Map>() + let isFlushingQueue = false + let staggeringBudget = cacheStaggering + let staggeringResetTimer: ReturnType | undefined + + function ensureCollectionStateCacheReactivityMarker(collectionName: string) { + let marker = collectionStateCacheReactivityMarker.get(collectionName) + if (!marker) { + marker = ref(0) + collectionStateCacheReactivityMarker.set(collectionName, marker) + } + return marker + } + + function invalidateCollectionStateCache(collectionName: string) { + collectionStateCache.delete(collectionName) + ensureCollectionStateCacheReactivityMarker(collectionName).value++ + } + + function getItemWrapKey(collection: ResolvedCollection, key: string | number, layer: CacheLayer | undefined) { + return [layer?.id, collection.name, key].filter(Boolean).join(':') + } + + function getItemKey(collection: ResolvedCollection, item: ResolvedCollectionItem): string | number { + const key = collection.getKey(item) + if (!isKeyDefined(key)) { + throw new Error(`Item does not have a key for collection ${collection.name}: ${item}`) + } + return key + } + + function addWrappedItemKeyToLayer(layer: CacheLayer | undefined, wrapKey: string) { + if (!layer) { + return + } + let keys = wrappedItemKeysPerLayer.get(layer.id) + if (!keys) { + keys = new Set() + wrappedItemKeysPerLayer.set(layer.id, keys) + } + keys.add(wrapKey) + } + + function ensureCollectionRef(collectionName: string) { + if (!state.collections[collectionName]) { + state.collections[collectionName] = ref({}) + } + return state.collections[collectionName] + } + + function getWrappedItem( + collection: ResolvedCollection, + item: ResolvedCollectionItem | null | undefined, + noCache = false, + ): WrappedItem | undefined { + if (!item) { + return undefined + } + + if (noCache) { + return wrapItem({ + store: getStore(), + collection, + item: shallowRef(item), + metadata: { + queries: new Set(), + dirtyQueries: new Set(), + }, + }) + } + + const key = getItemKey(collection, item) + const layer = item.$layer as CacheLayer | undefined + const wrapKey = getItemWrapKey(collection, key, layer) + let wrappedItem = wrappedItems.get(wrapKey) + if (!wrappedItem) { + let metadata = wrappedItemsMetadata.get(wrapKey) + if (!metadata) { + metadata = { + queries: new Set(), + dirtyQueries: new Set(), + } + wrappedItemsMetadata.set(wrapKey, metadata) + } + wrappedItem = wrapItem({ + store: getStore(), + collection, + item: computed(() => layer + ? item + : ensureCollectionRef(collection.name).value[key] ?? item), + metadata, + }) + wrappedItems.set(wrapKey, wrappedItem) + addWrappedItemKeyToLayer(item.$layer, wrapKey) + } + return wrappedItem + } + + function mark(marker: string) { + state.markers[marker] = true + } + + function getCollectionIndex(collectionName: string, indexKey: string) { + let collectionIndex = state.collectionIndexes.get(collectionName) + if (!collectionIndex) { + collectionIndex = new Map() + state.collectionIndexes.set(collectionName, collectionIndex) + } + let index = collectionIndex.get(indexKey) + if (!index) { + index = new Map() + collectionIndex.set(indexKey, index) + } + return index + } + + function deleteItem(collection: ResolvedCollection, key: string | number) { + const collectionState = ensureCollectionRef(collection.name).value + const item = collectionState[key] + if (item) { + // Update indexes + for (const [indexKey, indexFields] of collection.indexes) { + const index = getCollectionIndex(collection.name, indexKey) + // Remove previous index entry + const previousValue = indexFields.map(f => item[f]).join(':') + const existingKeys = index.get(previousValue) + if (existingKeys) { + existingKeys.value.delete(key) + } + } + + invalidateCollectionStateCache(collection.name) + delete collectionState[key] + const wrapKey = getItemWrapKey(collection, key, undefined) + wrappedItems.delete(wrapKey) + wrappedItemsMetadata.delete(wrapKey) + + const store = getStore() + store.$hooks.callHookSync('afterCacheWrite', { + store, + meta: {}, + collection, + key, + operation: 'delete', + }) + } + } + + function garbageCollectItem(collection: ResolvedCollection, item: WrappedItem) { + if (item.$meta.queries.size === 0) { + const key = getItemKey(collection, item) + deleteItem(collection, key) + const store = getStore() + store.$hooks.callHookSync('itemGarbageCollect', { + store, + collection, + item, + key, + }) + } + } + + function getStateForCollection(collectionName: string) { + // eslint-disable-next-line ts/no-unused-expressions + ensureCollectionStateCacheReactivityMarker(collectionName).value // Track reactivity + + const cached = collectionStateCache.get(collectionName) + if (cached) { + return cached + } + + let copied = false + let result = ensureCollectionRef(collectionName).value + + // Add & modify from layers + const collectionLayersRef = layers[collectionName] + if (collectionLayersRef) { + const collectionLayers = collectionLayersRef.value + for (const layer of collectionLayers) { + if (!layer.skip) { + // Lazy copy the state if needed + if (!copied) { + result = Object.assign({}, result) + copied = true + } + + const layerState: Record = {} + for (const [key, value] of Object.entries(layer.state)) { + const data = { + ...result[key], + ...value, + $layer: layer, + } + layerState[key] = data + } + Object.assign(result, layerState) + } + } + + // Delete from layers + for (const layer of collectionLayers) { + if (!layer.skip && layer.deletedItems) { + // Lazy copy the state if needed + if (!copied) { + result = Object.assign({}, result) + copied = true + } + + for (const key of layer.deletedItems) { + delete result[key] + } + } + } + } + + collectionStateCache.set(collectionName, result) + + return result + } + + function ensureLayersForCollection(collectionName: string) { + return layers[collectionName] ??= shallowRef([]) + } + + function removeLayer(layerId: string) { + const collectionName = layerIdToCollectionName[layerId] + if (!collectionName) { + return + } + const collectionLayersRef = layers[collectionName] + if (!collectionLayersRef) { + return + } + const collectionLayers = collectionLayersRef.value + const index = collectionLayers.findIndex(l => l.id === layerId) + if (index !== -1) { + const layer = collectionLayers[index]! + invalidateCollectionStateCache(layer.collectionName) + const keys = wrappedItemKeysPerLayer.get(layer.id) + if (keys) { + for (const key of keys) { + wrappedItems.delete(key) + wrappedItemsMetadata.delete(key) + } + wrappedItemKeysPerLayer.delete(layer.id) + } + collectionLayersRef.value = collectionLayers.filter(l => l.id !== layerId) + delete layerIdToCollectionName[layerId] + + // Update indexes + const collection = getStore().$collections.find(c => c.name === layer.collectionName) + if (collection) { + for (const key in layer.state) { + const currentData = getStateForCollection(collection.name)[key] + const previousData = { ...currentData, ...layer.state[key] } + updateItemIndexes(collection, key, previousData, currentData) + } + } + + const store = getStore() + store.$hooks.callHookSync('cacheLayerRemove', { + store, + layer, + }) + } + } + + function updateItemIndexes(collection: ResolvedCollection, key: string | number, previousData: any, newData: any = {}) { + // Update indexes + for (const [indexKey, indexFields] of collection.indexes) { + // Check if updated fields are part of the index + if (indexFields.some(f => f in newData && (!previousData || newData[f] !== previousData[f]))) { + const index = getCollectionIndex(collection.name, indexKey) + if (previousData) { + // Remove previous index entry + const values = indexFields.map(f => previousData?.[f]) + if (values.every(v => v != null)) { + const previousValue = values.join(':') + const existingKeys = index.get(previousValue) + if (existingKeys) { + existingKeys.value.delete(key) + } + } + } + const newValues = indexFields.map(f => newData[f] ?? previousData?.[f]) + if (newValues.every(v => v != null)) { + const newValue = newValues.join(':') + let existingKeys = index.get(newValue) + if (!existingKeys) { + existingKeys = ref(new Set()) + index.set(newValue, existingKeys) + } + existingKeys.value.add(key) + } + } + } + } + + function scheduleStaggeringBudgetReset() { + if (!cacheStaggering || staggeringResetTimer) { + return + } + staggeringResetTimer = setTimeout(() => { + staggeringResetTimer = undefined + staggeringBudget = cacheStaggering + if (!state.paused) { + flushQueuedOperations() + } + }, 10) + } + + function canProcessQueuedWrite() { + if (!cacheStaggering) { + return true + } + if (staggeringBudget > 0) { + return true + } + scheduleStaggeringBudgetReset() + return false + } + + function consumeQueuedWrite() { + if (!cacheStaggering) { + return + } + scheduleStaggeringBudgetReset() + staggeringBudget = Math.max(0, staggeringBudget - 1) + } + + function enqueueOperation(operation: QueuedOperation) { + state.queue.push(operation) + if (!state.paused) { + flushQueuedOperations() + } + } + + function writeItemForRelationNow({ + parentCollection, + relationKey, + relation, + childItem, + meta, + }: Parameters[0]) { + const store = getStore() + const possibleCollections = Object.keys(relation.to) + const nestedItemCollection = store.$getCollection(childItem, possibleCollections) + if (!nestedItemCollection) { + throw new Error(`Could not determine type for relation ${parentCollection.name}.${String(relationKey)}`) + } + const nestedKey = nestedItemCollection.getKey(childItem) + if (!isKeyDefined(nestedKey)) { + throw new Error(`Could not determine key for relation ${parentCollection.name}.${String(relationKey)}`) + } + + writeItemNow({ + collection: nestedItemCollection, + key: nestedKey, + item: childItem, + meta, + }) + } + + function writeItemNow(params: Parameters[0]) { + const { collection, key, item, marker, fromWriteItems, meta } = params + + // Tombstone guard: if a tombstone exists and the incoming write has + // per-field timestamps, drop the write when the tombstone is newer. + // Writes without timestamps always resurrect (explicit user intent). + const tomb = state.tombstones.get(collection.name, key) + if (tomb) { + if (params.fieldTimestamps && !shouldResurrect(tomb, params.fieldTimestamps)) { + return + } + state.tombstones.clear(collection.name, key) + } + + invalidateCollectionStateCache(collection.name) + + const collectionState = ensureCollectionRef(collection.name).value + const isFrozen = Object.isFrozen(item) + if (isFrozen) { + collectionState[key] = item + } + else { + const rawData = pickNonSpecialProps(item, true) + + // Handle relations + const data = {} as Record + for (const field in rawData) { + if (field in collection.relations) { + const relation = collection.relations[field] + const rawItem = rawData[field] + + // TODO: figure out deletions + if (!rawItem || !relation) { + continue + } + + if (relation.many && !Array.isArray(rawItem)) { + throw new Error(`Expected array for relation ${collection.name}.${field}`) + } + else if (!relation.many && Array.isArray(rawItem)) { + throw new Error(`Expected object for relation ${collection.name}.${field}`) + } + + if (Array.isArray(rawItem)) { + for (const nestedItem of rawItem as any[]) { + writeItemForRelationNow({ + parentCollection: collection, + relationKey: field, + relation, + childItem: nestedItem, + meta, + }) + } + } + else if (rawItem) { + writeItemForRelationNow({ + parentCollection: collection, + relationKey: field, + relation, + childItem: rawItem, + meta, + }) + } + } + else { + data[field] = rawData[field] + } + } + + const existing = collectionState[key] + + updateItemIndexes(collection, key, existing, data) + + if (!existing) { + // Disable deep reactivity tracking inside the `data` object + collectionState[key] = shallowRef(markRaw(data)) + + // Store field timestamps if provided + if (params.fieldTimestamps) { + let collectionTs = state.fieldTimestamps.get(collection.name) + if (!collectionTs) { + collectionTs = new Map() + state.fieldTimestamps.set(collection.name, collectionTs) + } + collectionTs.set(key, { ...params.fieldTimestamps }) + } + } + else if (params.fieldTimestamps) { + // CRDT field-level LWW merge + let collectionTs = state.fieldTimestamps.get(collection.name) + if (!collectionTs) { + collectionTs = new Map() + state.fieldTimestamps.set(collection.name, collectionTs) + } + const localTimestamps = collectionTs.get(key) ?? {} + const { merged, mergedTimestamps, conflicts } = mergeItemFields( + existing, + data, + localTimestamps, + params.fieldTimestamps, + ) + collectionTs.set(key, mergedTimestamps) + collectionState[key] = markRaw(merged) + + // Emit conflict hook if there are conflicts + if (conflicts.length > 0) { + const store = getStore() + store.$hooks.callHookSync('cacheConflict', { + store, + meta: {}, + collection, + key, + conflicts, + }) + } + } + else { + collectionState[key] = markRaw({ + ...existing, + ...data, + }) + } + } + if (marker) { + mark(marker) + } + + if (meta?.$queryTracking) { + meta.$queryTracking.items[collection.name] ??= new Set() + meta.$queryTracking.items[collection.name]!.add(key) + } + + if (!fromWriteItems) { + const store = getStore() + store.$hooks.callHookSync('afterCacheWrite', { + store, + meta: {}, + collection, + key, + result: [item], + marker, + operation: 'write', + }) + } + } + + function setStateNow(value: CustomCacheState) { + // Process incoming state + + // Markers + state.markers = value.markers || {} + + // Collections + + const newCollectionsState: Record>> = {} + for (const collectionName in value.collections) { + const collection = getStore().$collections.find(c => c.name === collectionName) + if (!collection) { + continue + } + const incomingCollectionState = value.collections[collectionName as keyof typeof value.collections] as Record + const collectionState = newCollectionsState[collectionName] = ref>({}) + for (const key in incomingCollectionState) { + const item = incomingCollectionState[key] + if (item) { + const existing = collectionState.value[key] + collectionState.value[key] = Object.isFrozen(item) ? item : shallowRef(item) + updateItemIndexes(collection, key, existing, item) + } + } + } + state.collections = newCollectionsState + + // Modules + + const newModulesState: Record> = {} + for (const moduleKey in value.modules) { + newModulesState[moduleKey] = ref(value.modules[moduleKey]) + } + state.modules = newModulesState + + wrappedItems.clear() + wrappedItemsMetadata.clear() + collectionStateCache.clear() + + const store = getStore() + store.$hooks.callHookSync('afterCacheReset', { + store, + meta: {}, + }) + + // Query Meta + state.queryMeta = value.queryMeta || {} + } + + function clearNow() { + state.markers = {} + for (const collectionName in state.collections) { + state.collections[collectionName]!.value = {} + } + for (const moduleKey in state.modules) { + state.modules[moduleKey]!.value = {} + } + wrappedItems.clear() + wrappedItemsMetadata.clear() + collectionStateCache.clear() + state.collectionIndexes.clear() + state.fieldTimestamps.clear() + const tombIds = Array.from(state.tombstones.entries(), ([, t]) => t) + for (const t of tombIds) { + state.tombstones.clear(t.collection, t.key) + } + + const store = getStore() + store.$hooks.callHookSync('afterCacheReset', { + store, + meta: {}, + }) + } + + function addLayerNow(layer: CacheLayer) { + const collection = getStore().$collections.find(c => c.name === layer.collectionName) + if (!collection) { + throw new Error(`Collection not found for layer: ${layer.collectionName}`) + } + + removeLayer(layer.id) + + // Update indexes + const queuedIndexUpdates: Array<[string | number, any, any]> = [] + for (const key in layer.state) { + const existing = getStateForCollection(collection.name)[key] + const newData = layer.state[key] + queuedIndexUpdates.push([key, existing, newData]) + } + + const collectionLayersRef = ensureLayersForCollection(layer.collectionName) + collectionLayersRef.value = [...collectionLayersRef.value, layer] + layerIdToCollectionName[layer.id] = layer.collectionName + + invalidateCollectionStateCache(layer.collectionName) + + // Update indexes after adding the layer + for (const [key, existing, newData] of queuedIndexUpdates) { + updateItemIndexes(collection, key, existing, newData) + } + + const store = getStore() + store.$hooks.callHookSync('cacheLayerAdd', { + store, + layer, + }) + } + + function flushQueuedOperations() { + if (isFlushingQueue || state.paused) { + return + } + + isFlushingQueue = true + try { + while (state.queue.length) { + const operation = state.queue[0]! + switch (operation.type) { + case 'writeItem': + if (!canProcessQueuedWrite()) { + return + } + writeItemNow(operation.params) + consumeQueuedWrite() + state.queue.shift() + break + case 'writeItems': + while (operation.index < operation.params.items.length) { + if (!canProcessQueuedWrite()) { + return + } + const { key, value: item } = operation.params.items[operation.index]! + writeItemNow({ + collection: operation.params.collection, + key, + item, + meta: operation.params.meta, + fromWriteItems: true, + }) + operation.index++ + consumeQueuedWrite() + } + if (operation.params.marker) { + mark(operation.params.marker) + } + { + const store = getStore() + store.$hooks.callHookSync('afterCacheWrite', { + store, + meta: {}, + collection: operation.params.collection, + result: operation.params.items, + marker: operation.params.marker, + operation: 'write', + }) + } + state.queue.shift() + break + case 'deleteItem': + { + const { collection, key, deletedAt } = operation.params + const collectionTs = state.fieldTimestamps.get(collection.name) + if (collectionTs) { + collectionTs.delete(key) + } + if (deletedAt != null) { + state.tombstones.set({ + collection: collection.name, + key, + deletedAt, + }) + } + deleteItem(collection, key) + } + state.queue.shift() + break + case 'addLayer': + addLayerNow(operation.layer) + state.queue.shift() + break + case 'removeLayer': + removeLayer(operation.layerId) + state.queue.shift() + break + case 'setState': + setStateNow(operation.state) + state.queue.shift() + break + case 'clear': + clearNow() + state.queue.shift() + break + } + } + } + finally { + isFlushingQueue = false + } + } + + return { + wrapItem({ collection, item, noCache }) { + return getWrappedItem(collection, item, noCache)! + }, + readItem({ collection, key }) { + return getWrappedItem(collection, getStateForCollection(collection.name)[key]) + }, + readItems({ collection, marker, filter, keys, limit, indexKey, indexValue }) { + if (marker && !state.markers[marker]) { + return [] + } + const data: Record> = getStateForCollection(collection.name) + const result: Array> = [] + let count = 0 + + // Index + if (keys == null && indexKey != null) { + const index = getCollectionIndex(collection.name, indexKey) + const itemKeys = index.get(indexValue) + if (itemKeys) { + keys = Array.from(itemKeys.value) + } + else { + keys = [] + } + } + + const keysToRead = keys ?? Object.keys(data) + for (const key of keysToRead) { + const item = data[key] + if (item) { + if (filter && !filter(item)) { + continue + } + const wrappedItem = getWrappedItem(collection, item) + if (wrappedItem) { + result.push(wrappedItem) + count++ + if (limit != null && count >= limit) { + break + } + } + } + } + return result + }, + writeItem(params) { + enqueueOperation({ type: 'writeItem', params }) + }, + writeItems(params) { + enqueueOperation({ type: 'writeItems', params, index: 0 }) + }, + writeItemForRelation({ parentCollection, relationKey, relation, childItem, meta }) { + const store = getStore() + const possibleCollections = Object.keys(relation.to) + const nestedItemCollection = store.$getCollection(childItem, possibleCollections) + if (!nestedItemCollection) { + throw new Error(`Could not determine type for relation ${parentCollection.name}.${String(relationKey)}`) + } + const nestedKey = nestedItemCollection.getKey(childItem) + if (!isKeyDefined(nestedKey)) { + throw new Error(`Could not determine key for relation ${parentCollection.name}.${String(relationKey)}`) + } + + this.writeItem({ + collection: nestedItemCollection, + key: nestedKey, + item: childItem, + meta, + }) + }, + deleteItem(params) { + enqueueOperation({ type: 'deleteItem', params }) + }, + readFieldTimestamps({ collectionName, key }) { + return state.fieldTimestamps.get(collectionName)?.get(key) + }, + writeFieldTimestamps({ collectionName, key, timestamps }) { + let collectionTs = state.fieldTimestamps.get(collectionName) + if (!collectionTs) { + collectionTs = new Map() + state.fieldTimestamps.set(collectionName, collectionTs) + } + collectionTs.set(key, { ...timestamps }) + }, + getModuleState(name, key, initState) { + const cacheKey = `${name}:${key}` + if (!state.modules[cacheKey]) { + state.modules[cacheKey] = ref(initState) + } + return state.modules[cacheKey].value + }, + getState() { + const result: CustomCacheState = { + collections: {}, + markers: toValue(state.markers), + modules: {}, + queryMeta: state.queryMeta, + } + + for (const collectionName in state.collections) { + const targetState: Record = result.collections[collectionName] = {} + const itemsForType = state.collections[collectionName]!.value + for (const key in itemsForType) { + const item = itemsForType[key] + if (item) { + targetState[key] = toValue(item) + } + } + } + + for (const moduleKey in state.modules) { + result.modules[moduleKey] = toValue(state.modules[moduleKey]!) + } + + return result + }, + setState(value) { + enqueueOperation({ type: 'setState', state: value }) + }, + clear() { + enqueueOperation({ type: 'clear' }) + }, + clearCollection({ collection }) { + invalidateCollectionStateCache(collection.name) + state.fieldTimestamps.delete(collection.name) + const tombIds = Array.from(state.tombstones.entries(), ([, t]) => t) + .filter(t => t.collection === collection.name) + for (const t of tombIds) { + state.tombstones.clear(t.collection, t.key) + } + const itemsForType = state.collections[collection.name] + if (itemsForType) { + for (const key in itemsForType.value) { + this.deleteItem({ collection, key }) + } + } + }, + garbageCollectItem({ collection, item }) { + garbageCollectItem(collection, item) + }, + garbageCollect() { + for (const collectionName in state.collections) { + const collection = getStore().$collections.find(m => m.name === collectionName) + if (!collection) { + continue + } + const itemsForType = state.collections[collectionName]?.value + if (itemsForType) { + for (const key in itemsForType) { + const wrappedItem = this.wrapItem({ collection, item: itemsForType[key] }) + garbageCollectItem(collection, wrappedItem) + } + } + } + }, + addLayer(layer) { + enqueueOperation({ type: 'addLayer', layer }) + }, + getLayer(layerId) { + const collectionName = layerIdToCollectionName[layerId] + if (!collectionName) { + return undefined + } + const collectionLayers = ensureLayersForCollection(collectionName) + return collectionLayers.value.find(l => l.id === layerId) + }, + removeLayer(layerId) { + enqueueOperation({ type: 'removeLayer', layerId }) + }, + tombstones: { + get: (c, k) => state.tombstones.get(c, k), + entries: () => state.tombstones.entries(), + size: () => state.tombstones.size(), + }, + gcTombstones(olderThan: FieldTimestampValue) { + return gcTombstones(state.tombstones, olderThan) + }, + pause() { + state.paused = true + }, + resume() { + state.paused = false + flushQueuedOperations() + }, + dispose() { + stopTombstoneGc?.() + stopTombstoneGc = undefined + }, + _private: { + state, + wrappedItems, + wrappedItemsMetadata, + getWrappedItem, + layers, + ensureLayersForCollection, + }, + } satisfies Cache & VueCachePrivate as any +} + +export interface VueCachePrivate { + _private: { + state: InternalCacheState + wrappedItems: Map> + wrappedItemsMetadata: Map> + getWrappedItem: ( + collection: ResolvedCollection, + item: ResolvedCollectionItem | null | undefined, + noCache?: boolean, + ) => WrappedItem | undefined + layers: Record> + ensureLayersForCollection: (collectionName: string) => Ref + } +} diff --git a/packages/vue/package.json b/packages/vue/package.json index 081252b9..d8336cc9 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -35,6 +35,7 @@ "build": "unbuild", "dev": "unbuild --stub && printf \"export * from '../src/index'\" > ./dist/index.mjs", "prepack": "node ../../scripts/sync-dep-skills.mjs .", + "benchmark": "vitest bench --run benchmark/cache.bench.ts", "test:types": "tsc --noEmit" }, "peerDependencies": { diff --git a/packages/vue/src/cache/api.ts b/packages/vue/src/cache/api.ts index c131fd56..d8c10237 100644 --- a/packages/vue/src/cache/api.ts +++ b/packages/vue/src/cache/api.ts @@ -1,11 +1,8 @@ -import type { Cache, CollectionDefaults, CustomCacheState, ResolvedCollectionItemBase, StoreSchema, WrappedItem } from '@rstore/shared' +import type { Cache, CollectionDefaults, StoreSchema, WrappedItem } from '@rstore/shared' import type { CacheRuntime, VueCachePrivate } from './types' -import { gcTombstones, isKeyDefined } from '@rstore/core' -import { ref, toValue } from 'vue' -import { getCollectionIndex, invalidateCollectionStateCache } from './context' -import { ensureLayersForCollection, getStateForCollection } from './layers' +import { reactive } from 'vue' +import { ensureLayersForCollection } from './context' import { applyMutationToCache } from './mutations' -import { enqueueOperation, flushQueuedOperations } from './queue' import { garbageCollectItem, getWrappedItem } from './wrapped' /** Create the public Cache implementation from a cache runtime. */ @@ -18,55 +15,47 @@ export function createCacheApi< return getWrappedItem(ctx, collection, item, noCache)! }, readItem({ collection, key }) { - return getWrappedItem(ctx, collection, getStateForCollection(ctx, collection.name)[key]) + ctx.signals.trackItem(collection.name, key) + return getWrappedItem(ctx, collection, ctx.engine.readItemRaw({ collection, key })) }, readItems(params) { return readItems(ctx, params) }, writeItem(params) { - enqueueOperation(ctx, { type: 'writeItem', params }) + ctx.engine.writeItem(params) }, writeItems(params) { - enqueueOperation(ctx, { type: 'writeItems', params, index: 0 }) + ctx.engine.writeItems(params) }, writeItemForRelation(params) { - writeItemForRelation(ctx, params) + ctx.engine.writeItemForRelation(params) }, applyMutation(params) { return applyMutationToCache(ctx, params) }, deleteItem(params) { - enqueueOperation(ctx, { type: 'deleteItem', params }) + ctx.engine.deleteItem(params) }, - readFieldTimestamps({ collectionName, key }) { - return ctx.state.fieldTimestamps.get(collectionName)?.get(key) + readFieldTimestamps(params) { + return ctx.engine.readFieldTimestamps(params) }, - writeFieldTimestamps({ collectionName, key, timestamps }) { - let collectionTs = ctx.state.fieldTimestamps.get(collectionName) - if (!collectionTs) { - collectionTs = new Map() - ctx.state.fieldTimestamps.set(collectionName, collectionTs) - } - collectionTs.set(key, { ...timestamps }) + writeFieldTimestamps(params) { + ctx.engine.writeFieldTimestamps(params) }, getModuleState(name, key, initState) { - const cacheKey = `${name}:${key}` - if (!ctx.state.modules[cacheKey]) { - ctx.state.modules[cacheKey] = ref(initState) - } - return ctx.state.modules[cacheKey]!.value + return reactive(ctx.engine.getModuleState(name, key, initState)) }, getState() { - return getState(ctx) + return ctx.engine.getState() }, setState(state) { - enqueueOperation(ctx, { type: 'setState', state }) + ctx.engine.setState(state) }, clear() { - enqueueOperation(ctx, { type: 'clear' }) + ctx.engine.clear() }, - clearCollection({ collection }) { - clearCollection(ctx, collection) + clearCollection(params) { + ctx.engine.clearCollection(params) }, garbageCollectItem({ collection, item }) { garbageCollectItem(ctx, collection, item) @@ -75,36 +64,31 @@ export function createCacheApi< garbageCollect(ctx) }, addLayer(layer) { - enqueueOperation(ctx, { type: 'addLayer', layer }) + ctx.engine.addLayer(layer) }, getLayer(layerId) { - const collectionName = ctx.layerIdToCollectionName[layerId] - if (!collectionName) { - return undefined - } - return ensureLayersForCollection(ctx, collectionName).value.find(l => l.id === layerId) + return ctx.engine.getLayer(layerId) }, removeLayer(layerId) { - enqueueOperation(ctx, { type: 'removeLayer', layerId }) + ctx.engine.removeLayer(layerId) }, tombstones: { - get: (c, k) => ctx.state.tombstones.get(c, k), - entries: () => ctx.state.tombstones.entries(), - size: () => ctx.state.tombstones.size(), + get: (c, k) => ctx.engine.tombstones.get(c, k), + entries: () => ctx.engine.tombstones.entries(), + size: () => ctx.engine.tombstones.size(), }, gcTombstones(olderThan) { - return gcTombstones(ctx.state.tombstones, olderThan) + return ctx.engine.gcTombstones(olderThan) }, pause() { - ctx.state.paused = true + ctx.engine.pause() }, resume() { - ctx.state.paused = false - flushQueuedOperations(ctx) + ctx.engine.resume() }, dispose() { - ctx.stopTombstoneGc?.() - ctx.stopTombstoneGc = undefined + ctx.engine.dispose() + ctx.signals.dispose() }, _private: { state: ctx.state, @@ -113,120 +97,48 @@ export function createCacheApi< getWrappedItem: (collection, item, noCache) => getWrappedItem(ctx, collection, item, noCache), layers: ctx.layers, ensureLayersForCollection: collectionName => ensureLayersForCollection(ctx, collectionName), + signals: ctx.signals, }, } satisfies Cache & VueCachePrivate as any } function readItems(ctx: CacheRuntime, { collection, marker, filter, keys, limit, indexKey, indexValue }: Parameters[0]) { - if (marker && !ctx.state.markers[marker]) { - return [] - } - const data: Record> = getStateForCollection(ctx, collection.name) - const result: Array> = [] - let count = 0 - if (keys == null && indexKey != null) { - const index = getCollectionIndex(ctx, collection.name, indexKey) - const itemKeys = index.get(indexValue) - keys = itemKeys ? Array.from(itemKeys.value) : [] + ctx.signals.trackIndex(collection.name, indexKey, String(indexValue)) } - - for (const key of keys ?? Object.keys(data)) { - const item = data[key] - if (!item || (filter && !filter(item))) { - continue - } - const wrappedItem = getWrappedItem(ctx, collection, item) - if (wrappedItem) { - result.push(wrappedItem) - count++ - if (limit != null && count >= limit) { - break - } - } + else { + ctx.signals.trackList(collection.name) } - return result -} - -function writeItemForRelation(ctx: CacheRuntime, { parentCollection, relationKey, relation, childItem, meta }: Parameters[0]) { - const possibleCollections = Object.keys(relation.to) - const nestedItemCollection = ctx.getStore().$getCollection(childItem, possibleCollections) - if (!nestedItemCollection) { - throw new Error(`Could not determine type for relation ${parentCollection.name}.${String(relationKey)}`) - } - const nestedKey = nestedItemCollection.getKey(childItem) - if (!isKeyDefined(nestedKey)) { - throw new Error(`Could not determine key for relation ${parentCollection.name}.${String(relationKey)}`) - } - enqueueOperation(ctx, { - type: 'writeItem', - params: { - collection: nestedItemCollection, - key: nestedKey, - item: childItem, - meta, - }, - }) -} -function getState(ctx: CacheRuntime): CustomCacheState { - const result: CustomCacheState = { - collections: {}, - markers: toValue(ctx.state.markers), - modules: {}, - queryMeta: ctx.state.queryMeta, + if (marker && !ctx.engine.hasMarker(marker)) { + return [] } - for (const collectionName in ctx.state.collections) { - const targetState: Record = result.collections[collectionName] = {} - const itemsForType = ctx.state.collections[collectionName]!.value - for (const key in itemsForType) { - const item = itemsForType[key] - if (item) { - targetState[key] = toValue(item) - } + const candidateKeys = ctx.engine.resolveKeys({ collection, marker, keys, indexKey, indexValue }) + const result: Array> = [] + let count = 0 + for (const key of candidateKeys) { + const wrappedItem = getWrappedItem(ctx, collection, ctx.engine.readItemRaw({ collection, key })) + if (!wrappedItem || (filter && !filter(wrappedItem))) { + continue + } + result.push(wrappedItem) + count++ + if (limit != null && count >= limit) { + break } } - - for (const moduleKey in ctx.state.modules) { - result.modules[moduleKey] = toValue(ctx.state.modules[moduleKey]!) - } - return result } -function clearCollection(ctx: CacheRuntime, collection: Parameters[0]['collection']) { - invalidateCollectionStateCache(ctx, collection.name) - ctx.state.fieldTimestamps.delete(collection.name) - const tombIds = Array.from(ctx.state.tombstones.entries(), ([, t]) => t) - .filter(t => t.collection === collection.name) - for (const t of tombIds) { - ctx.state.tombstones.clear(t.collection, t.key) - } - const itemsForType = ctx.state.collections[collection.name] - if (!itemsForType) { - return - } - for (const key in itemsForType.value) { - enqueueOperation(ctx, { type: 'deleteItem', params: { collection, key } }) - } -} - function garbageCollect(ctx: CacheRuntime) { - for (const collectionName in ctx.state.collections) { - const collection = ctx.getStore().$collections.find(m => m.name === collectionName) - if (!collection) { - continue - } - const itemsForType = ctx.state.collections[collectionName]?.value - if (!itemsForType) { - continue - } - for (const key in itemsForType) { - const wrappedItem = getWrappedItem(ctx, collection, itemsForType[key]) + const store = ctx.getStore() + for (const collection of store.$collections) { + ctx.engine.forEachKey(collection.name, (key) => { + const wrappedItem = getWrappedItem(ctx, collection, ctx.engine.readItemRaw({ collection, key })) if (wrappedItem) { garbageCollectItem(ctx, collection, wrappedItem) } - } + }) } } diff --git a/packages/vue/src/cache/context.ts b/packages/vue/src/cache/context.ts index 92e710f4..1fc31328 100644 --- a/packages/vue/src/cache/context.ts +++ b/packages/vue/src/cache/context.ts @@ -1,7 +1,9 @@ -import type { CollectionDefaults, ResolvedCollection, ResolvedCollectionItem, StoreSchema } from '@rstore/shared' +import type { EngineAfterWritePayload, EngineCallbacks, EngineConflictPayload } from '@rstore/core' +import type { CacheLayer, CollectionDefaults, ResolvedCollection, ResolvedCollectionItem, StoreSchema } from '@rstore/shared' import type { CacheRuntime, CreateCacheOptions } from './types' -import { createTombstoneStore, isKeyDefined, scheduleTombstoneGc } from '@rstore/core' -import { ref } from 'vue' +import { createStoreEngine, isKeyDefined } from '@rstore/core' +import { shallowRef } from 'vue' +import { createSignalRegistry } from './signals' /** Create the mutable runtime shared by all cache modules. */ export function createCacheRuntime< @@ -9,64 +11,101 @@ export function createCacheRuntime< TCollectionDefaults extends CollectionDefaults, >({ getStore, - cacheStaggering: rawCacheStaggering = 0, + cacheStaggering, tombstoneGc = {}, isServer = (import.meta as unknown as { server?: boolean }).server === true, }: CreateCacheOptions): CacheRuntime { - const cacheStaggering = Math.max(0, Math.floor(rawCacheStaggering)) - const runtime: CacheRuntime = { - getStore, + let runtime: CacheRuntime + const pageRefs = new Map() + + const callbacks: EngineCallbacks = { + getCollection: name => getStore().$collections.find(c => c.name === name), + + resolveChildCollection: (item, possibleNames) => getStore().$getCollection(item, possibleNames), + + onAfterWrite: (payload: EngineAfterWritePayload) => { + if (payload.operation === 'delete' && payload.key != null) { + evictBaseWrappedItem(runtime, payload.collection, payload.key) + } + const store = getStore() + store.$hooks.callHookSync('afterCacheWrite', { + store, + meta: {}, + collection: payload.collection, + key: payload.key, + result: payload.result, + marker: payload.marker, + operation: payload.operation, + }) + }, + + onConflict: (payload: EngineConflictPayload) => { + const store = getStore() + store.$hooks.callHookSync('cacheConflict', { + store, + meta: {}, + collection: payload.collection, + key: payload.key, + conflicts: payload.conflicts, + }) + }, + + onLayerAdd: (layer) => { + const ref = ensureLayersForCollection(runtime, layer.collectionName) + ref.value = [...ref.value.filter(l => l.id !== layer.id), layer] + runtime.layerIdToCollectionName[layer.id] = layer.collectionName + const store = getStore() + store.$hooks.callHookSync('cacheLayerAdd', { store, layer }) + }, + + onLayerRemove: (layer) => { + const ref = runtime.layers[layer.collectionName] + if (ref) { + ref.value = ref.value.filter(l => l.id !== layer.id) + } + delete runtime.layerIdToCollectionName[layer.id] + clearLayerWrappedItems(runtime, layer.id) + const store = getStore() + store.$hooks.callHookSync('cacheLayerRemove', { store, layer }) + }, + + onReset: () => { + runtime.wrappedItems.clear() + runtime.wrappedItemsMetadata.clear() + runtime.wrappedItemKeysPerLayer.clear() + runtime.signals.dispose() + const store = getStore() + store.$hooks.callHookSync('afterCacheReset', { store, meta: {} }) + }, + } + + const engine = createStoreEngine({ + callbacks, cacheStaggering, + tombstoneGc, + isServer, + }) + + runtime = { + getStore, + engine, state: { - markers: {}, - collections: {}, - collectionIndexes: new Map(), - modules: {}, - queryMeta: {}, - pageRefs: new Map(), - paused: false, - queue: [], - fieldTimestamps: new Map(), - tombstones: createTombstoneStore(), + pageRefs, + get queryMeta() { + return engine._getQueryMeta() + }, }, + signals: createSignalRegistry({ engine, isServer }), layers: {}, layerIdToCollectionName: {}, wrappedItems: new Map(), wrappedItemsMetadata: new Map(), wrappedItemKeysPerLayer: new Map(), - collectionStateCache: new Map(), - collectionStateCacheReactivityMarker: new Map(), - isFlushingQueue: false, - staggeringBudget: cacheStaggering, - } - - const canScheduleTombstoneGc = !isServer && typeof setInterval !== 'undefined' - if (tombstoneGc !== false && canScheduleTombstoneGc) { - runtime.stopTombstoneGc = scheduleTombstoneGc(runtime.state.tombstones, { - intervalMs: tombstoneGc.intervalMs ?? 60_000, - ttlMs: tombstoneGc.ttlMs ?? 24 * 60 * 60 * 1000, - }) } return runtime } -/** Ensure the reactivity marker for a collection overlay cache exists. */ -export function ensureCollectionStateCacheReactivityMarker(ctx: CacheRuntime, collectionName: string) { - let marker = ctx.collectionStateCacheReactivityMarker.get(collectionName) - if (!marker) { - marker = ref(0) - ctx.collectionStateCacheReactivityMarker.set(collectionName, marker) - } - return marker -} - -/** Drop a cached overlay state and notify reactive readers. */ -export function invalidateCollectionStateCache(ctx: CacheRuntime, collectionName: string) { - ctx.collectionStateCache.delete(collectionName) - ensureCollectionStateCacheReactivityMarker(ctx, collectionName).value++ -} - /** Build the cache key for a wrapped item proxy. */ export function getItemWrapKey(collection: ResolvedCollection, key: string | number, layer: { id: string } | undefined) { return [layer?.id, collection.name, key].filter(Boolean).join(':') @@ -94,30 +133,27 @@ export function addWrappedItemKeyToLayer(ctx: CacheRuntime, layer: { id: string keys.add(wrapKey) } -/** Ensure a collection state ref exists. */ -export function ensureCollectionRef(ctx: CacheRuntime, collectionName: string) { - if (!ctx.state.collections[collectionName]) { - ctx.state.collections[collectionName] = ref({}) - } - return ctx.state.collections[collectionName] +/** Ensure the devtools layer mirror for a collection exists. */ +export function ensureLayersForCollection(ctx: CacheRuntime, collectionName: string) { + return ctx.layers[collectionName] ??= shallowRef([]) } -/** Mark a query marker as fetched. */ -export function mark(ctx: CacheRuntime, marker: string) { - ctx.state.markers[marker] = true +/** Drop a wrapped base item and its metadata from the identity maps. */ +export function evictBaseWrappedItem(ctx: CacheRuntime, collection: ResolvedCollection, key: string | number) { + const wrapKey = getItemWrapKey(collection, key, undefined) + ctx.wrappedItems.delete(wrapKey) + ctx.wrappedItemsMetadata.delete(wrapKey) + ctx.signals.dropItem(collection.name, key) } -/** Get or create a collection index map. */ -export function getCollectionIndex(ctx: CacheRuntime, collectionName: string, indexKey: string) { - let collectionIndex = ctx.state.collectionIndexes.get(collectionName) - if (!collectionIndex) { - collectionIndex = new Map() - ctx.state.collectionIndexes.set(collectionName, collectionIndex) +function clearLayerWrappedItems(ctx: CacheRuntime, layerId: string) { + const keys = ctx.wrappedItemKeysPerLayer.get(layerId) + if (!keys) { + return } - let index = collectionIndex.get(indexKey) - if (!index) { - index = new Map() - collectionIndex.set(indexKey, index) + for (const wrapKey of keys) { + ctx.wrappedItems.delete(wrapKey) + ctx.wrappedItemsMetadata.delete(wrapKey) } - return index + ctx.wrappedItemKeysPerLayer.delete(layerId) } diff --git a/packages/vue/src/cache/index.ts b/packages/vue/src/cache/index.ts index faf022b0..d63f5c3f 100644 --- a/packages/vue/src/cache/index.ts +++ b/packages/vue/src/cache/index.ts @@ -3,7 +3,7 @@ import type { CreateCacheOptions } from './types' import { createCacheApi } from './api' import { createCacheRuntime } from './context' -export type { CreateCacheOptions, VueCachePrivate } from './types' +export type { CreateCacheOptions, VueCachePrivate, VueCacheState } from './types' /** Create a Vue-backed rstore cache. */ export function createCache< diff --git a/packages/vue/src/cache/indexes.ts b/packages/vue/src/cache/indexes.ts deleted file mode 100644 index 15d22c90..00000000 --- a/packages/vue/src/cache/indexes.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { Collection, CollectionDefaults, ResolvedCollection, StoreSchema } from '@rstore/shared' -import type { CacheRuntime } from './types' -import { ref } from 'vue' -import { getCollectionIndex } from './context' - -/** Remove an item from every index it previously occupied. */ -export function removeItemIndexes( - ctx: CacheRuntime, - collection: ResolvedCollection, - key: string | number, - item: any, -) { - for (const [indexKey, indexFields] of collection.indexes) { - const index = getCollectionIndex(ctx, collection.name, indexKey) - const previousValue = indexFields.map(f => item[f]).join(':') - const existingKeys = index.get(previousValue) - if (existingKeys) { - existingKeys.value.delete(key) - } - } -} - -/** Update index entries affected by changed item data. */ -export function updateItemIndexes( - ctx: CacheRuntime, - collection: ResolvedCollection, - key: string | number, - previousData: any, - newData: any = {}, -) { - for (const [indexKey, indexFields] of collection.indexes) { - if (!indexFields.some(f => f in newData && (!previousData || newData[f] !== previousData[f]))) { - continue - } - - const index = getCollectionIndex(ctx, collection.name, indexKey) - if (previousData) { - const values = indexFields.map(f => previousData?.[f]) - if (values.every(v => v != null)) { - const previousValue = values.join(':') - const existingKeys = index.get(previousValue) - if (existingKeys) { - existingKeys.value.delete(key) - } - } - } - - const newValues = indexFields.map(f => newData[f] ?? previousData?.[f]) - if (newValues.every(v => v != null)) { - const newValue = newValues.join(':') - let existingKeys = index.get(newValue) - if (!existingKeys) { - existingKeys = ref(new Set()) - index.set(newValue, existingKeys) - } - existingKeys.value.add(key) - } - } -} diff --git a/packages/vue/src/cache/layers.ts b/packages/vue/src/cache/layers.ts deleted file mode 100644 index 176e8c98..00000000 --- a/packages/vue/src/cache/layers.ts +++ /dev/null @@ -1,147 +0,0 @@ -import type { CacheLayer } from '@rstore/shared' -import type { Ref } from 'vue' -import type { CacheRuntime } from './types' -import { shallowRef } from 'vue' -import { ensureCollectionStateCacheReactivityMarker, invalidateCollectionStateCache } from './context' -import { updateItemIndexes } from './indexes' - -/** Read a collection state with active non-skipped layers applied. */ -export function getStateForCollection(ctx: CacheRuntime, collectionName: string) { - // eslint-disable-next-line ts/no-unused-expressions - ensureCollectionStateCacheReactivityMarker(ctx, collectionName).value - - const cached = ctx.collectionStateCache.get(collectionName) - if (cached) { - return cached - } - - let copied = false - let result = ctx.state.collections[collectionName]?.value ?? {} - const collectionLayersRef = ctx.layers[collectionName] - if (collectionLayersRef) { - const collectionLayers = collectionLayersRef.value - for (const layer of collectionLayers) { - if (layer.skip) { - continue - } - if (!copied) { - result = Object.assign({}, result) - copied = true - } - const layerState: Record = {} - for (const [key, value] of Object.entries(layer.state)) { - layerState[key] = { - ...result[key], - ...value, - $layer: layer, - } - } - Object.assign(result, layerState) - } - - for (const layer of collectionLayers) { - if (!layer.skip && layer.deletedItems) { - if (!copied) { - result = Object.assign({}, result) - copied = true - } - for (const key of layer.deletedItems) { - delete result[key] - } - } - } - } - - ctx.collectionStateCache.set(collectionName, result) - return result -} - -/** Ensure the layer list for a collection exists. */ -export function ensureLayersForCollection(ctx: CacheRuntime, collectionName: string): Ref { - return ctx.layers[collectionName] ??= shallowRef([]) -} - -/** Remove a cache layer immediately. */ -export function removeLayer(ctx: CacheRuntime, layerId: string) { - const collectionName = ctx.layerIdToCollectionName[layerId] - if (!collectionName) { - return - } - const collectionLayersRef = ctx.layers[collectionName] - if (!collectionLayersRef) { - return - } - const collectionLayers = collectionLayersRef.value - const index = collectionLayers.findIndex(l => l.id === layerId) - if (index === -1) { - return - } - - const layer = collectionLayers[index]! - invalidateCollectionStateCache(ctx, layer.collectionName) - clearLayerWrappedItems(ctx, layer.id) - collectionLayersRef.value = collectionLayers.filter(l => l.id !== layerId) - delete ctx.layerIdToCollectionName[layerId] - updateIndexesAfterLayerRemoval(ctx, layer) - - const store = ctx.getStore() - store.$hooks.callHookSync('cacheLayerRemove', { - store, - layer, - }) -} - -/** Add a cache layer immediately. */ -export function addLayerNow(ctx: CacheRuntime, layer: CacheLayer) { - const collection = ctx.getStore().$collections.find(c => c.name === layer.collectionName) - if (!collection) { - throw new Error(`Collection not found for layer: ${layer.collectionName}`) - } - - removeLayer(ctx, layer.id) - const queuedIndexUpdates: Array<[string | number, any, any]> = [] - for (const key in layer.state) { - const existing = getStateForCollection(ctx, collection.name)[key] - const newData = layer.state[key] - queuedIndexUpdates.push([key, existing, newData]) - } - - const collectionLayersRef = ensureLayersForCollection(ctx, layer.collectionName) - collectionLayersRef.value = [...collectionLayersRef.value, layer] - ctx.layerIdToCollectionName[layer.id] = layer.collectionName - invalidateCollectionStateCache(ctx, layer.collectionName) - - for (const [key, existing, newData] of queuedIndexUpdates) { - updateItemIndexes(ctx, collection, key, existing, newData) - } - - const store = ctx.getStore() - store.$hooks.callHookSync('cacheLayerAdd', { - store, - layer, - }) -} - -function clearLayerWrappedItems(ctx: CacheRuntime, layerId: string) { - const keys = ctx.wrappedItemKeysPerLayer.get(layerId) - if (!keys) { - return - } - for (const key of keys) { - ctx.wrappedItems.delete(key) - ctx.wrappedItemsMetadata.delete(key) - } - ctx.wrappedItemKeysPerLayer.delete(layerId) -} - -function updateIndexesAfterLayerRemoval(ctx: CacheRuntime, layer: CacheLayer) { - const collection = ctx.getStore().$collections.find(c => c.name === layer.collectionName) - if (!collection) { - return - } - for (const key in layer.state) { - const currentData = getStateForCollection(ctx, collection.name)[key] - const previousData = { ...currentData, ...layer.state[key] } - updateItemIndexes(ctx, collection, key, previousData, currentData) - } -} diff --git a/packages/vue/src/cache/mutations.ts b/packages/vue/src/cache/mutations.ts index 34ac3a05..64125a66 100644 --- a/packages/vue/src/cache/mutations.ts +++ b/packages/vue/src/cache/mutations.ts @@ -2,7 +2,6 @@ import type { ApplyMutationOptions, ApplyMutationResult, Collection, CollectionD import type { CacheRuntime } from './types' import { isKeyDefined } from '@rstore/core' import { getMutationItemKey, unwrapMutationItem } from '@rstore/shared' -import { enqueueOperation } from './queue' /** Apply a mutation-shaped cache update without emitting mutation hooks. */ export function applyMutationToCache( @@ -39,26 +38,19 @@ function applyWriteMutation) { - if (!canProcessQueuedWrite(ctx)) { - return false - } - writeItemNow(ctx, operation.params) - consumeQueuedWrite(ctx) - return true -} - -function processQueuedWriteItems(ctx: CacheRuntime, operation: Extract) { - while (operation.index < operation.params.items.length) { - if (!canProcessQueuedWrite(ctx)) { - return false - } - const { key, value: item } = operation.params.items[operation.index]! - writeItemNow(ctx, { - collection: operation.params.collection, - key, - item, - meta: operation.params.meta, - fromWriteItems: true, - }) - operation.index++ - consumeQueuedWrite(ctx) - } - if (operation.params.marker) { - mark(ctx, operation.params.marker) - } - const store = ctx.getStore() - store.$hooks.callHookSync('afterCacheWrite', { - store, - meta: {}, - collection: operation.params.collection, - result: operation.params.items, - marker: operation.params.marker, - operation: 'write', - }) - return true -} - -function processQueuedDelete(ctx: CacheRuntime, operation: Extract) { - const { collection, key, deletedAt } = operation.params - const collectionTs = ctx.state.fieldTimestamps.get(collection.name) - if (collectionTs) { - collectionTs.delete(key) - } - if (deletedAt != null) { - ctx.state.tombstones.set({ - collection: collection.name, - key, - deletedAt, - }) - } - deleteItemNow(ctx, collection, key) -} - -function scheduleStaggeringBudgetReset(ctx: CacheRuntime) { - if (!ctx.cacheStaggering || ctx.staggeringResetTimer) { - return - } - ctx.staggeringResetTimer = setTimeout(() => { - ctx.staggeringResetTimer = undefined - ctx.staggeringBudget = ctx.cacheStaggering - if (!ctx.state.paused) { - flushQueuedOperations(ctx) - } - }, 10) -} - -function canProcessQueuedWrite(ctx: CacheRuntime) { - if (!ctx.cacheStaggering || ctx.staggeringBudget > 0) { - return true - } - scheduleStaggeringBudgetReset(ctx) - return false -} - -function consumeQueuedWrite(ctx: CacheRuntime) { - if (!ctx.cacheStaggering) { - return - } - scheduleStaggeringBudgetReset(ctx) - ctx.staggeringBudget = Math.max(0, ctx.staggeringBudget - 1) -} diff --git a/packages/vue/src/cache/signals.ts b/packages/vue/src/cache/signals.ts new file mode 100644 index 00000000..6faa66e5 --- /dev/null +++ b/packages/vue/src/cache/signals.ts @@ -0,0 +1,182 @@ +import type { StoreEngine, Unsubscribe } from '@rstore/core' +import type { ShallowRef } from 'vue' +import { shallowRef } from 'vue' + +/** Reactive bridge between engine observers and Vue effects. */ +export interface SignalRegistry { + /** Track a single item's signal, subscribing lazily. */ + trackItem: (collection: string, key: string | number) => void + /** Track a collection's visible-key-set signal. */ + trackList: (collection: string) => void + /** Track a single index bucket's signal. */ + trackIndex: (collection: string, indexKey: string, indexValue: string) => void + /** Drop an item's signal and unsubscribe its engine observer. */ + dropItem: (collection: string, key: string | number) => void + /** Drop an index bucket's signal and unsubscribe its engine observer. */ + dropIndexBucket: (collection: string, indexKey: string, indexValue: string) => void + /** Unsubscribe every registered signal. */ + dispose: () => void + /** Count of currently-tracked signals, for diagnostics + leak tests. */ + size: () => { items: number, lists: number, indexes: number } +} + +interface Signal { + /** Vue ref used as a version counter. */ + ref: ShallowRef + /** Engine observer unsubscribe callback. */ + stop: Unsubscribe +} + +/** Options for creating a Vue signal registry. */ +export interface CreateSignalRegistryOptions { + /** Engine whose observer API drives this registry. */ + engine: StoreEngine + /** Whether tracking should be disabled for server-side rendering. */ + isServer: boolean +} + +/** Create the signal registry bound to an engine. */ +export function createSignalRegistry({ engine, isServer }: CreateSignalRegistryOptions): SignalRegistry { + if (isServer) { + const noop = () => {} + return { + trackItem: noop, + trackList: noop, + trackIndex: noop, + dropItem: noop, + dropIndexBucket: noop, + dispose: noop, + size: () => ({ items: 0, lists: 0, indexes: 0 }), + } + } + + const itemSignals = new Map>() + const listSignals = new Map() + const indexSignals = new Map>>() + + /** Read a signal's version to register the current effect dependency. */ + function track(signal: Signal): void { + // eslint-disable-next-line ts/no-unused-expressions + signal.ref.value + } + + /** Track a single item and subscribe to it on first use. */ + function trackItem(collection: string, key: string | number): void { + let byKey = itemSignals.get(collection) + if (!byKey) { + byKey = new Map() + itemSignals.set(collection, byKey) + } + let signal = byKey.get(key) + if (!signal) { + const ref = shallowRef(0) + const stop = engine.observeItem(collection, key, () => ref.value++) + signal = { ref, stop } + byKey.set(key, signal) + } + track(signal) + } + + /** Track a collection list and subscribe to it on first use. */ + function trackList(collection: string): void { + let signal = listSignals.get(collection) + if (!signal) { + const ref = shallowRef(0) + const stop = engine.observeList(collection, () => ref.value++) + signal = { ref, stop } + listSignals.set(collection, signal) + } + track(signal) + } + + /** Track one collection index bucket and subscribe to it on first use. */ + function trackIndex(collection: string, indexKey: string, indexValue: string): void { + let byIndexKey = indexSignals.get(collection) + if (!byIndexKey) { + byIndexKey = new Map() + indexSignals.set(collection, byIndexKey) + } + let byValue = byIndexKey.get(indexKey) + if (!byValue) { + byValue = new Map() + byIndexKey.set(indexKey, byValue) + } + let signal = byValue.get(indexValue) + if (!signal) { + const ref = shallowRef(0) + const stop = engine.observeIndex(collection, indexKey, indexValue, () => ref.value++) + signal = { ref, stop } + byValue.set(indexValue, signal) + } + track(signal) + } + + /** Drop an item signal and unsubscribe its observer. */ + function dropItem(collection: string, key: string | number): void { + const byKey = itemSignals.get(collection) + const signal = byKey?.get(key) + if (signal) { + signal.stop() + byKey!.delete(key) + } + } + + /** Drop an index signal and unsubscribe its observer. */ + function dropIndexBucket(collection: string, indexKey: string, indexValue: string): void { + const signal = indexSignals.get(collection)?.get(indexKey)?.get(indexValue) + if (signal) { + signal.stop() + indexSignals.get(collection)!.get(indexKey)!.delete(indexValue) + } + } + + /** Dispose every registered signal subscription. */ + function dispose(): void { + for (const byKey of itemSignals.values()) { + for (const signal of byKey.values()) { + signal.stop() + } + } + for (const signal of listSignals.values()) { + signal.stop() + } + for (const byIndexKey of indexSignals.values()) { + for (const byValue of byIndexKey.values()) { + for (const signal of byValue.values()) { + signal.stop() + } + } + } + itemSignals.clear() + listSignals.clear() + indexSignals.clear() + } + + /** + * Count tracked signals across the three registries (item count is summed + * across collections; index count is summed across buckets). + */ + function size(): { items: number, lists: number, indexes: number } { + let items = 0 + for (const byKey of itemSignals.values()) { + items += byKey.size + } + let indexes = 0 + for (const byIndexKey of indexSignals.values()) { + for (const byValue of byIndexKey.values()) { + indexes += byValue.size + } + } + return { items, lists: listSignals.size, indexes } + } + + return { + trackItem, + trackList, + trackIndex, + dropItem, + dropIndexBucket, + dispose, + size, + } +} diff --git a/packages/vue/src/cache/types.ts b/packages/vue/src/cache/types.ts index 83a04723..7f481981 100644 --- a/packages/vue/src/cache/types.ts +++ b/packages/vue/src/cache/types.ts @@ -1,78 +1,49 @@ -import type { TombstoneStore } from '@rstore/core' -import type { Cache, CacheLayer, Collection, CollectionDefaults, CustomCacheState, CustomHookMeta, FieldTimestamps, ResolvedCollection, ResolvedCollectionItem, StoreSchema, WrappedItem } from '@rstore/shared' +import type { StoreEngine, TombstoneGcOptions } from '@rstore/core' +import type { CacheLayer, Collection, CollectionDefaults, CustomHookMeta, ResolvedCollection, ResolvedCollectionItem, StoreSchema, WrappedItem } from '@rstore/shared' import type { Ref } from 'vue' import type { WrappedItemMetadata } from '../item' import type { VueStore } from '../store' +import type { SignalRegistry } from './signals' -/** Cache operations delayed while the cache is paused or write staggering is active. */ -export type QueuedOperation - = | { type: 'writeItem', params: Parameters[0] } - | { type: 'writeItems', params: Parameters[0], index: number } - | { type: 'deleteItem', params: Parameters[0] } - | { type: 'addLayer', layer: Parameters[0] } - | { type: 'removeLayer', layerId: Parameters[0] } - | { type: 'setState', state: CustomCacheState } - | { type: 'clear' } - -/** Reactive state owned by the Vue cache implementation. */ -export interface InternalCacheState { - /** Query markers used to know whether list results were fetched before. */ - markers: Record - /** Collection item state by collection name. */ - collections: Record>> - /** Relation/index lookups by collection, index key, and index value. */ - collectionIndexes: Map>>>> - /** Module state by module cache key. */ - modules: Record> - /** Last hook metadata for query ids. */ +/** Bridge-owned state surfaced to Vue internals. */ +export interface VueCacheState { + /** Cached raw page data keyed by page id, used by query pagination. */ + pageRefs: Map + /** Live per-query metadata, backed by the engine for SSR round-trips. */ queryMeta: Record - /** Saved page references for query pagination. */ - pageRefs: Map }> - /** Whether writes should be queued instead of applied immediately. */ - paused: boolean - /** Pending cache operations. */ - queue: QueuedOperation[] - /** Per-field timestamps for CRDT field-level LWW merge. */ - fieldTimestamps: Map> - /** Deletion tombstones used to reject stale writes after deletes. */ - tombstones: TombstoneStore } +/** Options used to create the Vue cache bridge. */ export interface CreateCacheOptions< TSchema extends StoreSchema, TCollectionDefaults extends CollectionDefaults, > { /** Resolve the owning Vue store. */ getStore: () => VueStore - /** Maximum number of queued writes processed per 10ms. */ + /** Maximum number of queued writes processed per 10ms by the engine. */ cacheStaggering?: number /** Auto-GC settings for the per-cache tombstone store. */ - tombstoneGc?: false | { - /** Sweep interval in ms. Defaults to 60_000. */ - intervalMs?: number - /** Drop tombstones older than this many ms. Defaults to 86_400_000. */ - ttlMs?: number - } + tombstoneGc?: TombstoneGcOptions /** Whether this cache belongs to a server-side store instance. */ isServer?: boolean } -/** Mutable runtime shared by cache helper modules. */ +/** Mutable runtime shared by the Vue cache bridge modules. */ export interface CacheRuntime< TSchema extends StoreSchema = StoreSchema, TCollectionDefaults extends CollectionDefaults = CollectionDefaults, > { /** Resolve the owning Vue store. */ getStore: () => VueStore - /** Write staggering budget. */ - cacheStaggering: number - /** Internal reactive cache state. */ - state: InternalCacheState - /** Stop the tombstone GC timer, when one is active. */ - stopTombstoneGc?: () => void - /** Optimistic/cache layers by collection name. */ + /** Framework-agnostic engine that owns storage and write semantics. */ + engine: StoreEngine + /** Bridge-owned state used by Vue query helpers. */ + state: VueCacheState + /** Vue signal registry subscribed to engine observers. */ + signals: SignalRegistry + /** Devtools layer mirror by collection name. */ layers: Record> - /** Collection lookup for layer ids. */ + /** Devtools layer id to collection lookup. */ layerIdToCollectionName: Record /** Wrapped item proxies by wrap key. */ wrappedItems: Map> @@ -80,29 +51,28 @@ export interface CacheRuntime< wrappedItemsMetadata: Map> /** Wrap keys created for each layer. */ wrappedItemKeysPerLayer: Map> - /** Cached overlay collection states. */ - collectionStateCache: Map> - /** Reactivity markers for cached overlay states. */ - collectionStateCacheReactivityMarker: Map> - /** Whether queued operations are currently being flushed. */ - isFlushingQueue: boolean - /** Remaining writes before the next staggering pause. */ - staggeringBudget: number - /** Timer that resets the staggering budget. */ - staggeringResetTimer?: ReturnType } +/** Private Vue cache surface consumed by existing Vue internals and devtools. */ export interface VueCachePrivate { _private: { - state: InternalCacheState + /** Bridge-owned cache state. */ + state: VueCacheState + /** Wrapped item proxies by wrap key. */ wrappedItems: Map> + /** Wrapped item metadata by wrap key. */ wrappedItemsMetadata: Map> + /** Return an existing wrapped item or create one for the raw item. */ getWrappedItem: ( collection: ResolvedCollection, item: ResolvedCollectionItem | null | undefined, noCache?: boolean, ) => WrappedItem | undefined + /** Devtools layer mirror by collection name. */ layers: Record> + /** Ensure a devtools layer mirror exists for a collection. */ ensureLayersForCollection: (collectionName: string) => Ref + /** Signal registry used by diagnostics and leak tests. */ + signals: SignalRegistry } } diff --git a/packages/vue/src/cache/wrapped.ts b/packages/vue/src/cache/wrapped.ts index b82c5b02..658639c9 100644 --- a/packages/vue/src/cache/wrapped.ts +++ b/packages/vue/src/cache/wrapped.ts @@ -2,8 +2,7 @@ import type { Collection, CollectionDefaults, ResolvedCollection, ResolvedCollec import type { CacheRuntime } from './types' import { computed, shallowRef } from 'vue' import { wrapItem } from '../item' -import { addWrappedItemKeyToLayer, ensureCollectionRef, getItemKey, getItemWrapKey } from './context' -import { deleteItemNow } from './writes' +import { addWrappedItemKeyToLayer, getItemKey, getItemWrapKey } from './context' /** Return the cached wrapped proxy for an item, creating it when needed. */ export function getWrappedItem< @@ -48,10 +47,14 @@ export function getWrappedItem< wrappedItem = wrapItem({ store: ctx.getStore(), collection, - item: computed(() => layer - ? item - : ensureCollectionRef(ctx, collection.name).value[key] ?? item), + item: layer + ? shallowRef(item) + : computed(() => { + ctx.signals.trackItem(collection.name, key) + return ctx.engine.readItemRaw({ collection, key }) ?? item + }), metadata, + seed: item, }) ctx.wrappedItems.set(wrapKey, wrappedItem) addWrappedItemKeyToLayer(ctx, item.$layer, wrapKey) @@ -69,7 +72,7 @@ export function garbageCollectItem( return } const key = getItemKey(collection, item) - deleteItemNow(ctx, collection, key) + ctx.engine.garbageCollectKey(collection, key) const store = ctx.getStore() store.$hooks.callHookSync('itemGarbageCollect', { store, diff --git a/packages/vue/src/cache/writes.ts b/packages/vue/src/cache/writes.ts deleted file mode 100644 index c41f35e5..00000000 --- a/packages/vue/src/cache/writes.ts +++ /dev/null @@ -1,272 +0,0 @@ -import type { Cache, Collection, CollectionDefaults, CustomCacheState, ResolvedCollection, StoreSchema } from '@rstore/shared' -import type { Ref } from 'vue' -import type { CacheRuntime } from './types' -import { isKeyDefined, mergeItemFields, shouldResurrect } from '@rstore/core' -import { pickNonSpecialProps } from '@rstore/shared' -import { markRaw, ref, shallowRef } from 'vue' -import { ensureCollectionRef, getItemWrapKey, invalidateCollectionStateCache, mark } from './context' -import { removeItemIndexes, updateItemIndexes } from './indexes' - -/** Delete an item immediately without going through the pause queue. */ -export function deleteItemNow( - ctx: CacheRuntime, - collection: ResolvedCollection, - key: string | number, -) { - const collectionState = ensureCollectionRef(ctx, collection.name).value - const item = collectionState[key] - if (!item) { - return - } - - removeItemIndexes(ctx, collection, key, item) - invalidateCollectionStateCache(ctx, collection.name) - delete collectionState[key] - invalidateCollectionStateCache(ctx, collection.name) - - const wrapKey = getItemWrapKey(collection, key, undefined) - ctx.wrappedItems.delete(wrapKey) - ctx.wrappedItemsMetadata.delete(wrapKey) - - const store = ctx.getStore() - store.$hooks.callHookSync('afterCacheWrite', { - store, - meta: {}, - collection, - key, - operation: 'delete', - }) -} - -/** Write a related nested item before linking it from its parent. */ -export function writeItemForRelationNow({ - ctx, - parentCollection, - relationKey, - relation, - childItem, - meta, -}: Parameters[0] & { ctx: CacheRuntime }) { - const store = ctx.getStore() - const possibleCollections = Object.keys(relation.to) - const nestedItemCollection = store.$getCollection(childItem, possibleCollections) - if (!nestedItemCollection) { - throw new Error(`Could not determine type for relation ${parentCollection.name}.${String(relationKey)}`) - } - const nestedKey = nestedItemCollection.getKey(childItem) - if (!isKeyDefined(nestedKey)) { - throw new Error(`Could not determine key for relation ${parentCollection.name}.${String(relationKey)}`) - } - - writeItemNow(ctx, { - collection: nestedItemCollection, - key: nestedKey, - item: childItem, - meta, - }) -} - -/** Write an item immediately without going through the pause queue. */ -export function writeItemNow(ctx: CacheRuntime, params: Parameters[0]) { - const { collection, key, item, marker, fromWriteItems, meta } = params - const tomb = ctx.state.tombstones.get(collection.name, key) - if (tomb) { - if (params.fieldTimestamps && !shouldResurrect(tomb, params.fieldTimestamps)) { - return - } - ctx.state.tombstones.clear(collection.name, key) - } - - invalidateCollectionStateCache(ctx, collection.name) - - const collectionState = ensureCollectionRef(ctx, collection.name).value - if (Object.isFrozen(item)) { - collectionState[key] = item - } - else { - writeMutableItem(ctx, params, collectionState) - } - invalidateCollectionStateCache(ctx, collection.name) - - if (marker) { - mark(ctx, marker) - } - - if (meta?.$queryTracking) { - meta.$queryTracking.items[collection.name] ??= new Set() - meta.$queryTracking.items[collection.name]!.add(key) - } - - if (!fromWriteItems) { - const store = ctx.getStore() - store.$hooks.callHookSync('afterCacheWrite', { - store, - meta: {}, - collection, - key, - result: [item], - marker, - operation: 'write', - }) - } -} - -/** Replace the entire cache state immediately. */ -export function setStateNow(ctx: CacheRuntime, value: CustomCacheState) { - ctx.state.markers = value.markers || {} - - const newCollectionsState: Record>> = {} - for (const collectionName in value.collections) { - const collection = ctx.getStore().$collections.find(c => c.name === collectionName) - if (!collection) { - continue - } - const incomingCollectionState = value.collections[collectionName as keyof typeof value.collections] as Record - const collectionState = newCollectionsState[collectionName] = ref>({}) - for (const key in incomingCollectionState) { - const item = incomingCollectionState[key] - if (item) { - const existing = collectionState.value[key] - collectionState.value[key] = Object.isFrozen(item) ? item : shallowRef(item) - updateItemIndexes(ctx, collection, key, existing, item) - } - } - } - ctx.state.collections = newCollectionsState - - const newModulesState: Record>> = {} - for (const moduleKey in value.modules) { - newModulesState[moduleKey] = ref(value.modules[moduleKey]) - } - ctx.state.modules = newModulesState - - ctx.wrappedItems.clear() - ctx.wrappedItemsMetadata.clear() - ctx.collectionStateCache.clear() - - const store = ctx.getStore() - store.$hooks.callHookSync('afterCacheReset', { - store, - meta: {}, - }) - - ctx.state.queryMeta = value.queryMeta || {} -} - -/** Clear all cache data immediately. */ -export function clearNow(ctx: CacheRuntime) { - ctx.state.markers = {} - for (const collectionName in ctx.state.collections) { - ctx.state.collections[collectionName]!.value = {} - } - for (const moduleKey in ctx.state.modules) { - ctx.state.modules[moduleKey]!.value = {} - } - ctx.wrappedItems.clear() - ctx.wrappedItemsMetadata.clear() - ctx.collectionStateCache.clear() - ctx.state.collectionIndexes.clear() - ctx.state.fieldTimestamps.clear() - const tombIds = Array.from(ctx.state.tombstones.entries(), ([, t]) => t) - for (const t of tombIds) { - ctx.state.tombstones.clear(t.collection, t.key) - } - - const store = ctx.getStore() - store.$hooks.callHookSync('afterCacheReset', { - store, - meta: {}, - }) -} - -function writeMutableItem(ctx: CacheRuntime, params: Parameters[0], collectionState: Record) { - const { collection, key, item } = params - const rawData = pickNonSpecialProps(item, true) - const data: Record = {} - - for (const field in rawData) { - if (field in collection.relations) { - writeRelationField(ctx, params, field, rawData[field]) - } - else { - data[field] = rawData[field] - } - } - - const existing = collectionState[key] - updateItemIndexes(ctx, collection, key, existing, data) - - if (!existing) { - collectionState[key] = shallowRef(markRaw(data)) - if (params.fieldTimestamps) { - ensureCollectionTimestamps(ctx, collection.name).set(key, { ...params.fieldTimestamps }) - } - } - else if (params.fieldTimestamps) { - mergeTimestampedItem(ctx, params, collectionState, existing, data) - } - else { - collectionState[key] = markRaw({ - ...existing, - ...data, - }) - } -} - -function writeRelationField(ctx: CacheRuntime, params: Parameters[0], field: string, rawItem: any) { - const relation = params.collection.relations[field] - if (!rawItem || !relation) { - return - } - if (relation.many && !Array.isArray(rawItem)) { - throw new Error(`Expected array for relation ${params.collection.name}.${field}`) - } - if (!relation.many && Array.isArray(rawItem)) { - throw new Error(`Expected object for relation ${params.collection.name}.${field}`) - } - - const items = Array.isArray(rawItem) ? rawItem : [rawItem] - for (const nestedItem of items) { - writeItemForRelationNow({ - ctx, - parentCollection: params.collection, - relationKey: field as never, - relation, - childItem: nestedItem, - meta: params.meta, - }) - } -} - -function mergeTimestampedItem(ctx: CacheRuntime, params: Parameters[0], collectionState: Record, existing: any, data: any) { - const collectionTs = ensureCollectionTimestamps(ctx, params.collection.name) - const localTimestamps = collectionTs.get(params.key) ?? {} - const { merged, mergedTimestamps, conflicts } = mergeItemFields( - existing, - data, - localTimestamps, - params.fieldTimestamps!, - ) - collectionTs.set(params.key, mergedTimestamps) - collectionState[params.key] = markRaw(merged) - - if (conflicts.length > 0) { - const store = ctx.getStore() - store.$hooks.callHookSync('cacheConflict', { - store, - meta: {}, - collection: params.collection, - key: params.key, - conflicts, - }) - } -} - -function ensureCollectionTimestamps(ctx: CacheRuntime, collectionName: string) { - let collectionTs = ctx.state.fieldTimestamps.get(collectionName) - if (!collectionTs) { - collectionTs = new Map() - ctx.state.fieldTimestamps.set(collectionName, collectionTs) - } - return collectionTs -} diff --git a/packages/vue/src/item.ts b/packages/vue/src/item.ts index b01d12fd..94762962 100644 --- a/packages/vue/src/item.ts +++ b/packages/vue/src/item.ts @@ -16,6 +16,17 @@ export interface WrapItemOptions< collection: ResolvedCollection item: Ref> metadata: WrappedItemMetadata + /** + * Non-reactive snapshot used to seed the proxy target and the `isFrozen` + * check. When omitted, `item.value` is read once at construction. + * + * For engine-backed items, `item` is a tracking `computed` that registers a + * fine-grained signal on read. Reading it during construction would leak that + * dependency into whatever effect first wraps the item (e.g. a list query), + * defeating the per-item granularity. Passing the raw item here keeps the + * computed lazy: it is only evaluated when an actual field is accessed. + */ + seed?: ResolvedCollectionItem } export function wrapItem< @@ -27,6 +38,7 @@ export function wrapItem< collection, item, metadata, + seed, }: WrapItemOptions): WrappedItem { function getApi(): VueCollectionApi> { return store[collection.name as keyof typeof store] as any @@ -34,9 +46,12 @@ export function wrapItem< const cache = store.$cache as unknown as Cache & VueCachePrivate - const isFrozen = Object.isFrozen(item.value) + // Construct from the non-reactive seed (falling back to a single `item.value` + // read) so a tracking `computed` source is not evaluated here — see `seed`. + const target = seed ?? item.value + const isFrozen = Object.isFrozen(target) - const proxy = new Proxy(item.value, { + const proxy = new Proxy(target, { get: (proxyTarget, key) => { switch (key) { case '$collection': diff --git a/packages/vue/src/types.ts b/packages/vue/src/types.ts index 02a10f00..d94c9bfe 100644 --- a/packages/vue/src/types.ts +++ b/packages/vue/src/types.ts @@ -1,16 +1,9 @@ import type { UpdateOptions } from '@rstore/core' -import type { Collection, CollectionDefaults, CustomHookMeta, ResolvedCollectionItem, StandardSchemaV1, StoreSchema } from '@rstore/shared' +import type { Collection, CollectionDefaults, ResolvedCollectionItem, StandardSchemaV1, StoreSchema } from '@rstore/shared' import type { VueUpdateFormObject } from './form' import type { WrappedItemMetadata } from './item' declare module '@rstore/shared' { - export interface CustomCacheState { - markers: Record - collections: Record> - modules: Record - queryMeta: Record - } - export interface MutationSpecialProps { $loading: boolean $error: Error | null diff --git a/packages/vue/test/granularity.spec.ts b/packages/vue/test/granularity.spec.ts new file mode 100644 index 00000000..7fc4be44 --- /dev/null +++ b/packages/vue/test/granularity.spec.ts @@ -0,0 +1,232 @@ +import { describe, expect, it, vi } from 'vitest' +import { effectScope, watchEffect } from 'vue' +import { createStore } from '../src' + +/** + * These tests lock in the headline performance property of the engine bridge: + * fine-grained signals mean a single-item field write only re-runs the reactive + * scopes that actually read that item — not every live list query on the + * collection (the old per-collection marker bumped on every write). + */ +describe('cache reactivity granularity', () => { + it('a field write re-runs item readers but not list readers', async () => { + const store = await createStore({ + schema: [{ name: 'Todo' }], + plugins: [], + }) + const cache = store.$cache + const collection = store.$collections[0]! + + cache.writeItem({ collection, key: 1, item: { id: 1, label: 'a' }, marker: 'all' }) + cache.writeItem({ collection, key: 2, item: { id: 2, label: 'b' }, marker: 'all' }) + + const listSpy = vi.fn() + const itemSpy = vi.fn() + const scope = effectScope() + scope.run(() => { + // Reads only the visible key set length -> tracks the list signal only. + watchEffect(() => { + listSpy(cache.readItems({ collection, marker: 'all' }).length) + }, { flush: 'sync' }) + // Reads item 1's field -> tracks item 1's signal. + watchEffect(() => { + const item = cache.readItem({ collection, key: 1 }) + itemSpy(item ? (item as any).label : undefined) + }, { flush: 'sync' }) + }) + + expect(listSpy).toHaveBeenCalledTimes(1) + expect(itemSpy).toHaveBeenCalledTimes(1) + + // Field edit to item 1: only the item reader should re-run. + cache.writeItem({ collection, key: 1, item: { id: 1, label: 'changed' } }) + expect(itemSpy).toHaveBeenCalledTimes(2) + expect(itemSpy).toHaveBeenLastCalledWith('changed') + // The win: the list reader did NOT re-run on a single-item field write. + expect(listSpy).toHaveBeenCalledTimes(1) + + // Inserting a new item changes the visible set: the list reader re-runs, + // but the unrelated item-1 reader does not. + cache.writeItem({ collection, key: 3, item: { id: 3, label: 'c' }, marker: 'all' }) + expect(listSpy).toHaveBeenCalledTimes(2) + expect(itemSpy).toHaveBeenCalledTimes(2) + + scope.stop() + cache.dispose() + }) + + it('a write to one item does not re-run readers of a different item', async () => { + const store = await createStore({ + schema: [{ name: 'Todo' }], + plugins: [], + }) + const cache = store.$cache + const collection = store.$collections[0]! + + cache.writeItem({ collection, key: 1, item: { id: 1, label: 'a' } }) + cache.writeItem({ collection, key: 2, item: { id: 2, label: 'b' } }) + + const item1Spy = vi.fn() + const item2Spy = vi.fn() + const scope = effectScope() + scope.run(() => { + watchEffect(() => { + item1Spy((cache.readItem({ collection, key: 1 }) as any)?.label) + }, { flush: 'sync' }) + watchEffect(() => { + item2Spy((cache.readItem({ collection, key: 2 }) as any)?.label) + }, { flush: 'sync' }) + }) + + expect(item1Spy).toHaveBeenCalledTimes(1) + expect(item2Spy).toHaveBeenCalledTimes(1) + + cache.writeItem({ collection, key: 1, item: { id: 1, label: 'a2' } }) + expect(item1Spy).toHaveBeenCalledTimes(2) + // Reader of item 2 is untouched by a write to item 1. + expect(item2Spy).toHaveBeenCalledTimes(1) + + scope.stop() + cache.dispose() + }) + + it('a write to one collection does not re-run list readers of another', async () => { + const store = await createStore({ + schema: [{ name: 'Todo' }, { name: 'User' }], + plugins: [], + }) + const cache = store.$cache + const todo = store.$collections.find(c => c.name === 'Todo')! + const user = store.$collections.find(c => c.name === 'User')! + + cache.writeItem({ collection: todo, key: 1, item: { id: 1 }, marker: 'todos' }) + cache.writeItem({ collection: user, key: 1, item: { id: 1 }, marker: 'users' }) + + const todoListSpy = vi.fn() + const userListSpy = vi.fn() + const scope = effectScope() + scope.run(() => { + watchEffect(() => { + todoListSpy(cache.readItems({ collection: todo, marker: 'todos' }).length) + }, { flush: 'sync' }) + watchEffect(() => { + userListSpy(cache.readItems({ collection: user, marker: 'users' }).length) + }, { flush: 'sync' }) + }) + + expect(todoListSpy).toHaveBeenCalledTimes(1) + expect(userListSpy).toHaveBeenCalledTimes(1) + + cache.writeItem({ collection: todo, key: 2, item: { id: 2 }, marker: 'todos' }) + expect(todoListSpy).toHaveBeenCalledTimes(2) + // The User list reader is unaffected by a write to the Todo collection. + expect(userListSpy).toHaveBeenCalledTimes(1) + + scope.stop() + cache.dispose() + }) + + it('writing a related item re-runs a parent that reads the relation', async () => { + const store = await createStore({ + schema: [ + { + name: 'Post', + relations: { + comments: { + many: true, + to: { + Comment: { + on: { postId: 'id' }, + }, + }, + }, + }, + }, + { + name: 'Comment', + }, + ], + plugins: [], + }) + const cache = store.$cache + const post = store.$collections.find(c => c.name === 'Post')! + const comment = store.$collections.find(c => c.name === 'Comment')! + + cache.writeItem({ collection: post, key: 1, item: { id: 1 } }) + cache.writeItem({ collection: comment, key: 1, item: { id: 1, postId: 1 } }) + + const relationSpy = vi.fn() + const scope = effectScope() + scope.run(() => { + watchEffect(() => { + const p = cache.readItem({ collection: post, key: 1 }) as any + relationSpy(p?.comments?.length) + }, { flush: 'sync' }) + }) + + expect(relationSpy).toHaveBeenCalledTimes(1) + expect(relationSpy).toHaveBeenLastCalledWith(1) + + // Writing a new related comment must re-run the parent's relation reader + // via the index-bucket signal. + cache.writeItem({ collection: comment, key: 2, item: { id: 2, postId: 1 } }) + expect(relationSpy).toHaveBeenCalledTimes(2) + expect(relationSpy).toHaveBeenLastCalledWith(2) + + scope.stop() + cache.dispose() + }) + + it('frees an item signal + its engine subscription when the item is deleted', async () => { + const store = await createStore({ schema: [{ name: 'Todo' }], plugins: [] }) + const cache = store.$cache + const collection = store.$collections[0]! + cache.writeItem({ collection, key: 1, item: { id: 1, label: 'a' } }) + + const scope = effectScope() + scope.run(() => { + watchEffect(() => { + void (cache.readItem({ collection, key: 1 }) as any)?.label + }, { flush: 'sync' }) + }) + + const signals = (cache as any)._private.signals + expect(signals.size().items).toBe(1) + + // The signal outlives the effect: nothing reclaims it implicitly. + scope.stop() + expect(signals.size().items).toBe(1) + + // Deleting the item drops its signal (and unsubscribes the engine observer). + cache.deleteItem({ collection, key: 1 }) + expect(signals.size().items).toBe(0) + + cache.dispose() + }) + + it('disposes every signal on cache reset (clear)', async () => { + const store = await createStore({ schema: [{ name: 'Todo' }], plugins: [] }) + const cache = store.$cache + const collection = store.$collections[0]! + cache.writeItem({ collection, key: 1, item: { id: 1, label: 'a' }, marker: 'all' }) + + const scope = effectScope() + scope.run(() => { + watchEffect(() => { + void cache.readItems({ collection, marker: 'all' }).length + void (cache.readItem({ collection, key: 1 }) as any)?.label + }, { flush: 'sync' }) + }) + + const signals = (cache as any)._private.signals + expect(signals.size().items).toBe(1) + expect(signals.size().lists).toBe(1) + + scope.stop() + cache.clear() + // onReset disposed every signal + its engine subscription. + expect(signals.size()).toEqual({ items: 0, lists: 0, indexes: 0 }) + + cache.dispose() + }) +}) diff --git a/packages/vue/test/signals.spec.ts b/packages/vue/test/signals.spec.ts new file mode 100644 index 00000000..1b537f19 --- /dev/null +++ b/packages/vue/test/signals.spec.ts @@ -0,0 +1,96 @@ +import type { StoreEngine } from '@rstore/core' +import { describe, expect, it, vi } from 'vitest' +import { createSignalRegistry } from '../src/cache/signals' + +/** + * Build a fake engine that records observer subscriptions + unsubscriptions, + * so we can assert the registry frees them. Only the three `observe*` methods + * the registry touches are implemented. + */ +function fakeEngine() { + const stops = { item: vi.fn(), list: vi.fn(), index: vi.fn() } + const engine = { + observeItem: vi.fn(() => stops.item), + observeList: vi.fn(() => stops.list), + observeIndex: vi.fn(() => stops.index), + } as unknown as StoreEngine + return { engine, stops } +} + +describe('signal registry', () => { + it('subscribes lazily on first track and reuses the signal by key', () => { + const { engine } = fakeEngine() + const reg = createSignalRegistry({ engine, isServer: false }) + + reg.trackItem('User', 1) + reg.trackItem('User', 1) + + expect(engine.observeItem).toHaveBeenCalledTimes(1) + }) + + it('unsubscribes and frees the item signal on dropItem', () => { + const { engine, stops } = fakeEngine() + const reg = createSignalRegistry({ engine, isServer: false }) + + reg.trackItem('User', 1) + reg.dropItem('User', 1) + + expect(stops.item).toHaveBeenCalledTimes(1) + expect(reg.size().items).toBe(0) + // Tracking again creates a fresh subscription (the old signal was freed). + reg.trackItem('User', 1) + expect(engine.observeItem).toHaveBeenCalledTimes(2) + }) + + it('unsubscribes the index bucket on dropIndexBucket', () => { + const { engine, stops } = fakeEngine() + const reg = createSignalRegistry({ engine, isServer: false }) + + reg.trackIndex('Comment', 'postId', 'p1') + reg.dropIndexBucket('Comment', 'postId', 'p1') + + expect(stops.index).toHaveBeenCalledTimes(1) + expect(reg.size().indexes).toBe(0) + }) + + it('disposes every subscription and resets counts', () => { + const { engine, stops } = fakeEngine() + const reg = createSignalRegistry({ engine, isServer: false }) + + reg.trackItem('User', 1) + reg.trackList('User') + reg.trackIndex('Comment', 'postId', 'p1') + reg.dispose() + + expect(stops.item).toHaveBeenCalledTimes(1) + expect(stops.list).toHaveBeenCalledTimes(1) + expect(stops.index).toHaveBeenCalledTimes(1) + expect(reg.size()).toEqual({ items: 0, lists: 0, indexes: 0 }) + }) + + it('reports tracked signal counts via size()', () => { + const { engine } = fakeEngine() + const reg = createSignalRegistry({ engine, isServer: false }) + + reg.trackItem('User', 1) + reg.trackItem('User', 2) + reg.trackList('User') + reg.trackIndex('Comment', 'postId', 'p1') + + expect(reg.size()).toEqual({ items: 2, lists: 1, indexes: 1 }) + reg.dropItem('User', 1) + expect(reg.size().items).toBe(1) + }) + + it('is a no-op on the server', () => { + const { engine } = fakeEngine() + const reg = createSignalRegistry({ engine, isServer: true }) + + reg.trackItem('User', 1) + reg.trackList('User') + reg.trackIndex('Comment', 'postId', 'p1') + + expect(engine.observeItem).not.toHaveBeenCalled() + expect(reg.size()).toEqual({ items: 0, lists: 0, indexes: 0 }) + }) +})